dsh-qqchat 0.2.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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +276 -0
  3. package/README.md +211 -0
  4. package/client-src/plugin.cts +15 -0
  5. package/client-src/settings.cts +216 -0
  6. package/client-src/shared.cts +104 -0
  7. package/client-src/workspace.cts +164 -0
  8. package/cordis.patch.yml +7 -0
  9. package/docs/ARCHITECTURE.en.md +346 -0
  10. package/docs/ARCHITECTURE.md +411 -0
  11. package/docs/DEVELOPMENT.md +69 -0
  12. package/docs/MEDIA_AND_QUOTES.md +644 -0
  13. package/docs/MEMORY.md +99 -0
  14. package/docs/MEMORY_CONTEXT.md +254 -0
  15. package/docs/QUOTES.md +355 -0
  16. package/docs/README.md +22 -0
  17. package/docs/SECURITY.md +62 -0
  18. package/docs/STREAMING.md +347 -0
  19. package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
  20. package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
  21. package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
  22. package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
  23. package/docs/devlog.md +24 -0
  24. package/lib/client.js +433 -0
  25. package/lib/commands/dispatch.d.ts +16 -0
  26. package/lib/commands/dispatch.js +48 -0
  27. package/lib/config.d.ts +3 -0
  28. package/lib/config.js +63 -0
  29. package/lib/gateway/api.d.ts +30 -0
  30. package/lib/gateway/api.js +202 -0
  31. package/lib/gateway/auth.d.ts +35 -0
  32. package/lib/gateway/auth.js +80 -0
  33. package/lib/gateway/crypto.d.ts +2 -0
  34. package/lib/gateway/crypto.js +17 -0
  35. package/lib/gateway/gateway.d.ts +32 -0
  36. package/lib/gateway/gateway.js +219 -0
  37. package/lib/gateway/normalize.d.ts +6 -0
  38. package/lib/gateway/normalize.js +248 -0
  39. package/lib/index.d.ts +14 -0
  40. package/lib/index.js +35 -0
  41. package/lib/media/image-tool.d.ts +4 -0
  42. package/lib/media/image-tool.js +62 -0
  43. package/lib/media/media-tools.d.ts +3 -0
  44. package/lib/media/media-tools.js +68 -0
  45. package/lib/media/store.d.ts +14 -0
  46. package/lib/media/store.js +176 -0
  47. package/lib/session/agent-bridge.d.ts +56 -0
  48. package/lib/session/agent-bridge.js +613 -0
  49. package/lib/session/memory-snapshot.d.ts +23 -0
  50. package/lib/session/memory-snapshot.js +27 -0
  51. package/lib/session/runtime.d.ts +41 -0
  52. package/lib/session/runtime.js +395 -0
  53. package/lib/shared/augmentations.d.ts +40 -0
  54. package/lib/shared/augmentations.js +1 -0
  55. package/lib/shared/logging.d.ts +14 -0
  56. package/lib/shared/logging.js +41 -0
  57. package/lib/storage/db.d.ts +82 -0
  58. package/lib/storage/db.js +669 -0
  59. package/lib/storage/memory.d.ts +50 -0
  60. package/lib/storage/memory.js +566 -0
  61. package/lib/transport/rpc.d.ts +18 -0
  62. package/lib/transport/rpc.js +318 -0
  63. package/lib/types.d.ts +416 -0
  64. package/lib/types.js +1 -0
  65. package/package.json +115 -0
  66. package/scripts/wrap-client.mjs +20 -0
  67. package/src/commands/dispatch.ts +75 -0
  68. package/src/config.ts +70 -0
  69. package/src/gateway/api.ts +260 -0
  70. package/src/gateway/auth.ts +99 -0
  71. package/src/gateway/crypto.ts +16 -0
  72. package/src/gateway/gateway.ts +207 -0
  73. package/src/gateway/normalize.ts +236 -0
  74. package/src/index.ts +46 -0
  75. package/src/media/image-tool.ts +62 -0
  76. package/src/media/media-tools.ts +66 -0
  77. package/src/media/store.ts +157 -0
  78. package/src/session/agent-bridge.ts +624 -0
  79. package/src/session/memory-snapshot.ts +41 -0
  80. package/src/session/runtime.ts +368 -0
  81. package/src/shared/augmentations.ts +45 -0
  82. package/src/shared/logging.ts +36 -0
  83. package/src/storage/db.ts +760 -0
  84. package/src/storage/memory.ts +581 -0
  85. package/src/transport/rpc.ts +316 -0
  86. package/src/types.ts +456 -0
  87. package/tsconfig.build.json +12 -0
  88. package/tsconfig.client.json +14 -0
  89. package/tsconfig.json +23 -0
