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