koishi-plugin-chatluna-character-buffer-backup 0.1.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/LICENSE +21 -0
- package/README.md +51 -0
- package/index.js +168 -0
- package/lib.js +40 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 妖祀
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# koishi-plugin-chatluna-character-buffer-backup
|
|
2
|
+
|
|
3
|
+
> 把 [chatluna-character](https://github.com/ChatLunaLab/chatluna-character) **只存在内存里**的近期对话缓冲
|
|
4
|
+
> 持久化到数据库,**重启 / 控制台保存配置后自动灌回**,避免 bot 忘记刚刚聊到一半的上下文。
|
|
5
|
+
|
|
6
|
+
chatluna-character 的「角色对话」近期消息缓冲只放在内存(`MessageCollector`)。一旦重启 koishi、
|
|
7
|
+
或在控制台改配置触发热重载,这段缓冲就清空了——bot 会突然「忘了刚才在聊什么」。本插件:
|
|
8
|
+
|
|
9
|
+
- 每次收到新消息 → 防抖把该会话的缓冲快照写进数据库;
|
|
10
|
+
- `dispose`(正常重启 / 控制台保存)时再补一次刷盘,抓上 bot 的最后一条回复;
|
|
11
|
+
- 启动就绪后,把数据库里**够新**的存档灌回内存缓冲(按 messageId 去重合并、尾部截断到上限);
|
|
12
|
+
- 收到 chatluna-character 的「清除记忆」事件 → 同步删存档,保持诚实。
|
|
13
|
+
|
|
14
|
+
## 依赖
|
|
15
|
+
|
|
16
|
+
- `koishi` ^4.17
|
|
17
|
+
- **`koishi-plugin-chatluna-character`**(提供 `chatluna_character` 服务,本插件的硬依赖)——必须先装好并启用。
|
|
18
|
+
- 一个 koishi `database` 实现(如 `koishi-plugin-database-sqlite`),用来存档。
|
|
19
|
+
|
|
20
|
+
> ⚠️ 本插件读写 chatluna-character 的内部消息缓冲(私有字段 `_messages` 与 `chatluna_character/*` 事件)。
|
|
21
|
+
> 已做防御:字段结构不符时只告警、不崩溃。但若 chatluna-character 有**大版本改动**改了这些内部结构,
|
|
22
|
+
> 恢复可能静默失效——届时更新本插件即可。建议与你实际测试过的 chatluna-character 版本搭配使用。
|
|
23
|
+
|
|
24
|
+
## 启用
|
|
25
|
+
|
|
26
|
+
1. 在 Koishi 控制台的**插件市场**搜 `chatluna-character-buffer-backup` 安装并启用;或命令行
|
|
27
|
+
```bash
|
|
28
|
+
npm i koishi-plugin-chatluna-character-buffer-backup
|
|
29
|
+
```
|
|
30
|
+
(从源码用:放到 koishi app 的 `external/` 下,再 `npm i ./external/koishi-plugin-chatluna-character-buffer-backup`。)
|
|
31
|
+
2. 确保 `chatluna-character` 与一个 `database` 插件已启用,然后启用本插件即可——无需改预设。
|
|
32
|
+
3. 重启或保存配置后,日志出现 `上下文恢复完成:N 个会话` 即生效。
|
|
33
|
+
|
|
34
|
+
## 配置项
|
|
35
|
+
|
|
36
|
+
| 配置 | 默认 | 说明 |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `debounceMs` | `3000` | 收到消息后多久把缓冲快照写库(毫秒,防抖;最小 500)。 |
|
|
39
|
+
| `maxAgeHours` | `24` | 启动恢复时,超过这个小时数的旧存档不再灌回(`0` = 不限)。 |
|
|
40
|
+
| `restoreCap` | `100` | 每个会话恢复的最大消息条数(对齐 chatluna-character 的 `maxMessages` 上限,3–100)。 |
|
|
41
|
+
| `debug` | `false` | 打印每次快照 / 恢复的条数。 |
|
|
42
|
+
|
|
43
|
+
## 工作原理(简)
|
|
44
|
+
|
|
45
|
+
- 存档表 `chatluna_character_buffer`,主键 = 会话 key(`private:<userId>` / `group:<guildId>`,与 chatluna-character 内部键一致)。
|
|
46
|
+
- 恢复时把数据库存档与当前内存缓冲按 messageId 去重合并、按时间升序、尾部保留最新 `restoreCap` 条。
|
|
47
|
+
- 全程 try/catch 兜底:存档读写失败、字段结构变化都只告警,不影响 bot 正常对话。
|
|
48
|
+
|
|
49
|
+
## 许可
|
|
50
|
+
|
|
51
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const { Schema } = require('koishi')
|
|
2
|
+
const { deriveKey, mergeById, isFresh } = require('./lib')
|
|
3
|
+
|
|
4
|
+
exports.name = 'chatluna-character-buffer-backup'
|
|
5
|
+
|
|
6
|
+
exports.inject = ['database', 'chatluna_character']
|
|
7
|
+
|
|
8
|
+
exports.Config = Schema.object({
|
|
9
|
+
debounceMs: Schema.number()
|
|
10
|
+
.default(3000)
|
|
11
|
+
.min(500)
|
|
12
|
+
.description('收到消息后多久把缓冲快照写入数据库(毫秒,防抖)'),
|
|
13
|
+
maxAgeHours: Schema.number()
|
|
14
|
+
.default(24)
|
|
15
|
+
.min(0)
|
|
16
|
+
.description('启动恢复时,超过这个小时数的旧存档不再灌回(0 = 不限)'),
|
|
17
|
+
restoreCap: Schema.number()
|
|
18
|
+
.default(100)
|
|
19
|
+
.min(3)
|
|
20
|
+
.max(100)
|
|
21
|
+
.description('每个会话恢复的最大消息条数(对齐 chatluna-character 的 maxMessages 上限)'),
|
|
22
|
+
debug: Schema.boolean().default(false).description('打印每次快照 / 恢复的条数')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const TABLE = 'chatluna_character_buffer'
|
|
26
|
+
|
|
27
|
+
exports.apply = (ctx, config) => {
|
|
28
|
+
const logger = ctx.logger('chatluna-character-buffer-backup')
|
|
29
|
+
const svc = ctx.chatluna_character
|
|
30
|
+
|
|
31
|
+
ctx.database.extend(
|
|
32
|
+
TABLE,
|
|
33
|
+
{
|
|
34
|
+
sessionKey: { type: 'string', length: 255 },
|
|
35
|
+
messages: { type: 'text', nullable: true },
|
|
36
|
+
updatedAt: { type: 'timestamp', nullable: false, initial: new Date() }
|
|
37
|
+
},
|
|
38
|
+
{ autoInc: false, primary: 'sessionKey', unique: ['sessionKey'] }
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
// ---- 读 chatluna-character 内部缓冲(带 guard)----
|
|
42
|
+
function getBuffer(key) {
|
|
43
|
+
try {
|
|
44
|
+
const arr = svc.getMessages(key)
|
|
45
|
+
return Array.isArray(arr) ? arr : []
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return []
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 写私有字段 _messages:结构不对就告警 no-op,绝不让插件崩。
|
|
52
|
+
function setBuffer(key, arr) {
|
|
53
|
+
const store = svc && svc._messages
|
|
54
|
+
if (store == null || typeof store !== 'object') {
|
|
55
|
+
logger.warn('chatluna_character._messages 不可写(插件结构可能已变),跳过恢复 %s', key)
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
store[key] = arr
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- 快照写库(防抖)----
|
|
63
|
+
const timers = {}
|
|
64
|
+
async function snapshot(key) {
|
|
65
|
+
try {
|
|
66
|
+
const arr = getBuffer(key)
|
|
67
|
+
if (arr.length < 1) return
|
|
68
|
+
await ctx.database.upsert(TABLE, [
|
|
69
|
+
{ sessionKey: key, messages: JSON.stringify(arr), updatedAt: new Date() }
|
|
70
|
+
])
|
|
71
|
+
if (config.debug) logger.info('快照 %s:%d 条', key, arr.length)
|
|
72
|
+
} catch (e) {
|
|
73
|
+
logger.warn('快照失败 %s:%s', key, (e && e.message) || e)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function scheduleSnapshot(key) {
|
|
77
|
+
if (timers[key]) clearTimeout(timers[key])
|
|
78
|
+
timers[key] = setTimeout(() => {
|
|
79
|
+
delete timers[key]
|
|
80
|
+
snapshot(key)
|
|
81
|
+
}, config.debounceMs)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 每次有消息被收集 → 安排一次防抖快照
|
|
85
|
+
ctx.on('chatluna_character/message_collect', (session) => {
|
|
86
|
+
try {
|
|
87
|
+
scheduleSnapshot(deriveKey(session))
|
|
88
|
+
} catch (e) {
|
|
89
|
+
logger.warn('调度快照失败:%s', (e && e.message) || e)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// /清除记忆 → 删存档,保持诚实
|
|
94
|
+
ctx.on('chatluna_character/clear-chat-history', (payload) => {
|
|
95
|
+
const key = payload && payload.sessionKey
|
|
96
|
+
if (!key) return
|
|
97
|
+
if (timers[key]) {
|
|
98
|
+
clearTimeout(timers[key])
|
|
99
|
+
delete timers[key]
|
|
100
|
+
}
|
|
101
|
+
ctx.database.remove(TABLE, { sessionKey: key }).catch((e) =>
|
|
102
|
+
logger.warn('删存档失败 %s:%s', key, (e && e.message) || e)
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ---- 启动恢复(只跑一次)----
|
|
107
|
+
let restored = false
|
|
108
|
+
async function restore() {
|
|
109
|
+
if (restored) return
|
|
110
|
+
restored = true
|
|
111
|
+
let rows
|
|
112
|
+
try {
|
|
113
|
+
rows = await ctx.database.get(TABLE, {})
|
|
114
|
+
} catch (e) {
|
|
115
|
+
logger.warn('读取存档失败,跳过恢复:%s', (e && e.message) || e)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const now = Date.now()
|
|
119
|
+
let ok = 0
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
if (!isFresh(row.updatedAt, config.maxAgeHours, now)) {
|
|
122
|
+
if (config.debug) logger.info('跳过过期存档 %s', row.sessionKey)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
let parsed
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(row.messages)
|
|
128
|
+
} catch (e) {
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (!Array.isArray(parsed) || parsed.length < 1) continue
|
|
132
|
+
const merged = mergeById(getBuffer(row.sessionKey), parsed, config.restoreCap)
|
|
133
|
+
if (setBuffer(row.sessionKey, merged)) {
|
|
134
|
+
ok++
|
|
135
|
+
if (config.debug) logger.info('恢复 %s:%d 条', row.sessionKey, merged.length)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
logger.info('上下文恢复完成:%d 个会话', ok)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// chatluna_character + database 就绪后恢复。late-registered 的 ready
|
|
142
|
+
// 监听器(热重载场景)koishi 会立即触发,故冷启动与保存配置都覆盖。
|
|
143
|
+
ctx.on('ready', () => {
|
|
144
|
+
restore()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ---- dispose 兜底刷盘:正常重启 / 控制台 save 时抓最新态(含 bot 最后回复)----
|
|
148
|
+
ctx.on('dispose', () => {
|
|
149
|
+
let keys = []
|
|
150
|
+
try {
|
|
151
|
+
const store = svc && svc._messages
|
|
152
|
+
if (store && typeof store === 'object') keys = Object.keys(store)
|
|
153
|
+
} catch (e) {
|
|
154
|
+
keys = []
|
|
155
|
+
}
|
|
156
|
+
for (const key of keys) {
|
|
157
|
+
const arr = getBuffer(key)
|
|
158
|
+
if (arr.length < 1) continue
|
|
159
|
+
// fire-and-forget:dispose 不保证 await
|
|
160
|
+
ctx.database
|
|
161
|
+
.upsert(TABLE, [
|
|
162
|
+
{ sessionKey: key, messages: JSON.stringify(arr), updatedAt: new Date() }
|
|
163
|
+
])
|
|
164
|
+
.catch(() => {})
|
|
165
|
+
}
|
|
166
|
+
for (const k of Object.keys(timers)) clearTimeout(timers[k])
|
|
167
|
+
})
|
|
168
|
+
}
|
package/lib.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// 纯逻辑:不依赖 koishi 运行时,可离线 node 测。
|
|
2
|
+
|
|
3
|
+
// 会话 key:与 chatluna-character 内部 `_messages` 的键格式一致。
|
|
4
|
+
function deriveKey(session) {
|
|
5
|
+
const isDirect = !!session.isDirect
|
|
6
|
+
return `${isDirect ? 'private' : 'group'}:${isDirect ? session.userId : session.guildId}`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 合并两份 Message[]:按 messageId 去重(缺则回退 id|timestamp),
|
|
10
|
+
// incoming 覆盖同键的 existing;按 timestamp 升序;尾部截断到 cap(保留最新)。
|
|
11
|
+
function mergeById(existing, incoming, cap) {
|
|
12
|
+
const map = new Map()
|
|
13
|
+
const list = []
|
|
14
|
+
if (Array.isArray(existing)) list.push(...existing)
|
|
15
|
+
if (Array.isArray(incoming)) list.push(...incoming)
|
|
16
|
+
for (const msg of list) {
|
|
17
|
+
if (msg == null) continue
|
|
18
|
+
const key =
|
|
19
|
+
msg.messageId != null ? `mid:${msg.messageId}` : `fb:${msg.id}|${msg.timestamp}`
|
|
20
|
+
map.set(key, msg) // 后写覆盖 → incoming 在 existing 之后入队,故覆盖同键
|
|
21
|
+
}
|
|
22
|
+
const merged = [...map.values()].sort(
|
|
23
|
+
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)
|
|
24
|
+
)
|
|
25
|
+
if (Number.isFinite(cap) && cap > 0) {
|
|
26
|
+
while (merged.length > cap) merged.shift()
|
|
27
|
+
}
|
|
28
|
+
return merged
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 存档是否够新:maxAgeHours<=0 视为不限(永远 fresh);updatedAt 缺失则保守判 false。
|
|
32
|
+
function isFresh(updatedAt, maxAgeHours, now) {
|
|
33
|
+
if (!(maxAgeHours > 0)) return true
|
|
34
|
+
if (updatedAt == null) return false
|
|
35
|
+
const t = updatedAt instanceof Date ? updatedAt.getTime() : new Date(updatedAt).getTime()
|
|
36
|
+
if (Number.isNaN(t)) return false
|
|
37
|
+
return now - t <= maxAgeHours * 3600000
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { deriveKey, mergeById, isFresh }
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-chatluna-character-buffer-backup",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Persist chatluna-character's in-memory message buffer to the database and restore it on restart / config-save, so the bot keeps recent context across reloads.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "妖祀",
|
|
8
|
+
"homepage": "https://github.com/ningningningning0420-ui/koishi-chatluna-plugins/tree/main/packages/koishi-plugin-chatluna-character-buffer-backup#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ningningningning0420-ui/koishi-chatluna-plugins.git",
|
|
12
|
+
"directory": "packages/koishi-plugin-chatluna-character-buffer-backup"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/ningningningning0420-ui/koishi-chatluna-plugins/issues"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"index.js",
|
|
19
|
+
"lib.js",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"koishi",
|
|
25
|
+
"plugin",
|
|
26
|
+
"chatluna",
|
|
27
|
+
"chatluna-character",
|
|
28
|
+
"chatbot",
|
|
29
|
+
"memory",
|
|
30
|
+
"context"
|
|
31
|
+
],
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"koishi": "^4.17.0",
|
|
34
|
+
"koishi-plugin-chatluna-character": "*"
|
|
35
|
+
},
|
|
36
|
+
"koishi": {
|
|
37
|
+
"description": {
|
|
38
|
+
"zh": "把 chatluna-character 只存在内存里的近期对话缓冲持久化到数据库,重启 / 保存配置后自动灌回,避免 bot 忘记刚刚聊的上下文。",
|
|
39
|
+
"en": "Persist chatluna-character's in-memory recent-message buffer to the database and auto-restore it after a restart / config-save, so the bot doesn't forget the conversation it was just having."
|
|
40
|
+
},
|
|
41
|
+
"service": {
|
|
42
|
+
"required": [
|
|
43
|
+
"database",
|
|
44
|
+
"chatluna_character"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|