@@ -0,0 +1,669 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { chmodSync, mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ const now = () => Date.now();
5
+ function parseStringList(value) {
6
+ if (!value)
7
+ return [];
8
+ try {
9
+ const parsed = JSON.parse(value);
10
+ return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string' && item.trim()).map(item => item.trim()) : [];
11
+ }
12
+ catch {
13
+ return [];
14
+ }
15
+ }
16
+ function profileTypeForKey(key) {
17
+ if (key === 'name' || key === 'name_observed' || key === 'display_name' || key === 'nickname')
18
+ return 'name';
19
+ if (key === 'address')
20
+ return 'address';
21
+ if (key === 'pronoun')
22
+ return 'pronoun';
23
+ if (key === 'background' || key === 'dev_env')
24
+ return 'background';
25
+ if (key === 'preference')
26
+ return 'preference';
27
+ return 'note';
28
+ }
29
+ function parseDailyEntries(value) {
30
+ const entries = [];
31
+ let date = '';
32
+ for (const raw of value.split(/\r?\n/)) {
33
+ const line = raw.trim();
34
+ const heading = /^##\s+(\d{4}-\d{2}-\d{2})\s*$/.exec(line);
35
+ if (heading) {
36
+ date = heading[1] || '';
37
+ continue;
38
+ }
39
+ if (!date || !line)
40
+ continue;
41
+ const note = line.startsWith('- ') ? line.slice(2).trim() : line;
42
+ if (note)
43
+ entries.push([date, note]);
44
+ }
45
+ return entries;
46
+ }
47
+ function renderDailyEntries(entries) {
48
+ const out = [];
49
+ let current = '';
50
+ for (const [date, note] of entries) {
51
+ if (!date || !note)
52
+ continue;
53
+ if (date !== current) {
54
+ if (out.length)
55
+ out.push('');
56
+ out.push(`## ${date}`);
57
+ current = date;
58
+ }
59
+ out.push(`- ${note}`);
60
+ }
61
+ return out.join('\n');
62
+ }
63
+ function one(value) {
64
+ return value === undefined ? undefined : value;
65
+ }
66
+ function many(value) {
67
+ return value;
68
+ }
69
+ export class QQChatDatabase {
70
+ path;
71
+ db;
72
+ constructor(path) {
73
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
74
+ this.path = path;
75
+ this.db = new DatabaseSync(path);
76
+ try {
77
+ chmodSync(path, 0o600);
78
+ }
79
+ catch { }
80
+ this.db.exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;');
81
+ this.migrate();
82
+ }
83
+ close() { this.db.close(); }
84
+ migrate() {
85
+ this.db.exec(`
86
+ CREATE TABLE IF NOT EXISTS accounts (
87
+ id INTEGER PRIMARY KEY,
88
+ app_id TEXT NOT NULL UNIQUE,
89
+ app_secret TEXT NOT NULL,
90
+ bot_user_id TEXT,
91
+ enabled INTEGER NOT NULL DEFAULT 1,
92
+ sandbox INTEGER NOT NULL DEFAULT 0,
93
+ gateway_status TEXT NOT NULL DEFAULT 'offline',
94
+ gateway_last_error TEXT,
95
+ created_at INTEGER NOT NULL,
96
+ updated_at INTEGER NOT NULL
97
+ );
98
+ CREATE TABLE IF NOT EXISTS auth_tasks (
99
+ task_id TEXT PRIMARY KEY,
100
+ aes_key TEXT NOT NULL,
101
+ expires_at INTEGER NOT NULL
102
+ );
103
+ CREATE TABLE IF NOT EXISTS groups (
104
+ id INTEGER PRIMARY KEY,
105
+ account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
106
+ platform_group_id TEXT NOT NULL,
107
+ name TEXT,
108
+ enabled INTEGER NOT NULL DEFAULT 1,
109
+ read_enabled INTEGER NOT NULL DEFAULT 1,
110
+ dsh_session_id TEXT,
111
+ created_at INTEGER NOT NULL,
112
+ updated_at INTEGER NOT NULL,
113
+ UNIQUE(account_id, platform_group_id)
114
+ );
115
+ CREATE TABLE IF NOT EXISTS qq_quote_index (
116
+ id INTEGER PRIMARY KEY,
117
+ account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
118
+ chat_type TEXT NOT NULL CHECK(chat_type IN ('c2c','group')),
119
+ chat_id TEXT NOT NULL,
120
+ msg_idx TEXT NOT NULL,
121
+ platform_message_id TEXT,
122
+ sender_id TEXT NOT NULL,
123
+ sender_name TEXT,
124
+ content TEXT NOT NULL DEFAULT '',
125
+ attachments_json TEXT NOT NULL DEFAULT '[]',
126
+ created_at INTEGER NOT NULL,
127
+ expires_at INTEGER NOT NULL,
128
+ UNIQUE(account_id, chat_type, chat_id, msg_idx)
129
+ );
130
+ CREATE INDEX IF NOT EXISTS qq_quote_index_expiry ON qq_quote_index(expires_at);
131
+ CREATE TABLE IF NOT EXISTS members (
132
+ id INTEGER PRIMARY KEY,
133
+ account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
134
+ platform_user_id TEXT NOT NULL,
135
+ display_name TEXT,
136
+ dsh_session_id TEXT,
137
+ first_seen_at INTEGER NOT NULL,
138
+ last_seen_at INTEGER NOT NULL,
139
+ UNIQUE(account_id, platform_user_id)
140
+ );
141
+ CREATE TABLE IF NOT EXISTS group_members (
142
+ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
143
+ member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
144
+ display_name TEXT,
145
+ first_seen_at INTEGER NOT NULL,
146
+ last_seen_at INTEGER NOT NULL,
147
+ aliases_json TEXT NOT NULL DEFAULT '[]',
148
+ nicknames_json TEXT NOT NULL DEFAULT '[]',
149
+ message_count INTEGER NOT NULL DEFAULT 0,
150
+ PRIMARY KEY(group_id, member_id)
151
+ );
152
+ CREATE TABLE IF NOT EXISTS messages (
153
+ id INTEGER PRIMARY KEY,
154
+ account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
155
+ platform_message_id TEXT,
156
+ chat_type TEXT NOT NULL CHECK(chat_type IN ('c2c','group')),
157
+ group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
158
+ member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
159
+ direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')),
160
+ content TEXT NOT NULL,
161
+ quoted_text TEXT,
162
+ mentioned INTEGER NOT NULL DEFAULT 0,
163
+ created_at INTEGER NOT NULL,
164
+ raw_json TEXT,
165
+ attachments_json TEXT,
166
+ quote_json TEXT
167
+ );
168
+ CREATE TABLE IF NOT EXISTS attachments (
169
+ id TEXT PRIMARY KEY,
170
+ account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
171
+ source_message_id TEXT,
172
+ source_file_id TEXT,
173
+ kind TEXT NOT NULL CHECK(kind IN ('image','audio','video','voice','file')),
174
+ filename TEXT NOT NULL,
175
+ content_type TEXT,
176
+ size_bytes INTEGER NOT NULL DEFAULT 0,
177
+ local_path TEXT,
178
+ image_ref_json TEXT,
179
+ status TEXT NOT NULL DEFAULT 'staged' CHECK(status IN ('staged','attached','expired','deleted','failed')),
180
+ created_at INTEGER NOT NULL,
181
+ expires_at INTEGER
182
+ );
183
+ CREATE INDEX IF NOT EXISTS attachments_source ON attachments(account_id, source_message_id, source_file_id);
184
+ CREATE TABLE IF NOT EXISTS message_attachments (
185
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
186
+ attachment_id TEXT NOT NULL REFERENCES attachments(id) ON DELETE CASCADE,
187
+ role TEXT NOT NULL CHECK(role IN ('own','quoted')),
188
+ ordinal INTEGER NOT NULL DEFAULT 0,
189
+ PRIMARY KEY(message_id, attachment_id, role)
190
+ );
191
+ CREATE UNIQUE INDEX IF NOT EXISTS messages_platform_unique
192
+ ON messages(account_id, platform_message_id, direction)
193
+ WHERE platform_message_id IS NOT NULL AND platform_message_id <> '';
194
+ CREATE INDEX IF NOT EXISTS messages_group_created ON messages(group_id, created_at, id);
195
+ CREATE INDEX IF NOT EXISTS messages_member_created ON messages(member_id, chat_type, created_at, id);
196
+ CREATE TABLE IF NOT EXISTS plugin_settings (
197
+ key TEXT PRIMARY KEY,
198
+ value TEXT NOT NULL,
199
+ updated_at INTEGER NOT NULL
200
+ );
201
+ CREATE TABLE IF NOT EXISTS memory_documents (
202
+ scope_type TEXT NOT NULL CHECK(scope_type IN ('group','member')),
203
+ scope_key INTEGER NOT NULL,
204
+ doc_type TEXT NOT NULL,
205
+ content TEXT NOT NULL,
206
+ updated_at INTEGER NOT NULL,
207
+ PRIMARY KEY(scope_type, scope_key, doc_type)
208
+ );
209
+ CREATE TABLE IF NOT EXISTS reflection_state (
210
+ group_id INTEGER PRIMARY KEY REFERENCES groups(id) ON DELETE CASCADE,
211
+ last_message_id INTEGER NOT NULL DEFAULT 0,
212
+ last_reflected_at INTEGER
213
+ );
214
+ CREATE TABLE IF NOT EXISTS outbox (
215
+ id INTEGER PRIMARY KEY,
216
+ account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
217
+ chat_type TEXT NOT NULL CHECK(chat_type IN ('c2c','group')),
218
+ target_id TEXT NOT NULL,
219
+ content TEXT NOT NULL,
220
+ status TEXT NOT NULL DEFAULT 'pending',
221
+ scheduled_at INTEGER NOT NULL,
222
+ sent_at INTEGER,
223
+ error TEXT
224
+ );
225
+ `);
226
+ const columns = new Set(many(this.db.prepare('PRAGMA table_info(group_members)').all()).map(column => column.name));
227
+ const groupColumns = new Set(many(this.db.prepare('PRAGMA table_info(groups)').all()).map(column => column.name));
228
+ if (groupColumns.has('requires_at'))
229
+ this.db.exec('ALTER TABLE groups DROP COLUMN requires_at');
230
+ if (!columns.has('aliases_json'))
231
+ this.db.exec("ALTER TABLE group_members ADD COLUMN aliases_json TEXT NOT NULL DEFAULT '[]'");
232
+ if (!columns.has('nicknames_json'))
233
+ this.db.exec("ALTER TABLE group_members ADD COLUMN nicknames_json TEXT NOT NULL DEFAULT '[]'");
234
+ if (!columns.has('message_count'))
235
+ this.db.exec('ALTER TABLE group_members ADD COLUMN message_count INTEGER NOT NULL DEFAULT 0');
236
+ const messageColumns = new Set(many(this.db.prepare('PRAGMA table_info(messages)').all()).map(column => column.name));
237
+ if (!messageColumns.has('attachments_json'))
238
+ this.db.exec('ALTER TABLE messages ADD COLUMN attachments_json TEXT');
239
+ if (!messageColumns.has('quote_json'))
240
+ this.db.exec('ALTER TABLE messages ADD COLUMN quote_json TEXT');
241
+ this.db.exec(`UPDATE group_members SET message_count=(
242
+ SELECT COUNT(*) FROM messages WHERE messages.group_id=group_members.group_id AND messages.member_id=group_members.member_id
243
+ ) WHERE message_count=0`);
244
+ this.migrateMemberProfiles();
245
+ this.normalizeDailyDocuments();
246
+ }
247
+ migrateMemberProfiles() {
248
+ const rows = many(this.db.prepare("SELECT scope_key,content FROM memory_documents WHERE scope_type='member' AND doc_type='profile'").all());
249
+ const update = this.db.prepare("UPDATE memory_documents SET content=?,updated_at=? WHERE scope_type='member' AND scope_key=? AND doc_type='profile'");
250
+ for (const row of rows) {
251
+ try {
252
+ const value = JSON.parse(row.content);
253
+ if (Array.isArray(value) || !value || typeof value !== 'object')
254
+ continue;
255
+ const entries = Object.entries(value).map(([key, raw]) => ({
256
+ type: profileTypeForKey(key),
257
+ text: typeof raw === 'string' ? raw : JSON.stringify(raw),
258
+ ts: now(),
259
+ }));
260
+ update.run(JSON.stringify(entries, null, 2), now(), row.scope_key);
261
+ }
262
+ catch {
263
+ // Preserve malformed legacy content for manual inspection.
264
+ }
265
+ }
266
+ }
267
+ normalizeDailyDocuments() {
268
+ const rows = many(this.db.prepare("SELECT scope_type,scope_key,content FROM memory_documents WHERE doc_type='daily'").all());
269
+ const update = this.db.prepare('UPDATE memory_documents SET content=?,updated_at=? WHERE scope_type=? AND scope_key=? AND doc_type=\'daily\'');
270
+ for (const row of rows) {
271
+ const entries = parseDailyEntries(row.content);
272
+ const normalized = renderDailyEntries(entries);
273
+ if (normalized !== row.content.trim())
274
+ update.run(normalized, now(), row.scope_type, row.scope_key);
275
+ }
276
+ }
277
+ pruneAuthTasks() { this.db.prepare('DELETE FROM auth_tasks WHERE expires_at <= ?').run(now()); }
278
+ saveAuthTask(taskId, aesKey, ttlMs = 600_000) {
279
+ this.pruneAuthTasks();
280
+ this.db.prepare('INSERT OR REPLACE INTO auth_tasks(task_id,aes_key,expires_at) VALUES(?,?,?)')
281
+ .run(taskId, aesKey, now() + ttlMs);
282
+ }
283
+ getAuthTask(taskId) {
284
+ this.pruneAuthTasks();
285
+ return one(this.db.prepare('SELECT * FROM auth_tasks WHERE task_id=?').get(taskId));
286
+ }
287
+ deleteAuthTask(taskId) { this.db.prepare('DELETE FROM auth_tasks WHERE task_id=?').run(taskId); }
288
+ upsertAccount(appId, secret, sandbox = false) {
289
+ const t = now();
290
+ this.db.prepare(`INSERT INTO accounts(app_id,app_secret,enabled,sandbox,created_at,updated_at)
291
+ VALUES(?,?,1,?,?,?)
292
+ ON CONFLICT(app_id) DO UPDATE SET app_secret=excluded.app_secret, enabled=1,
293
+ sandbox=excluded.sandbox, updated_at=excluded.updated_at`)
294
+ .run(appId, secret, sandbox ? 1 : 0, t, t);
295
+ const account = this.accountByAppId(appId);
296
+ if (!account)
297
+ throw new Error('QQ account upsert did not return a row');
298
+ return account;
299
+ }
300
+ accountByAppId(appId) {
301
+ return one(this.db.prepare('SELECT * FROM accounts WHERE app_id=?').get(appId));
302
+ }
303
+ accountById(id) {
304
+ return one(this.db.prepare('SELECT * FROM accounts WHERE id=?').get(id));
305
+ }
306
+ firstEnabledAccount() {
307
+ return one(this.db.prepare('SELECT * FROM accounts WHERE enabled=1 ORDER BY id LIMIT 1').get());
308
+ }
309
+ enabledAccounts() {
310
+ return many(this.db.prepare('SELECT * FROM accounts WHERE enabled=1 ORDER BY id').all());
311
+ }
312
+ publicAccounts() {
313
+ return many(this.db.prepare(`SELECT id,app_id,bot_user_id,enabled,sandbox,gateway_status,gateway_last_error,created_at,updated_at
314
+ FROM accounts ORDER BY id`).all());
315
+ }
316
+ setAccountGateway(id, status, error = null, botUserId) {
317
+ const sql = botUserId === undefined
318
+ ? 'UPDATE accounts SET gateway_status=?,gateway_last_error=?,updated_at=? WHERE id=?'
319
+ : 'UPDATE accounts SET gateway_status=?,gateway_last_error=?,bot_user_id=?,updated_at=? WHERE id=?';
320
+ const args = botUserId === undefined ? [status, error, now(), id] : [status, error, botUserId, now(), id];
321
+ this.db.prepare(sql).run(...args);
322
+ }
323
+ setAccountEnabled(id, enabled) {
324
+ this.db.prepare('UPDATE accounts SET enabled=?,updated_at=? WHERE id=?').run(enabled ? 1 : 0, now(), id);
325
+ }
326
+ getSetting(key, fallback) {
327
+ const row = one(this.db.prepare('SELECT value FROM plugin_settings WHERE key=?').get(key));
328
+ if (!row)
329
+ return fallback;
330
+ try {
331
+ return JSON.parse(row.value);
332
+ }
333
+ catch {
334
+ return fallback;
335
+ }
336
+ }
337
+ setSetting(key, value) {
338
+ this.db.prepare(`INSERT INTO plugin_settings(key,value,updated_at) VALUES(?,?,?)
339
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`)
340
+ .run(key, JSON.stringify(value), now());
341
+ }
342
+ runtimeSettings(defaults) {
343
+ return {
344
+ memoryEnabled: this.getSetting('memoryEnabled', defaults.memoryEnabled),
345
+ groupReceiveMode: this.getSetting('groupReceiveMode', defaults.groupReceiveMode),
346
+ groupReplyFormat: this.getSetting('groupReplyFormat', defaults.groupReplyFormat),
347
+ directReplyFormat: this.getSetting('directReplyFormat', this.getSetting('replyFormat', defaults.directReplyFormat)),
348
+ directStreamingEnabled: this.getSetting('directStreamingEnabled', defaults.directStreamingEnabled),
349
+ groupMembersCanUseTools: this.getSetting('groupMembersCanUseTools', defaults.groupMembersCanUseTools),
350
+ groupMembersCanReceiveMedia: this.getSetting('groupMembersCanReceiveMedia', defaults.groupMembersCanReceiveMedia),
351
+ groupMembersCanReadMedia: this.getSetting('groupMembersCanReadMedia', defaults.groupMembersCanReadMedia),
352
+ ownerUserId: this.getSetting('ownerUserId', defaults.ownerUserId),
353
+ };
354
+ }
355
+ upsertGroup(accountId, platformGroupId, defaults = {}) {
356
+ const t = now();
357
+ this.db.prepare(`INSERT INTO groups(account_id,platform_group_id,name,enabled,read_enabled,created_at,updated_at)
358
+ VALUES(?,?,?,?,?,?,?)
359
+ ON CONFLICT(account_id,platform_group_id) DO UPDATE SET updated_at=excluded.updated_at`)
360
+ .run(accountId, platformGroupId, defaults.name || null, defaults.enabled === false ? 0 : 1, defaults.readEnabled === false ? 0 : 1, t, t);
361
+ const group = this.groupByPlatform(accountId, platformGroupId);
362
+ if (!group)
363
+ throw new Error('QQ group upsert did not return a row');
364
+ return group;
365
+ }
366
+ groupByPlatform(accountId, platformGroupId) {
367
+ return one(this.db.prepare('SELECT * FROM groups WHERE account_id=? AND platform_group_id=?').get(accountId, platformGroupId));
368
+ }
369
+ groupById(id) {
370
+ return one(this.db.prepare('SELECT * FROM groups WHERE id=?').get(id));
371
+ }
372
+ updateGroup(id, patch) {
373
+ const row = this.groupById(id);
374
+ if (!row)
375
+ return undefined;
376
+ const next = {
377
+ name: patch.name === undefined ? row.name : String(patch.name || ''),
378
+ enabled: patch.enabled === undefined ? row.enabled : patch.enabled ? 1 : 0,
379
+ read_enabled: patch.readEnabled === undefined ? row.read_enabled : patch.readEnabled ? 1 : 0,
380
+ };
381
+ this.db.prepare('UPDATE groups SET name=?,enabled=?,read_enabled=?,updated_at=? WHERE id=?')
382
+ .run(next.name, next.enabled, next.read_enabled, now(), id);
383
+ return this.groupById(id);
384
+ }
385
+ saveQuoteIndex(input) {
386
+ this.db.prepare(`INSERT INTO qq_quote_index
387
+ (account_id,chat_type,chat_id,msg_idx,platform_message_id,sender_id,sender_name,content,attachments_json,created_at,expires_at)
388
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)
389
+ ON CONFLICT(account_id,chat_type,chat_id,msg_idx) DO UPDATE SET
390
+ platform_message_id=excluded.platform_message_id,sender_id=excluded.sender_id,sender_name=excluded.sender_name,
391
+ content=excluded.content,attachments_json=excluded.attachments_json,created_at=excluded.created_at,expires_at=excluded.expires_at`)
392
+ .run(input.accountId, input.chatType, input.chatId, input.msgIdx, input.platformMessageId || null, input.senderId, input.senderName || null, input.content.slice(0, 200), JSON.stringify(input.attachments), input.createdAt, input.expiresAt);
393
+ }
394
+ quoteIndex(accountId, chatType, chatId, msgIdx) {
395
+ const row = one(this.db.prepare(`SELECT * FROM qq_quote_index
396
+ WHERE account_id=? AND chat_type=? AND chat_id=? AND msg_idx=? AND expires_at>?`).get(accountId, chatType, chatId, msgIdx, now()));
397
+ if (!row)
398
+ return undefined;
399
+ let attachments = [];
400
+ try {
401
+ const parsed = JSON.parse(String(row.attachments_json || '[]'));
402
+ if (Array.isArray(parsed))
403
+ attachments = parsed;
404
+ }
405
+ catch { }
406
+ return {
407
+ id: Number(row.id), accountId: Number(row.account_id), chatType: row.chat_type, chatId: String(row.chat_id),
408
+ msgIdx: String(row.msg_idx), platformMessageId: row.platform_message_id ? String(row.platform_message_id) : undefined,
409
+ senderId: String(row.sender_id), senderName: row.sender_name ? String(row.sender_name) : undefined,
410
+ content: String(row.content || ''), attachments, createdAt: Number(row.created_at), expiresAt: Number(row.expires_at),
411
+ };
412
+ }
413
+ expireQuoteIndex(before) {
414
+ const result = this.db.prepare('DELETE FROM qq_quote_index WHERE expires_at<=?').run(before);
415
+ return Number(result.changes || 0);
416
+ }
417
+ listGroups() {
418
+ return many(this.db.prepare(`SELECT g.*,
419
+ (SELECT MAX(created_at) FROM messages m WHERE m.group_id=g.id) AS last_message_at,
420
+ (SELECT COUNT(*) FROM group_members gm WHERE gm.group_id=g.id) AS member_count,
421
+ (SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id) AS message_count
422
+ FROM groups g ORDER BY COALESCE(last_message_at,g.updated_at) DESC`).all());
423
+ }
424
+ listDirectChats() {
425
+ return many(this.db.prepare(`SELECT mem.*,
426
+ (SELECT MAX(created_at) FROM messages m WHERE m.member_id=mem.id AND m.chat_type='c2c') AS last_message_at,
427
+ (SELECT COUNT(*) FROM messages m WHERE m.member_id=mem.id AND m.chat_type='c2c') AS message_count
428
+ FROM members mem
429
+ WHERE EXISTS(SELECT 1 FROM messages m WHERE m.member_id=mem.id AND m.chat_type='c2c')
430
+ ORDER BY COALESCE(last_message_at,mem.last_seen_at) DESC`).all());
431
+ }
432
+ listKnownMembers() {
433
+ return many(this.db.prepare('SELECT * FROM members ORDER BY last_seen_at DESC').all());
434
+ }
435
+ upsertMember(accountId, platformUserId, displayName = '') {
436
+ const t = now();
437
+ this.db.prepare(`INSERT INTO members(account_id,platform_user_id,display_name,first_seen_at,last_seen_at)
438
+ VALUES(?,?,?,?,?) ON CONFLICT(account_id,platform_user_id) DO UPDATE SET
439
+ display_name=CASE WHEN excluded.display_name<>'' THEN excluded.display_name ELSE members.display_name END,
440
+ last_seen_at=excluded.last_seen_at`)
441
+ .run(accountId, platformUserId, displayName, t, t);
442
+ const member = this.memberByPlatform(accountId, platformUserId);
443
+ if (!member)
444
+ throw new Error('QQ member upsert did not return a row');
445
+ return member;
446
+ }
447
+ memberByPlatform(accountId, platformUserId) {
448
+ return one(this.db.prepare('SELECT * FROM members WHERE account_id=? AND platform_user_id=?').get(accountId, platformUserId));
449
+ }
450
+ memberById(id) {
451
+ return one(this.db.prepare('SELECT * FROM members WHERE id=?').get(id));
452
+ }
453
+ touchGroupMember(groupId, memberId, displayName = '') {
454
+ const t = now();
455
+ const existing = one(this.db.prepare('SELECT display_name,aliases_json FROM group_members WHERE group_id=? AND member_id=?').get(groupId, memberId));
456
+ const aliases = parseStringList(existing?.aliases_json);
457
+ if (displayName && existing?.display_name && displayName !== existing.display_name && !aliases.includes(existing.display_name)) {
458
+ aliases.push(existing.display_name);
459
+ }
460
+ this.db.prepare(`INSERT INTO group_members(group_id,member_id,display_name,first_seen_at,last_seen_at,aliases_json)
461
+ VALUES(?,?,?,?,?,?) ON CONFLICT(group_id,member_id) DO UPDATE SET
462
+ display_name=CASE WHEN excluded.display_name<>'' THEN excluded.display_name ELSE group_members.display_name END,
463
+ last_seen_at=excluded.last_seen_at, aliases_json=excluded.aliases_json`).run(groupId, memberId, displayName, t, t, JSON.stringify(aliases));
464
+ }
465
+ listGroupMembers(groupId) {
466
+ return many(this.db.prepare(`SELECT m.id,m.platform_user_id,m.display_name AS global_display_name,
467
+ COALESCE(NULLIF(gm.display_name,''),m.display_name) AS display_name, gm.first_seen_at,gm.last_seen_at,
468
+ gm.aliases_json,gm.nicknames_json,gm.message_count
469
+ FROM group_members gm JOIN members m ON m.id=gm.member_id WHERE gm.group_id=? ORDER BY gm.last_seen_at DESC`).all(groupId));
470
+ }
471
+ addGroupMemberNickname(groupId, memberId, nickname) {
472
+ const value = nickname.trim();
473
+ if (!value)
474
+ return;
475
+ const row = one(this.db.prepare('SELECT nicknames_json FROM group_members WHERE group_id=? AND member_id=?').get(groupId, memberId));
476
+ if (!row)
477
+ return;
478
+ const nicknames = parseStringList(row.nicknames_json);
479
+ if (nicknames.includes(value))
480
+ return;
481
+ nicknames.push(value);
482
+ this.db.prepare('UPDATE group_members SET nicknames_json=? WHERE group_id=? AND member_id=?')
483
+ .run(JSON.stringify(nicknames), groupId, memberId);
484
+ }
485
+ insertMessage(input) {
486
+ if (input.platformMessageId) {
487
+ const existing = one(this.db.prepare(`SELECT id FROM messages WHERE account_id=? AND platform_message_id=? AND direction=?`)
488
+ .get(input.accountId, input.platformMessageId, input.direction));
489
+ if (existing) {
490
+ this.linkMessageAttachments(Number(existing.id), input.attachments || []);
491
+ return Number(existing.id);
492
+ }
493
+ }
494
+ const result = this.db.prepare(`INSERT INTO messages(account_id,platform_message_id,chat_type,group_id,member_id,direction,
495
+ content,quoted_text,mentioned,created_at,raw_json,attachments_json,quote_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`)
496
+ .run(input.accountId, input.platformMessageId || null, input.chatType, input.groupId || null, input.memberId || null, input.direction, input.content || '', input.quotedText || null, input.mentioned ? 1 : 0, input.createdAt || now(), input.raw ? JSON.stringify(input.raw) : null, input.attachments?.length ? JSON.stringify(input.attachments) : null, input.quote ? JSON.stringify(input.quote) : null);
497
+ const messageId = Number(result.lastInsertRowid);
498
+ this.linkMessageAttachments(messageId, input.attachments || []);
499
+ if (input.chatType === 'group' && input.groupId && input.memberId) {
500
+ this.db.prepare('UPDATE group_members SET message_count=message_count+1 WHERE group_id=? AND member_id=?')
501
+ .run(input.groupId, input.memberId);
502
+ }
503
+ return messageId;
504
+ }
505
+ linkMessageAttachments(messageId, attachments) {
506
+ for (const [ordinal, attachment] of attachments.entries()) {
507
+ this.db.prepare(`INSERT OR IGNORE INTO message_attachments(message_id,attachment_id,role,ordinal) VALUES(?,?,?,?)`)
508
+ .run(messageId, attachment.id, attachment.quoted ? 'quoted' : 'own', ordinal);
509
+ this.db.prepare("UPDATE attachments SET status='attached' WHERE id=?").run(attachment.id);
510
+ }
511
+ }
512
+ saveAttachment(input) {
513
+ this.db.prepare(`INSERT OR REPLACE INTO attachments
514
+ (id,account_id,source_message_id,source_file_id,kind,filename,content_type,size_bytes,local_path,image_ref_json,status,created_at,expires_at)
515
+ VALUES(?,?,?,?,?,?,?,?,?,?,?, ?,?)`).run(input.id, input.accountId, input.sourceMessageId || null, input.sourceFileId || null, input.kind, input.filename, input.contentType || null, input.sizeBytes, input.localPath || null, input.imageRef ? JSON.stringify(input.imageRef) : null, 'staged', now(), input.expiresAt || null);
516
+ }
517
+ attachmentById(id) {
518
+ const row = one(this.db.prepare('SELECT id,source_file_id,kind,filename,content_type,size_bytes,local_path,image_ref_json FROM attachments WHERE id=?').get(id));
519
+ if (!row)
520
+ return undefined;
521
+ let imageRef;
522
+ try {
523
+ imageRef = row.image_ref_json ? JSON.parse(row.image_ref_json) : undefined;
524
+ }
525
+ catch { }
526
+ return { id: row.id, sourceFileId: row.source_file_id || undefined, kind: row.kind, filename: row.filename, contentType: row.content_type || undefined,
527
+ sizeBytes: Number(row.size_bytes || 0), quoted: false, localPath: row.local_path || undefined, imageRef };
528
+ }
529
+ findReusableAttachment(accountId, sourceMessageId, sourceFileId, kind) {
530
+ const row = one(this.db.prepare(`SELECT id,source_file_id,kind,filename,content_type,size_bytes,local_path,image_ref_json FROM attachments
531
+ WHERE account_id=? AND source_message_id=? AND status IN ('staged','attached')
532
+ AND (? IS NULL OR source_file_id=? ) AND (? IS NULL OR kind=? ) AND local_path IS NOT NULL
533
+ ORDER BY created_at DESC LIMIT 1`).get(accountId, sourceMessageId, sourceFileId || null, sourceFileId || null, kind || null, kind || null));
534
+ if (!row)
535
+ return undefined;
536
+ let imageRef;
537
+ try {
538
+ imageRef = row.image_ref_json ? JSON.parse(row.image_ref_json) : undefined;
539
+ }
540
+ catch { }
541
+ return { id: row.id, sourceFileId: row.source_file_id || undefined, kind: row.kind, filename: row.filename, contentType: row.content_type || undefined,
542
+ sizeBytes: Number(row.size_bytes || 0), quoted: false, localPath: row.local_path || undefined, imageRef };
543
+ }
544
+ extendAttachment(id, expiresAt) {
545
+ this.db.prepare("UPDATE attachments SET expires_at=CASE WHEN expires_at IS NULL OR expires_at<? THEN ? ELSE expires_at END, status='attached' WHERE id=?")
546
+ .run(expiresAt, expiresAt, id);
547
+ }
548
+ attachmentForSession(sessionId, attachmentId) {
549
+ const row = one(this.db.prepare(`
550
+ SELECT a.id,a.source_file_id,a.kind,a.filename,a.content_type,a.size_bytes,a.local_path,a.image_ref_json,a.status,a.expires_at
551
+ FROM attachments a JOIN message_attachments ma ON ma.attachment_id=a.id
552
+ JOIN messages msg ON msg.id=ma.message_id
553
+ LEFT JOIN groups g ON g.id=msg.group_id
554
+ LEFT JOIN members mem ON mem.id=msg.member_id
555
+ WHERE a.id=? AND a.status IN ('staged','attached') AND (a.expires_at IS NULL OR a.expires_at>?)
556
+ AND (
557
+ (msg.chat_type='group' AND g.dsh_session_id=?)
558
+ OR
559
+ (msg.chat_type='c2c' AND mem.dsh_session_id=?)
560
+ )
561
+ LIMIT 1
562
+ `).get(attachmentId, Date.now(), sessionId, sessionId));
563
+ if (!row)
564
+ return undefined;
565
+ let imageRef;
566
+ try {
567
+ imageRef = row.image_ref_json ? JSON.parse(row.image_ref_json) : undefined;
568
+ }
569
+ catch { }
570
+ return { id: row.id, sourceFileId: row.source_file_id || undefined, kind: row.kind, filename: row.filename, contentType: row.content_type || undefined,
571
+ sizeBytes: Number(row.size_bytes || 0), quoted: false, localPath: row.local_path || undefined, imageRef };
572
+ }
573
+ expireAttachments(before) {
574
+ const result = this.db.prepare("UPDATE attachments SET status='expired' WHERE expires_at IS NOT NULL AND expires_at<? AND status IN ('staged','attached')").run(before);
575
+ return Number(result.changes || 0);
576
+ }
577
+ expiredAttachmentPaths(before) {
578
+ return many(this.db.prepare("SELECT local_path FROM attachments WHERE expires_at IS NOT NULL AND expires_at<? AND status='expired'").all(before)).map(row => row.local_path).filter((value) => Boolean(value));
579
+ }
580
+ markAttachmentDeleted(path) {
581
+ this.db.prepare("UPDATE attachments SET status='deleted',local_path=NULL WHERE local_path=?").run(path);
582
+ }
583
+ listMessages(groupId, limit = 100) {
584
+ const rows = many(this.db.prepare(`SELECT m.*,mem.platform_user_id,mem.display_name
585
+ FROM messages m LEFT JOIN members mem ON mem.id=m.member_id
586
+ WHERE m.group_id=? ORDER BY m.id DESC LIMIT ?`).all(groupId, limit));
587
+ return rows.reverse();
588
+ }
589
+ listDirectMessages(memberId, limit = 100) {
590
+ const rows = many(this.db.prepare(`SELECT m.*,mem.platform_user_id,mem.display_name
591
+ FROM messages m LEFT JOIN members mem ON mem.id=m.member_id
592
+ WHERE m.chat_type='c2c' AND m.member_id=? ORDER BY m.id DESC LIMIT ?`).all(memberId, limit));
593
+ return rows.reverse();
594
+ }
595
+ groupBySession(sessionId) {
596
+ return one(this.db.prepare('SELECT * FROM groups WHERE dsh_session_id=?').get(sessionId));
597
+ }
598
+ memberBySession(sessionId) {
599
+ return one(this.db.prepare('SELECT * FROM members WHERE dsh_session_id=?').get(sessionId));
600
+ }
601
+ recentGroupMessages(groupId, limit = 40) { return this.listMessages(groupId, limit); }
602
+ unreflectedMessages(groupId, limit = 80) {
603
+ const state = one(this.db.prepare('SELECT last_message_id FROM reflection_state WHERE group_id=?').get(groupId));
604
+ const after = Number(state?.last_message_id || 0);
605
+ return many(this.db.prepare(`SELECT m.*,mem.platform_user_id,mem.display_name
606
+ FROM messages m LEFT JOIN members mem ON mem.id=m.member_id
607
+ WHERE m.group_id=? AND m.id>? ORDER BY m.id ASC LIMIT ?`).all(groupId, after, limit));
608
+ }
609
+ unreflectedCount(groupId) {
610
+ const state = one(this.db.prepare('SELECT last_message_id FROM reflection_state WHERE group_id=?').get(groupId));
611
+ const row = one(this.db.prepare('SELECT COUNT(*) AS n FROM messages WHERE group_id=? AND id>?').get(groupId, Number(state?.last_message_id || 0)));
612
+ return Number(row?.n || 0);
613
+ }
614
+ markReflected(groupId, messageId) {
615
+ this.db.prepare(`INSERT INTO reflection_state(group_id,last_message_id,last_reflected_at) VALUES(?,?,?)
616
+ ON CONFLICT(group_id) DO UPDATE SET last_message_id=excluded.last_message_id,last_reflected_at=excluded.last_reflected_at`)
617
+ .run(groupId, messageId, now());
618
+ }
619
+ memoryDocs(scopeType, scopeKey) {
620
+ const rows = many(this.db.prepare('SELECT doc_type,content,updated_at FROM memory_documents WHERE scope_type=? AND scope_key=?')
621
+ .all(scopeType, scopeKey));
622
+ return Object.fromEntries(rows.map(row => [row.doc_type, row.content]));
623
+ }
624
+ setMemoryDoc(scopeType, scopeKey, docType, content) {
625
+ this.db.prepare(`INSERT INTO memory_documents(scope_type,scope_key,doc_type,content,updated_at) VALUES(?,?,?,?,?)
626
+ ON CONFLICT(scope_type,scope_key,doc_type) DO UPDATE SET content=excluded.content,updated_at=excluded.updated_at`)
627
+ .run(scopeType, scopeKey, docType, String(content || ''), now());
628
+ }
629
+ appendMemoryDoc(scopeType, scopeKey, docType, content, maxChars = 24_000) {
630
+ const current = this.memoryDocs(scopeType, scopeKey)[docType] || '';
631
+ const joined = [current.trim(), String(content || '').trim()].filter(Boolean).join('\n');
632
+ this.setMemoryDoc(scopeType, scopeKey, docType, joined.slice(-maxChars));
633
+ }
634
+ appendDailyDoc(scopeType, scopeKey, date, note, maxChars = 24_000) {
635
+ const text = note.trim();
636
+ if (!text)
637
+ return;
638
+ const existing = this.memoryDocs(scopeType, scopeKey).daily || '';
639
+ const entries = parseDailyEntries(existing);
640
+ entries.push([date, text]);
641
+ this.setMemoryDoc(scopeType, scopeKey, 'daily', renderDailyEntries(entries).slice(-maxChars));
642
+ }
643
+ dailyEntries(scopeType, scopeKey) {
644
+ return parseDailyEntries(this.memoryDocs(scopeType, scopeKey).daily || '').map(([date, note]) => ({ date, note }));
645
+ }
646
+ setDailyEntries(scopeType, scopeKey, entries) {
647
+ this.setMemoryDoc(scopeType, scopeKey, 'daily', renderDailyEntries(entries.map(entry => [entry.date, entry.note])));
648
+ }
649
+ getChatSession(chatType, rowId) {
650
+ const table = chatType === 'group' ? 'groups' : 'members';
651
+ const row = one(this.db.prepare(`SELECT dsh_session_id FROM ${table} WHERE id=?`).get(rowId));
652
+ return row?.dsh_session_id || null;
653
+ }
654
+ setChatSession(chatType, rowId, sessionId) {
655
+ const table = chatType === 'group' ? 'groups' : 'members';
656
+ this.db.prepare(`UPDATE ${table} SET dsh_session_id=? WHERE id=?`).run(sessionId, rowId);
657
+ }
658
+ queueOutbox(accountId, chatType, targetId, content, scheduledAt = now()) {
659
+ const result = this.db.prepare(`INSERT INTO outbox(account_id,chat_type,target_id,content,status,scheduled_at)
660
+ VALUES(?,?,?,?, 'pending', ?)`).run(accountId, chatType, targetId, content, scheduledAt);
661
+ return Number(result.lastInsertRowid);
662
+ }
663
+ dueOutbox(limit = 20) {
664
+ return many(this.db.prepare(`SELECT * FROM outbox WHERE status='pending' AND scheduled_at<=? ORDER BY scheduled_at,id LIMIT ?`).all(now(), limit));
665
+ }
666
+ finishOutbox(id, error = null) {
667
+ this.db.prepare('UPDATE outbox SET status=?,sent_at=?,error=? WHERE id=?').run(error ? 'failed' : 'sent', now(), error, id);
668
+ }
669
+ }