paperkite 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.
Files changed (121) hide show
  1. package/README.md +81 -0
  2. package/data/flows.example.yml +65 -0
  3. package/data/settings.example.yml +13 -0
  4. package/dist/data/hooks/weekend-slot.d.ts +12 -0
  5. package/dist/data/hooks/weekend-slot.js +107 -0
  6. package/dist/data/hooks/weekend-slot.js.map +1 -0
  7. package/dist/packages/account-health/src/index.d.ts +8 -0
  8. package/dist/packages/account-health/src/index.js +37 -0
  9. package/dist/packages/account-health/src/index.js.map +1 -0
  10. package/dist/packages/archive/src/archiver.d.ts +98 -0
  11. package/dist/packages/archive/src/archiver.js +399 -0
  12. package/dist/packages/archive/src/archiver.js.map +1 -0
  13. package/dist/packages/archive/src/config.d.ts +22 -0
  14. package/dist/packages/archive/src/config.js +62 -0
  15. package/dist/packages/archive/src/config.js.map +1 -0
  16. package/dist/packages/archive/src/console/live.d.ts +24 -0
  17. package/dist/packages/archive/src/console/live.js +144 -0
  18. package/dist/packages/archive/src/console/live.js.map +1 -0
  19. package/dist/packages/archive/src/console/media.d.ts +66 -0
  20. package/dist/packages/archive/src/console/media.js +368 -0
  21. package/dist/packages/archive/src/console/media.js.map +1 -0
  22. package/dist/packages/archive/src/console/server.d.ts +12 -0
  23. package/dist/packages/archive/src/console/server.js +583 -0
  24. package/dist/packages/archive/src/console/server.js.map +1 -0
  25. package/dist/packages/archive/src/console/service.d.ts +13 -0
  26. package/dist/packages/archive/src/console/service.js +86 -0
  27. package/dist/packages/archive/src/console/service.js.map +1 -0
  28. package/dist/packages/archive/src/storage/index.d.ts +6 -0
  29. package/dist/packages/archive/src/storage/index.js +23 -0
  30. package/dist/packages/archive/src/storage/index.js.map +1 -0
  31. package/dist/packages/archive/src/storage/model.d.ts +296 -0
  32. package/dist/packages/archive/src/storage/model.js +151 -0
  33. package/dist/packages/archive/src/storage/model.js.map +1 -0
  34. package/dist/packages/archive/src/storage/postgres.d.ts +67 -0
  35. package/dist/packages/archive/src/storage/postgres.js +1021 -0
  36. package/dist/packages/archive/src/storage/postgres.js.map +1 -0
  37. package/dist/packages/archive/src/storage/sqlite.d.ts +59 -0
  38. package/dist/packages/archive/src/storage/sqlite.js +981 -0
  39. package/dist/packages/archive/src/storage/sqlite.js.map +1 -0
  40. package/dist/packages/notify-bark/src/index.d.ts +33 -0
  41. package/dist/packages/notify-bark/src/index.js +96 -0
  42. package/dist/packages/notify-bark/src/index.js.map +1 -0
  43. package/dist/packages/runtime-console/src/console/errors.d.ts +5 -0
  44. package/dist/packages/runtime-console/src/console/errors.js +11 -0
  45. package/dist/packages/runtime-console/src/console/errors.js.map +1 -0
  46. package/dist/packages/runtime-console/src/console/logs.d.ts +1 -0
  47. package/dist/packages/runtime-console/src/console/logs.js +27 -0
  48. package/dist/packages/runtime-console/src/console/logs.js.map +1 -0
  49. package/dist/packages/runtime-console/src/console/server.d.ts +6 -0
  50. package/dist/packages/runtime-console/src/console/server.js +182 -0
  51. package/dist/packages/runtime-console/src/console/server.js.map +1 -0
  52. package/dist/packages/runtime-console/src/index.d.ts +10 -0
  53. package/dist/packages/runtime-console/src/index.js +83 -0
  54. package/dist/packages/runtime-console/src/index.js.map +1 -0
  55. package/dist/src/app.d.ts +15 -0
  56. package/dist/src/app.js +34 -0
  57. package/dist/src/app.js.map +1 -0
  58. package/dist/src/cli.d.ts +2 -0
  59. package/dist/src/cli.js +250 -0
  60. package/dist/src/cli.js.map +1 -0
  61. package/dist/src/config/editor.d.ts +2 -0
  62. package/dist/src/config/editor.js +19 -0
  63. package/dist/src/config/editor.js.map +1 -0
  64. package/dist/src/config/loader.d.ts +6 -0
  65. package/dist/src/config/loader.js +188 -0
  66. package/dist/src/config/loader.js.map +1 -0
  67. package/dist/src/config/model.d.ts +71 -0
  68. package/dist/src/config/model.js +97 -0
  69. package/dist/src/config/model.js.map +1 -0
  70. package/dist/src/config/paths.d.ts +3 -0
  71. package/dist/src/config/paths.js +12 -0
  72. package/dist/src/config/paths.js.map +1 -0
  73. package/dist/src/config/settings.d.ts +19 -0
  74. package/dist/src/config/settings.js +44 -0
  75. package/dist/src/config/settings.js.map +1 -0
  76. package/dist/src/control/process-lock.d.ts +4 -0
  77. package/dist/src/control/process-lock.js +57 -0
  78. package/dist/src/control/process-lock.js.map +1 -0
  79. package/dist/src/control/socket.d.ts +14 -0
  80. package/dist/src/control/socket.js +131 -0
  81. package/dist/src/control/socket.js.map +1 -0
  82. package/dist/src/engine/hooks.d.ts +4 -0
  83. package/dist/src/engine/hooks.js +38 -0
  84. package/dist/src/engine/hooks.js.map +1 -0
  85. package/dist/src/engine/logger.d.ts +17 -0
  86. package/dist/src/engine/logger.js +78 -0
  87. package/dist/src/engine/logger.js.map +1 -0
  88. package/dist/src/engine/runtime.d.ts +78 -0
  89. package/dist/src/engine/runtime.js +799 -0
  90. package/dist/src/engine/runtime.js.map +1 -0
  91. package/dist/src/engine/scheduler.d.ts +9 -0
  92. package/dist/src/engine/scheduler.js +58 -0
  93. package/dist/src/engine/scheduler.js.map +1 -0
  94. package/dist/src/extensions/loader.d.ts +11 -0
  95. package/dist/src/extensions/loader.js +202 -0
  96. package/dist/src/extensions/loader.js.map +1 -0
  97. package/dist/src/extensions/manager.d.ts +1 -0
  98. package/dist/src/extensions/manager.js +121 -0
  99. package/dist/src/extensions/manager.js.map +1 -0
  100. package/dist/src/extensions/profile.d.ts +14 -0
  101. package/dist/src/extensions/profile.js +57 -0
  102. package/dist/src/extensions/profile.js.map +1 -0
  103. package/dist/src/extensions/registry.d.ts +19 -0
  104. package/dist/src/extensions/registry.js +62 -0
  105. package/dist/src/extensions/registry.js.map +1 -0
  106. package/dist/src/telegram/client.d.ts +20 -0
  107. package/dist/src/telegram/client.js +54 -0
  108. package/dist/src/telegram/client.js.map +1 -0
  109. package/dist/src/telegram/failure.d.ts +2 -0
  110. package/dist/src/telegram/failure.js +98 -0
  111. package/dist/src/telegram/failure.js.map +1 -0
  112. package/dist/src/telegram/login.d.ts +1 -0
  113. package/dist/src/telegram/login.js +31 -0
  114. package/dist/src/telegram/login.js.map +1 -0
  115. package/dist/src/telegram/pool.d.ts +47 -0
  116. package/dist/src/telegram/pool.js +300 -0
  117. package/dist/src/telegram/pool.js.map +1 -0
  118. package/dist/src/telegram/session-files.d.ts +4 -0
  119. package/dist/src/telegram/session-files.js +34 -0
  120. package/dist/src/telegram/session-files.js.map +1 -0
  121. package/package.json +62 -0
@@ -0,0 +1,981 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { albumEntryOf, buildContextEntries, entitiesJson, groupKey, mediaKey, normalizeBlockword, normalizeContextLimit, normalizeLimit, normalizeOffset, normalizeRecordId, normalizeTimeMode, normalizeUserId, parseEntities, splitTerms, toIsoDate, uniqueGroupKeys } from "./model.js";
5
+ const MESSAGE_COLUMNS = `
6
+ m.id AS row_id, m.message_id, m.chat_id, m.grouped_id, m.chat_title, m.date,
7
+ m.sender_id, m.sender_username, m.sender_first_name, m.sender_last_name,
8
+ m.has_media, m.media_type, m.message_type, m.text, m.entities,
9
+ m.reply_to_msg_id, m.forward_from_id, m.forward_from_name,
10
+ (
11
+ SELECT substr(p.text, 1, 120) FROM messages p
12
+ WHERE p.chat_id = m.chat_id AND p.message_id = m.reply_to_msg_id
13
+ AND p.blocked = 0
14
+ LIMIT 1
15
+ ) AS reply_to_text
16
+ `;
17
+ const MIME_SUBQUERY = `(
18
+ SELECT mf.mime_type FROM media_files mf
19
+ WHERE mf.chat_id = m.chat_id AND mf.message_id = m.message_id
20
+ ORDER BY mf.id DESC LIMIT 1
21
+ )`;
22
+ /** 相册以组内第一条在时间线中定位,窗口按条目推进。 */
23
+ const GROUP_FIRST_CONDITION = `(
24
+ m.grouped_id IS NULL
25
+ OR NOT EXISTS (
26
+ SELECT 1 FROM messages g
27
+ WHERE g.chat_id = m.chat_id AND g.grouped_id = m.grouped_id
28
+ AND g.blocked = 0
29
+ AND (g.date < m.date OR (g.date = m.date AND g.id < m.id))
30
+ )
31
+ )`;
32
+ export class SqliteArchiveStore {
33
+ file;
34
+ database;
35
+ /** 屏蔽词内存缓存(小写归一);读路径条件固定为 blocked = 0,与词数无关。 */
36
+ blockwords = [];
37
+ blockwordsVersion = 0;
38
+ /** 屏蔽用户内存缓存(sender_id 精确匹配)。 */
39
+ blockedUsers = [];
40
+ blockedUsersVersion = 0;
41
+ constructor(file) {
42
+ this.file = file;
43
+ mkdirSync(dirname(resolve(file)), { recursive: true });
44
+ this.database = new DatabaseSync(resolve(file));
45
+ }
46
+ async init() {
47
+ this.database.exec(`
48
+ CREATE TABLE IF NOT EXISTS messages (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ message_id INTEGER NOT NULL,
51
+ chat_id TEXT NOT NULL,
52
+ grouped_id TEXT,
53
+ chat_title TEXT,
54
+ sender_id TEXT,
55
+ sender_username TEXT,
56
+ sender_first_name TEXT,
57
+ sender_last_name TEXT,
58
+ date TEXT NOT NULL,
59
+ text TEXT,
60
+ message_type TEXT DEFAULT 'text',
61
+ reply_to_msg_id INTEGER,
62
+ forward_from_id TEXT,
63
+ forward_from_name TEXT,
64
+ entities TEXT,
65
+ has_media INTEGER NOT NULL DEFAULT 0,
66
+ media_type TEXT,
67
+ media_path TEXT,
68
+ created_at TEXT NOT NULL,
69
+ UNIQUE(message_id, chat_id)
70
+ );
71
+ CREATE INDEX IF NOT EXISTS idx_messages_chat_date ON messages(chat_id, date);
72
+ CREATE INDEX IF NOT EXISTS idx_messages_chat_date_id ON messages(chat_id, date, id);
73
+ CREATE INDEX IF NOT EXISTS idx_messages_chat_grouped ON messages(chat_id, grouped_id);
74
+ CREATE INDEX IF NOT EXISTS idx_messages_chat_grouped_date_id ON messages(chat_id, grouped_id, date, id);
75
+ CREATE INDEX IF NOT EXISTS idx_messages_date_id ON messages(date, id);
76
+ CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id);
77
+ CREATE INDEX IF NOT EXISTS idx_messages_chat_reply ON messages(chat_id, reply_to_msg_id);
78
+ CREATE INDEX IF NOT EXISTS idx_messages_forward_from ON messages(forward_from_id);
79
+ CREATE TABLE IF NOT EXISTS media_files (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ message_id INTEGER NOT NULL,
82
+ chat_id TEXT NOT NULL,
83
+ media_type TEXT NOT NULL,
84
+ file_name TEXT NOT NULL,
85
+ file_path TEXT NOT NULL,
86
+ file_size INTEGER,
87
+ mime_type TEXT,
88
+ created_at TEXT NOT NULL,
89
+ FOREIGN KEY (message_id, chat_id) REFERENCES messages (message_id, chat_id)
90
+ );
91
+ CREATE INDEX IF NOT EXISTS idx_media_message ON media_files(message_id, chat_id);
92
+ CREATE TABLE IF NOT EXISTS chats (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ chat_id TEXT UNIQUE NOT NULL,
95
+ title TEXT,
96
+ username TEXT,
97
+ type TEXT,
98
+ description TEXT,
99
+ members_count INTEGER,
100
+ last_sync TEXT,
101
+ created_at TEXT NOT NULL
102
+ );
103
+ CREATE TABLE IF NOT EXISTS sync_history (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ chat_id TEXT NOT NULL,
106
+ start_date TEXT,
107
+ end_date TEXT,
108
+ messages_count INTEGER,
109
+ media_count INTEGER,
110
+ sync_started_at TEXT,
111
+ sync_completed_at TEXT,
112
+ status TEXT DEFAULT 'running'
113
+ );
114
+ CREATE TABLE IF NOT EXISTS blockwords (
115
+ word TEXT PRIMARY KEY,
116
+ created_at TEXT NOT NULL
117
+ );
118
+ CREATE TABLE IF NOT EXISTS blocked_users (
119
+ user_id TEXT PRIMARY KEY,
120
+ name TEXT,
121
+ username TEXT,
122
+ created_at TEXT NOT NULL
123
+ );
124
+ `);
125
+ this.ensureLegacyColumns();
126
+ this.ensureBlockedIndexes();
127
+ this.blockwords = this.database
128
+ .prepare("SELECT word FROM blockwords ORDER BY word")
129
+ .all()
130
+ .map((row) => String(row.word));
131
+ this.blockedUsers = this.database
132
+ .prepare("SELECT user_id, name, username FROM blocked_users ORDER BY user_id")
133
+ .all()
134
+ .map(toBlockedUserInfo);
135
+ }
136
+ /** 老库迁移:messages 补 blocked 标志列与 entities 实体列。 */
137
+ ensureLegacyColumns() {
138
+ const columns = this.database.prepare("PRAGMA table_info(messages)").all();
139
+ const names = new Set(columns.map((column) => String(column.name)));
140
+ if (!names.has("blocked")) {
141
+ this.database.exec("ALTER TABLE messages ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0");
142
+ }
143
+ if (!names.has("entities")) {
144
+ this.database.exec("ALTER TABLE messages ADD COLUMN entities TEXT");
145
+ }
146
+ }
147
+ /** blocked 标志维护的部分索引:置位只扫未屏蔽行,解锁只扫已屏蔽行。 */
148
+ ensureBlockedIndexes() {
149
+ this.database.exec(`
150
+ CREATE INDEX IF NOT EXISTS idx_messages_blocked_zero ON messages(blocked) WHERE blocked = 0;
151
+ CREATE INDEX IF NOT EXISTS idx_messages_blocked_one ON messages(blocked) WHERE blocked = 1;
152
+ `);
153
+ }
154
+ async close() {
155
+ this.database.close();
156
+ }
157
+ async saveChat(chat) {
158
+ const statement = this.database.prepare(`
159
+ INSERT INTO chats (chat_id, title, username, type, description, members_count, last_sync, created_at)
160
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
161
+ ON CONFLICT(chat_id) DO UPDATE SET
162
+ title = excluded.title,
163
+ username = excluded.username,
164
+ type = excluded.type,
165
+ description = excluded.description,
166
+ members_count = excluded.members_count,
167
+ last_sync = excluded.last_sync
168
+ `);
169
+ const now = new Date().toISOString();
170
+ statement.run(chat.chatId, chat.title ?? null, chat.username ?? null, chat.type ?? null, chat.description ?? null, chat.membersCount ?? null, now, now);
171
+ return true;
172
+ }
173
+ async startSyncSession(chatId, startDate, endDate) {
174
+ const result = this.database.prepare(`
175
+ INSERT INTO sync_history (chat_id, start_date, end_date, sync_started_at, status)
176
+ VALUES (?, ?, ?, ?, 'running')
177
+ `).run(chatId, startDate, endDate, new Date().toISOString());
178
+ return Number(result.lastInsertRowid);
179
+ }
180
+ async completeSyncSession(sessionId, messagesCount, mediaCount) {
181
+ if (sessionId < 0)
182
+ return;
183
+ this.database.prepare(`
184
+ UPDATE sync_history
185
+ SET sync_completed_at = ?, messages_count = ?, media_count = ?, status = 'completed'
186
+ WHERE id = ?
187
+ `).run(new Date().toISOString(), messagesCount, mediaCount, sessionId);
188
+ }
189
+ async getLastMessageInfo(chatId) {
190
+ const row = this.database.prepare(`
191
+ SELECT message_id, date FROM messages
192
+ WHERE chat_id = ?
193
+ ORDER BY date DESC, message_id DESC
194
+ LIMIT 1
195
+ `).get(chatId);
196
+ return row ? { messageId: Number(row.message_id), date: row.date } : undefined;
197
+ }
198
+ async getChatUsername(chatId) {
199
+ const row = this.database
200
+ .prepare(`SELECT username FROM chats WHERE chat_id = ?`)
201
+ .get(chatId);
202
+ const username = row?.username?.trim();
203
+ return username || undefined;
204
+ }
205
+ async messageIdsExist(chatId, messageIds) {
206
+ const ids = [...new Set(messageIds.map(Number))];
207
+ if (!ids.length)
208
+ return new Set();
209
+ const placeholders = ids.map(() => "?").join(", ");
210
+ const rows = this.database.prepare(`
211
+ SELECT message_id FROM messages
212
+ WHERE chat_id = ? AND message_id IN (${placeholders})
213
+ `).all(chatId, ...ids);
214
+ return new Set(rows.map((row) => Number(row.message_id)));
215
+ }
216
+ async saveBatch(messages, media) {
217
+ if (!messages.length)
218
+ return { messages: 0, media: 0 };
219
+ const messageStatement = this.database.prepare(`
220
+ INSERT OR IGNORE INTO messages
221
+ (message_id, chat_id, grouped_id, chat_title, sender_id, sender_username,
222
+ sender_first_name, sender_last_name, date, text, entities, message_type,
223
+ reply_to_msg_id, forward_from_id, forward_from_name,
224
+ has_media, media_type, media_path, created_at, blocked)
225
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
226
+ `);
227
+ const mediaStatement = this.database.prepare(`
228
+ INSERT INTO media_files
229
+ (message_id, chat_id, media_type, file_name, file_path, file_size, mime_type, created_at)
230
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
231
+ `);
232
+ const now = new Date().toISOString();
233
+ this.database.exec("BEGIN");
234
+ try {
235
+ let inserted = 0;
236
+ for (const row of messages) {
237
+ const result = messageStatement.run(row.messageId, row.chatId, row.groupedId ?? null, row.chatTitle ?? null, row.senderId ?? null, row.senderUsername ?? null, row.senderFirstName ?? null, row.senderLastName ?? null, toIsoDate(row.date), row.text, entitiesJson(row.entities), row.messageType ?? "text", row.replyToMessageId ?? null, row.forwardFromId ?? null, row.forwardFromName ?? null, row.hasMedia ? 1 : 0, row.mediaType ?? null, row.mediaFilePath ?? null, now, blockedOf(row, this.blockwords, this.blockedUsers) ? 1 : 0);
238
+ inserted += Number(result.changes);
239
+ }
240
+ for (const row of media) {
241
+ mediaStatement.run(row.messageId, row.chatId, row.mediaType, row.fileName, row.filePath, row.fileSize ?? null, row.mimeType ?? null, now);
242
+ }
243
+ this.database.exec("COMMIT");
244
+ return { messages: inserted, media: media.length };
245
+ }
246
+ catch (error) {
247
+ this.database.exec("ROLLBACK");
248
+ throw error;
249
+ }
250
+ }
251
+ async listBlockwords() {
252
+ return { words: [...this.blockwords], version: this.blockwordsVersion };
253
+ }
254
+ async addBlockword(word) {
255
+ const normalized = normalizeBlockword(word);
256
+ if (normalized === undefined)
257
+ return "invalid";
258
+ this.database.exec("BEGIN");
259
+ try {
260
+ const result = this.database.prepare("INSERT OR IGNORE INTO blockwords (word, created_at) VALUES (?, ?)").run(normalized, new Date().toISOString());
261
+ if (Number(result.changes) === 0) {
262
+ this.database.exec("ROLLBACK");
263
+ return "exists";
264
+ }
265
+ this.markBlockwordMatches(normalized);
266
+ this.blockwords = [...this.blockwords, normalized];
267
+ this.blockwordsVersion += 1;
268
+ this.database.exec("COMMIT");
269
+ return "added";
270
+ }
271
+ catch (error) {
272
+ this.database.exec("ROLLBACK");
273
+ throw error;
274
+ }
275
+ }
276
+ async removeBlockword(word) {
277
+ const normalized = normalizeBlockword(word);
278
+ if (normalized === undefined)
279
+ return false;
280
+ this.database.exec("BEGIN");
281
+ try {
282
+ const result = this.database.prepare("DELETE FROM blockwords WHERE word = ?").run(normalized);
283
+ if (Number(result.changes) === 0) {
284
+ this.database.exec("ROLLBACK");
285
+ return false;
286
+ }
287
+ const next = this.blockwords.filter((item) => item !== normalized);
288
+ this.unmarkBlockwordMatches(normalized, next);
289
+ this.blockwords = next;
290
+ this.blockwordsVersion += 1;
291
+ this.database.exec("COMMIT");
292
+ return true;
293
+ }
294
+ catch (error) {
295
+ this.database.exec("ROLLBACK");
296
+ throw error;
297
+ }
298
+ }
299
+ async listBlockedUsers() {
300
+ return { users: [...this.blockedUsers], version: this.blockedUsersVersion };
301
+ }
302
+ async addBlockedUser(input) {
303
+ const userId = normalizeUserId(input.userId);
304
+ if (userId === undefined)
305
+ return "invalid";
306
+ this.database.exec("BEGIN");
307
+ try {
308
+ const result = this.database.prepare(`
309
+ INSERT OR IGNORE INTO blocked_users (user_id, name, username, created_at)
310
+ VALUES (?, ?, ?, ?)
311
+ `).run(userId, input.name?.trim() || null, input.username?.trim() || null, new Date().toISOString());
312
+ if (Number(result.changes) === 0) {
313
+ this.database.exec("ROLLBACK");
314
+ return "exists";
315
+ }
316
+ this.markBlockedUserMatches(userId);
317
+ this.blockedUsers = [...this.blockedUsers, toBlockedUserInfo({
318
+ user_id: userId,
319
+ name: input.name?.trim() || null,
320
+ username: input.username?.trim() || null
321
+ })];
322
+ this.blockedUsersVersion += 1;
323
+ this.database.exec("COMMIT");
324
+ return "added";
325
+ }
326
+ catch (error) {
327
+ this.database.exec("ROLLBACK");
328
+ throw error;
329
+ }
330
+ }
331
+ async removeBlockedUser(userId) {
332
+ const normalized = normalizeUserId(userId);
333
+ if (normalized === undefined)
334
+ return false;
335
+ this.database.exec("BEGIN");
336
+ try {
337
+ const result = this.database.prepare("DELETE FROM blocked_users WHERE user_id = ?").run(normalized);
338
+ if (Number(result.changes) === 0) {
339
+ this.database.exec("ROLLBACK");
340
+ return false;
341
+ }
342
+ const next = this.blockedUsers.filter((user) => user.userId !== normalized);
343
+ this.unmarkBlockedUserMatches(normalized, next);
344
+ this.blockedUsers = next;
345
+ this.blockedUsersVersion += 1;
346
+ this.database.exec("COMMIT");
347
+ return true;
348
+ }
349
+ catch (error) {
350
+ this.database.exec("ROLLBACK");
351
+ throw error;
352
+ }
353
+ }
354
+ /** 增量置位:只置未屏蔽行中命中新词的行的标志。 */
355
+ markBlockwordMatches(word) {
356
+ this.database.prepare(`
357
+ UPDATE messages SET blocked = 1
358
+ WHERE id IN (SELECT id FROM messages WHERE blocked = 0 AND instr(lower(coalesce(text, '')), ?) > 0)
359
+ `).run(word);
360
+ }
361
+ /** 增量解锁:只重算已屏蔽且命中被删词的行,其余行不受影响。 */
362
+ unmarkBlockwordMatches(word, remaining) {
363
+ const holds = [
364
+ ...remaining.map((item) => `instr(lower(coalesce(text, '')), ?) > 0`),
365
+ ...this.blockedUsers.map(() => `coalesce(sender_id, '') = ?`)
366
+ ];
367
+ const values = [...remaining, ...this.blockedUsers.map((user) => user.userId)];
368
+ const sql = unblockSql(`instr(lower(coalesce(text, '')), ?) > 0`, word, holds, values);
369
+ this.database.prepare(sql.sql).run(...sql.values);
370
+ }
371
+ /** 增量置位:只置未屏蔽行中发送者为新用户的行的标志。 */
372
+ markBlockedUserMatches(userId) {
373
+ this.database.prepare(`
374
+ UPDATE messages SET blocked = 1
375
+ WHERE id IN (SELECT id FROM messages WHERE blocked = 0 AND coalesce(sender_id, '') = ?)
376
+ `).run(userId);
377
+ }
378
+ /** 增量解锁:只重算已屏蔽且发送者为被删用户的行。 */
379
+ unmarkBlockedUserMatches(userId, remaining) {
380
+ const holds = [
381
+ ...remaining.map((user) => `coalesce(sender_id, '') = ?`),
382
+ ...this.blockwords.map((word) => `instr(lower(coalesce(text, '')), ?) > 0`)
383
+ ];
384
+ const values = [...remaining.map((user) => user.userId), ...this.blockwords];
385
+ const sql = unblockSql("coalesce(sender_id, '') = ?", userId, holds, values);
386
+ this.database.prepare(sql.sql).run(...sql.values);
387
+ }
388
+ async searchStructured(query) {
389
+ const { where: rowWhere, values: rowValues } = buildWhere(query);
390
+ const { where: memberWhere, values: memberValues } = buildWhere(query, "g");
391
+ const entry = buildSearchWhere(rowWhere, rowValues, memberWhere, memberValues);
392
+ const limit = normalizeLimit(query.limit);
393
+ const offset = normalizeOffset(query.offset);
394
+ const row = rowWhere.replace(/^WHERE\s+/, "");
395
+ const entryCount = this.database.prepare(`SELECT (
396
+ (SELECT COUNT(*) FROM messages m
397
+ WHERE m.grouped_id IS NULL AND ${row})
398
+ + (SELECT COUNT(DISTINCT m.grouped_id) FROM messages m
399
+ WHERE m.grouped_id IS NOT NULL AND ${row})
400
+ ) AS count`).get(...rowValues, ...rowValues);
401
+ const messageCount = this.database.prepare(`SELECT COUNT(*) AS count FROM messages m ${rowWhere}`).get(...rowValues);
402
+ const rows = this.database.prepare(`
403
+ SELECT ${MESSAGE_COLUMNS}, ${MIME_SUBQUERY} AS mime_type
404
+ FROM messages m
405
+ ${entry.where}
406
+ ORDER BY m.date DESC, m.id DESC
407
+ LIMIT ? OFFSET ?
408
+ `).all(...entry.values, limit, offset);
409
+ const attached = this.attachMedia(rows);
410
+ const groupMap = await this.fetchGroupsAttached(uniqueGroupKeys(rows));
411
+ return {
412
+ items: buildContextEntries(attached, groupMap),
413
+ total: Number(entryCount.count),
414
+ totalMessages: Number(messageCount.count),
415
+ limit,
416
+ offset
417
+ };
418
+ }
419
+ async listChatLedger(limit) {
420
+ const cap = Math.min(500, Math.max(1, Number.isInteger(limit) ? limit : 300));
421
+ const rows = this.database.prepare(`
422
+ SELECT
423
+ c.chat_id AS chat_id,
424
+ COALESCE(c.title, (
425
+ SELECT m.chat_title FROM messages m
426
+ WHERE m.chat_id = c.chat_id AND m.blocked = 0
427
+ ORDER BY m.date DESC, m.id DESC LIMIT 1
428
+ )) AS title,
429
+ c.username,
430
+ c.type,
431
+ c.description,
432
+ c.members_count,
433
+ (
434
+ SELECT COUNT(*) FROM messages m
435
+ WHERE m.chat_id = c.chat_id AND m.blocked = 0
436
+ ) AS count,
437
+ (
438
+ SELECT m.date FROM messages m
439
+ WHERE m.chat_id = c.chat_id AND m.blocked = 0
440
+ ORDER BY m.date DESC, m.id DESC LIMIT 1
441
+ ) AS last_date,
442
+ (
443
+ SELECT substr(m.text, 1, 120) FROM messages m
444
+ WHERE m.chat_id = c.chat_id AND m.blocked = 0
445
+ ORDER BY m.date DESC, m.id DESC LIMIT 1
446
+ ) AS last_text
447
+ FROM chats c
448
+ ORDER BY last_date DESC NULLS LAST, c.chat_id
449
+ LIMIT ?
450
+ `).all(cap);
451
+ return rows.map((row) => ({
452
+ chatId: String(row.chat_id),
453
+ title: String(row.title ?? row.chat_id),
454
+ username: optionalString(row.username),
455
+ type: optionalString(row.type),
456
+ description: optionalString(row.description),
457
+ membersCount: optionalNumber(row.members_count),
458
+ count: Number(row.count ?? 0),
459
+ lastDate: optionalString(row.last_date),
460
+ lastText: optionalString(row.last_text)
461
+ }));
462
+ }
463
+ async getMessageByRecordId(recordId) {
464
+ const row = this.database.prepare(`
465
+ SELECT ${MESSAGE_COLUMNS}, ${MIME_SUBQUERY} AS mime_type
466
+ FROM messages m
467
+ WHERE m.id = ? AND m.blocked = 0
468
+ LIMIT 1
469
+ `).get(Number(normalizeRecordId(recordId)));
470
+ return row ? this.attachMedia([row])[0] : undefined;
471
+ }
472
+ async searchSenders(query) {
473
+ const limit = normalizeLimit(query.limit);
474
+ const conditions = ["m.blocked = 0"];
475
+ const values = [];
476
+ const q = (query.q ?? "").trim().replace(/^@/, "");
477
+ if (q) {
478
+ conditions.push(`(
479
+ m.sender_id = ?
480
+ OR m.sender_id LIKE ? COLLATE NOCASE
481
+ OR m.sender_username LIKE ? COLLATE NOCASE
482
+ OR m.sender_first_name LIKE ? COLLATE NOCASE
483
+ OR m.sender_last_name LIKE ? COLLATE NOCASE
484
+ )`);
485
+ const like = `%${escapeLike(q)}%`;
486
+ values.push(q, like, like, like, like);
487
+ }
488
+ if (query.chatId?.trim()) {
489
+ conditions.push("m.chat_id = ?");
490
+ values.push(query.chatId.trim());
491
+ }
492
+ const where = `WHERE ${conditions.join(" AND ")}`;
493
+ const total = this.database.prepare(`SELECT COUNT(DISTINCT m.sender_id) AS count FROM messages m ${where}`).get(...values);
494
+ const rows = this.database.prepare(`
495
+ SELECT m.sender_id,
496
+ MAX(coalesce(m.sender_username, '')) AS sender_username,
497
+ MAX(coalesce(m.sender_first_name, '')) AS sender_first_name,
498
+ MAX(coalesce(m.sender_last_name, '')) AS sender_last_name,
499
+ COUNT(*) AS count
500
+ FROM messages m
501
+ ${where}
502
+ GROUP BY m.sender_id
503
+ ORDER BY count DESC, m.sender_id
504
+ LIMIT ?
505
+ `).all(...values, limit);
506
+ return {
507
+ items: rows.filter((row) => row.sender_id !== null && row.sender_id !== "").map(toSenderInfo),
508
+ total: Number(total.count)
509
+ };
510
+ }
511
+ async getSenderSummary(senderId, chatId) {
512
+ const id = senderId.trim();
513
+ const exists = this.database.prepare("SELECT 1 FROM messages WHERE sender_id = ? LIMIT 1").get(id);
514
+ if (exists === undefined)
515
+ return undefined;
516
+ const chatFilter = chatId?.trim() ? " AND m.chat_id = ?" : "";
517
+ const chatValues = chatId?.trim() ? [id, chatId.trim()] : [id];
518
+ const stats = this.database.prepare(`
519
+ SELECT COUNT(*) AS total, MIN(m.date) AS first_date, MAX(m.date) AS last_date
520
+ FROM messages m
521
+ WHERE m.sender_id = ? AND m.blocked = 0${chatFilter}
522
+ `).get(...chatValues);
523
+ const chatRows = this.database.prepare(`
524
+ SELECT m.chat_id, c.title AS chat_title, COUNT(*) AS count,
525
+ MAX(m.date) AS last_date,
526
+ (
527
+ SELECT substr(m2.text, 1, 120)
528
+ FROM messages m2
529
+ WHERE m2.chat_id = m.chat_id AND m2.sender_id = m.sender_id AND m2.blocked = 0
530
+ ORDER BY m2.date DESC, m2.id DESC LIMIT 1
531
+ ) AS last_text
532
+ FROM messages m
533
+ LEFT JOIN chats c ON c.chat_id = m.chat_id
534
+ WHERE m.sender_id = ? AND m.blocked = 0${chatFilter}
535
+ GROUP BY m.chat_id
536
+ ORDER BY last_date DESC, m.chat_id
537
+ `).all(...chatValues);
538
+ const identity = this.database.prepare(`
539
+ SELECT m.sender_id,
540
+ MAX(coalesce(m.sender_username, '')) AS sender_username,
541
+ MAX(coalesce(m.sender_first_name, '')) AS sender_first_name,
542
+ MAX(coalesce(m.sender_last_name, '')) AS sender_last_name
543
+ FROM messages m
544
+ WHERE m.sender_id = ?
545
+ GROUP BY m.sender_id
546
+ `).get(id);
547
+ return {
548
+ sender: toSenderInfo(identity ?? { sender_id: id }),
549
+ total: Number(stats.total),
550
+ firstDate: optionalString(stats.first_date) ? toIsoDate(stats.first_date) : undefined,
551
+ lastDate: optionalString(stats.last_date) ? toIsoDate(stats.last_date) : undefined,
552
+ chats: chatRows.map((row) => ({
553
+ chatId: String(row.chat_id),
554
+ chatTitle: optionalString(row.chat_title),
555
+ count: Number(row.count),
556
+ lastDate: optionalString(row.last_date) ? toIsoDate(row.last_date) : undefined,
557
+ lastText: optionalString(row.last_text)
558
+ }))
559
+ };
560
+ }
561
+ async getReplyChain(recordId) {
562
+ const anchor = await this.getMessageByRecordId(recordId);
563
+ if (!anchor)
564
+ return undefined;
565
+ let parent;
566
+ if (anchor.replyToMessageId !== undefined) {
567
+ const row = this.database.prepare(`
568
+ SELECT ${MESSAGE_COLUMNS}, ${MIME_SUBQUERY} AS mime_type
569
+ FROM messages m
570
+ WHERE m.chat_id = ? AND m.message_id = ? AND m.blocked = 0
571
+ LIMIT 1
572
+ `).get(anchor.chatId, anchor.replyToMessageId);
573
+ if (row) {
574
+ const records = await this.attachMedia([row]);
575
+ const parentRecord = records[0];
576
+ if (parentRecord?.groupedId !== undefined) {
577
+ const group = await this.fetchGroupAttached(parentRecord.chatId, parentRecord.groupedId);
578
+ parent = group.length > 1
579
+ ? albumEntryOf(group, parentRecord.recordId)
580
+ : { kind: "message", record: parentRecord };
581
+ }
582
+ else if (parentRecord !== undefined) {
583
+ parent = { kind: "message", record: parentRecord };
584
+ }
585
+ }
586
+ }
587
+ const childRows = this.database.prepare(`
588
+ SELECT ${MESSAGE_COLUMNS}, ${MIME_SUBQUERY} AS mime_type
589
+ FROM messages m
590
+ WHERE m.chat_id = ? AND m.reply_to_msg_id = ? AND m.blocked = 0
591
+ ORDER BY m.date ASC, m.id ASC
592
+ LIMIT 100
593
+ `).all(anchor.chatId, anchor.messageId);
594
+ const children = childRows.length
595
+ ? buildContextEntries(await this.attachMedia(childRows), await this.fetchGroupsAttached(uniqueGroupKeys(childRows)))
596
+ : [];
597
+ return {
598
+ parent,
599
+ children,
600
+ replyToMessageId: anchor.replyToMessageId
601
+ };
602
+ }
603
+ async resolveMessageRef(ref) {
604
+ let chatId = ref.chatId?.trim() || "";
605
+ if (!chatId && ref.username?.trim()) {
606
+ const row = this.database
607
+ .prepare(`SELECT chat_id FROM chats WHERE username = ? LIMIT 1`)
608
+ .get(ref.username.trim());
609
+ chatId = row?.chat_id ?? "";
610
+ }
611
+ if (!chatId && ref.code?.trim()) {
612
+ const row = this.database
613
+ .prepare(`SELECT chat_id FROM chats WHERE chat_id IN ('-100' || ?, '-' || ?) LIMIT 1`)
614
+ .get(ref.code.trim(), ref.code.trim());
615
+ chatId = row?.chat_id ?? "";
616
+ }
617
+ if (!chatId)
618
+ return undefined;
619
+ const row = this.database
620
+ .prepare(`SELECT id FROM messages WHERE chat_id = ? AND message_id = ? LIMIT 1`)
621
+ .get(chatId, ref.messageId);
622
+ return row ? String(row.id) : undefined;
623
+ }
624
+ async getMessageContext(recordId, beforeN, afterN, beforeOffset = 0, afterOffset = 0) {
625
+ const beforeLimit = normalizeContextLimit(beforeN);
626
+ const afterLimit = normalizeContextLimit(afterN);
627
+ const beforeOff = normalizeOffset(beforeOffset);
628
+ const afterOff = normalizeOffset(afterOffset);
629
+ const anchorRecord = await this.getMessageByRecordId(recordId);
630
+ if (!anchorRecord) {
631
+ return { anchor: undefined, before: [], after: [], beforeN: 0, afterN: 0 };
632
+ }
633
+ let lower = { date: anchorRecord.date, id: Number(anchorRecord.recordId) };
634
+ let upper = lower;
635
+ let anchorGroup;
636
+ if (anchorRecord.groupedId !== undefined) {
637
+ const group = await this.fetchGroupAttached(anchorRecord.chatId, anchorRecord.groupedId);
638
+ if (group.length > 1) {
639
+ anchorGroup = group;
640
+ lower = { date: group[0].date, id: Number(group[0].recordId) };
641
+ const last = group[group.length - 1];
642
+ upper = { date: last.date, id: Number(last.recordId) };
643
+ }
644
+ }
645
+ const beforeRows = beforeLimit
646
+ ? this.database.prepare(`
647
+ SELECT ${MESSAGE_COLUMNS}, ${MIME_SUBQUERY} AS mime_type
648
+ FROM messages m
649
+ WHERE m.chat_id = ? AND (m.date < ? OR (m.date = ? AND m.id < ?))
650
+ AND ${GROUP_FIRST_CONDITION}
651
+ AND m.blocked = 0
652
+ ORDER BY m.date DESC, m.id DESC
653
+ LIMIT ? OFFSET ?
654
+ `).all(anchorRecord.chatId, lower.date, lower.date, lower.id, beforeLimit, beforeOff)
655
+ : [];
656
+ const afterRows = afterLimit
657
+ ? this.database.prepare(`
658
+ SELECT ${MESSAGE_COLUMNS}, ${MIME_SUBQUERY} AS mime_type
659
+ FROM messages m
660
+ WHERE m.chat_id = ? AND (m.date > ? OR (m.date = ? AND m.id > ?))
661
+ AND ${GROUP_FIRST_CONDITION}
662
+ AND m.blocked = 0
663
+ ORDER BY m.date ASC, m.id ASC
664
+ LIMIT ? OFFSET ?
665
+ `).all(anchorRecord.chatId, upper.date, upper.date, upper.id, afterLimit, afterOff)
666
+ : [];
667
+ const beforeCount = this.database.prepare(`
668
+ SELECT COUNT(*) AS count FROM messages m
669
+ WHERE m.chat_id = ? AND (m.date < ? OR (m.date = ? AND m.id < ?))
670
+ AND ${GROUP_FIRST_CONDITION}
671
+ AND m.blocked = 0
672
+ `).get(anchorRecord.chatId, lower.date, lower.date, lower.id);
673
+ const afterCount = this.database.prepare(`
674
+ SELECT COUNT(*) AS count FROM messages m
675
+ WHERE m.chat_id = ? AND (m.date > ? OR (m.date = ? AND m.id > ?))
676
+ AND ${GROUP_FIRST_CONDITION}
677
+ AND m.blocked = 0
678
+ `).get(anchorRecord.chatId, upper.date, upper.date, upper.id);
679
+ const groupMap = await this.fetchGroupsAttached(uniqueGroupKeys([...beforeRows, ...afterRows]));
680
+ const before = buildContextEntries(await this.attachMedia([...beforeRows].reverse()), groupMap);
681
+ const after = buildContextEntries(await this.attachMedia(afterRows), groupMap);
682
+ const anchor = anchorGroup !== undefined
683
+ ? albumEntryOf(anchorGroup, anchorRecord.recordId)
684
+ : { kind: "message", record: anchorRecord };
685
+ return {
686
+ anchor,
687
+ before,
688
+ after,
689
+ beforeN: Number(beforeCount.count),
690
+ afterN: Number(afterCount.count)
691
+ };
692
+ }
693
+ async fetchGroupAttached(chatId, groupedId) {
694
+ return this.attachMedia(this.groupRows([[chatId, groupedId]]));
695
+ }
696
+ async fetchGroupsAttached(keys) {
697
+ const result = new Map();
698
+ if (!keys.length)
699
+ return result;
700
+ const byKey = new Map();
701
+ for (const row of this.groupRows(keys)) {
702
+ pushGrouped(byKey, groupKey(String(row.chat_id), String(row.grouped_id)), row);
703
+ }
704
+ for (const [key, rows] of byKey) {
705
+ result.set(key, await this.attachMedia(rows));
706
+ }
707
+ return result;
708
+ }
709
+ groupRows(keys) {
710
+ const sql = `
711
+ SELECT ${MESSAGE_COLUMNS}, ${MIME_SUBQUERY} AS mime_type
712
+ FROM messages m
713
+ WHERE m.has_media = 1 AND m.blocked = 0 AND ${pairClause("grouped_id", keys)}
714
+ ORDER BY m.date ASC, m.id ASC
715
+ `;
716
+ return this.database.prepare(sql).all(...pairValues(keys));
717
+ }
718
+ async getMediaFileById(id) {
719
+ const row = this.database.prepare(`
720
+ SELECT mf.id, mf.message_id, mf.chat_id, mf.media_type, mf.file_name, mf.file_path, mf.file_size, mf.mime_type
721
+ FROM media_files mf
722
+ WHERE mf.id = ?
723
+ AND NOT EXISTS (
724
+ SELECT 1 FROM messages m
725
+ WHERE m.chat_id = mf.chat_id AND m.message_id = mf.message_id
726
+ AND m.blocked = 1
727
+ )
728
+ LIMIT 1
729
+ `).get(Number(normalizeRecordId(id)));
730
+ return row ? toStoredMediaFile(row) : undefined;
731
+ }
732
+ attachMedia(rows) {
733
+ if (!rows.length)
734
+ return [];
735
+ const pairKeys = rows.map((row) => [String(row.chat_id), Number(row.message_id)]);
736
+ const groupKeys = rows
737
+ .filter((row) => row.grouped_id !== null && row.grouped_id !== undefined)
738
+ .map((row) => [String(row.chat_id), String(row.grouped_id)]);
739
+ const mediaMap = this.fetchMediaFiles(pairKeys);
740
+ const albumMap = groupKeys.length
741
+ ? this.fetchAlbumRows(groupKeys)
742
+ : new Map();
743
+ return rows.map((row) => toMessageRecord(row, mediaMap, albumMap));
744
+ }
745
+ fetchMediaFiles(pairs) {
746
+ const sql = `
747
+ SELECT id, message_id, chat_id, media_type, mime_type
748
+ FROM media_files
749
+ WHERE ${pairClause("message_id", pairs)}
750
+ ORDER BY id ASC
751
+ `;
752
+ const rows = this.database.prepare(sql).all(...pairValues(pairs));
753
+ const map = new Map();
754
+ for (const row of rows) {
755
+ const file = toStoredMediaFile(row);
756
+ pushGrouped(map, mediaKey(file.chatId, file.messageId), file);
757
+ }
758
+ return map;
759
+ }
760
+ fetchAlbumRows(pairs) {
761
+ const sql = `
762
+ SELECT m.id AS row_id, m.message_id, m.chat_id, m.grouped_id, m.date,
763
+ m.has_media, m.media_type, m.message_type,
764
+ ${MIME_SUBQUERY} AS mime_type
765
+ FROM messages m
766
+ WHERE m.has_media = 1 AND m.blocked = 0 AND ${pairClause("grouped_id", pairs)}
767
+ ORDER BY m.date ASC, m.id ASC
768
+ `;
769
+ const rows = this.database.prepare(sql).all(...pairValues(pairs));
770
+ const map = new Map();
771
+ for (const row of rows) {
772
+ pushGrouped(map, groupKey(String(row.chat_id), String(row.grouped_id)), toAlbumRow(row));
773
+ }
774
+ return map;
775
+ }
776
+ }
777
+ function pairClause(column, pairs) {
778
+ return pairs
779
+ .map(() => `(chat_id = ? AND ${column} = ?)`)
780
+ .join(" OR ");
781
+ }
782
+ function pairValues(pairs) {
783
+ return pairs.flatMap(([chatId, second]) => [chatId, second]);
784
+ }
785
+ function pushGrouped(map, key, item) {
786
+ let items = map.get(key);
787
+ if (!items) {
788
+ items = [];
789
+ map.set(key, items);
790
+ }
791
+ items.push(item);
792
+ }
793
+ function toStoredMediaFile(row) {
794
+ return {
795
+ id: String(row.id),
796
+ messageId: Number(row.message_id),
797
+ chatId: String(row.chat_id),
798
+ mediaType: String(row.media_type ?? "document"),
799
+ fileName: optionalString(row.file_name),
800
+ filePath: optionalString(row.file_path),
801
+ fileSize: optionalNumber(row.file_size),
802
+ mimeType: optionalString(row.mime_type)
803
+ };
804
+ }
805
+ function toAlbumRow(row) {
806
+ return {
807
+ recordId: String(row.row_id ?? row.id ?? ""),
808
+ messageId: Number(row.message_id),
809
+ chatId: String(row.chat_id),
810
+ groupedId: String(row.grouped_id),
811
+ date: toIsoDate(row.date),
812
+ hasMedia: Boolean(row.has_media),
813
+ mediaType: optionalString(row.media_type),
814
+ messageType: optionalString(row.message_type),
815
+ mimeType: optionalString(row.mime_type)
816
+ };
817
+ }
818
+ function toMessageRecord(row, mediaMap, albumMap) {
819
+ const chatId = String(row.chat_id ?? "");
820
+ const messageId = Number(row.message_id);
821
+ const groupedId = optionalString(row.grouped_id);
822
+ const mediaFiles = mediaMap.get(mediaKey(chatId, messageId)) ?? [];
823
+ const albumRows = groupedId !== undefined ? albumMap.get(groupKey(chatId, groupedId)) ?? [] : [];
824
+ return {
825
+ recordId: String(row.row_id ?? row.id ?? ""),
826
+ messageId,
827
+ chatId,
828
+ groupedId,
829
+ chatTitle: optionalString(row.chat_title),
830
+ date: toIsoDate(row.date),
831
+ senderId: optionalString(row.sender_id),
832
+ senderUsername: optionalString(row.sender_username),
833
+ senderFirstName: optionalString(row.sender_first_name),
834
+ senderLastName: optionalString(row.sender_last_name),
835
+ hasMedia: Boolean(row.has_media),
836
+ mediaType: optionalString(row.media_type),
837
+ messageType: optionalString(row.message_type) ?? "text",
838
+ mimeType: optionalString(row.mime_type),
839
+ replyToMessageId: row.reply_to_msg_id === null || row.reply_to_msg_id === undefined ? undefined : Number(row.reply_to_msg_id),
840
+ replyToText: optionalString(row.reply_to_text),
841
+ forwardFromId: optionalString(row.forward_from_id),
842
+ forwardFromName: optionalString(row.forward_from_name),
843
+ entities: parseEntities(row.entities),
844
+ text: String(row.text ?? ""),
845
+ mediaFiles,
846
+ albumRows
847
+ };
848
+ }
849
+ /** 检索基线:普通消息取自身;相册取组内第一条作代表,组内任一条命中即整体命中。 */
850
+ function buildSearchWhere(rowWhere, rowValues, memberWhere, memberValues) {
851
+ const row = rowWhere?.replace(/^WHERE\s+/, "");
852
+ const member = memberWhere
853
+ ? ` OR EXISTS (
854
+ SELECT 1 FROM messages g
855
+ WHERE g.chat_id = m.chat_id AND g.grouped_id = m.grouped_id
856
+ AND ${memberWhere.replace(/^WHERE\s+/, "")}
857
+ )`
858
+ : "";
859
+ const matches = row ? ` AND (${row}${member})` : "";
860
+ return {
861
+ where: `WHERE ${GROUP_FIRST_CONDITION} AND m.blocked = 0${matches}`,
862
+ values: [...rowValues, ...memberValues]
863
+ };
864
+ }
865
+ function buildWhere(query, alias = "m") {
866
+ const conditions = [];
867
+ const values = [];
868
+ for (const term of splitTerms(query.keyword)) {
869
+ conditions.push(`${alias}.text LIKE ? COLLATE NOCASE`);
870
+ values.push(`%${escapeLike(term)}%`);
871
+ }
872
+ for (const term of splitTerms(query.excludeKeyword)) {
873
+ conditions.push(`${alias}.text NOT LIKE ? COLLATE NOCASE`);
874
+ values.push(`%${escapeLike(term)}%`);
875
+ }
876
+ const chatIds = (query.chatIds ?? []).map((id) => id.trim()).filter(Boolean);
877
+ if (chatIds.length > 0) {
878
+ conditions.push(`${alias}.chat_id IN (${chatIds.map(() => "?").join(", ")})`);
879
+ values.push(...chatIds);
880
+ }
881
+ if (query.chatTitle?.trim()) {
882
+ conditions.push(`${alias}.chat_title LIKE ? COLLATE NOCASE`);
883
+ values.push(`%${escapeLike(query.chatTitle.trim())}%`);
884
+ }
885
+ const senderIds = (query.senderIds ?? []).map((id) => id.trim()).filter(Boolean);
886
+ if (senderIds.length > 0) {
887
+ conditions.push(`${alias}.sender_id IN (${senderIds.map(() => "?").join(", ")})`);
888
+ values.push(...senderIds);
889
+ }
890
+ if (query.forwardFromId?.trim()) {
891
+ conditions.push(`${alias}.forward_from_id = ?`);
892
+ values.push(query.forwardFromId.trim());
893
+ }
894
+ if (query.forwardFromName?.trim()) {
895
+ conditions.push(`${alias}.forward_from_name LIKE ? COLLATE NOCASE`);
896
+ values.push(`%${escapeLike(query.forwardFromName.trim())}%`);
897
+ }
898
+ appendTimeWhere(conditions, values, query, alias);
899
+ conditions.push(`${alias}.blocked = 0`);
900
+ return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", values };
901
+ }
902
+ function appendTimeWhere(conditions, values, query, alias = "m") {
903
+ const mode = normalizeTimeMode(query.timeMode);
904
+ if (mode === "off" || (!query.dateFrom && !query.dateTo))
905
+ return;
906
+ const column = `${alias}.date`;
907
+ if (mode === "include") {
908
+ if (query.dateFrom) {
909
+ conditions.push(`${column} >= ?`);
910
+ values.push(query.dateFrom);
911
+ }
912
+ if (query.dateTo) {
913
+ conditions.push(`${column} <= ?`);
914
+ values.push(query.dateTo);
915
+ }
916
+ return;
917
+ }
918
+ if (query.dateFrom && query.dateTo) {
919
+ conditions.push(`NOT (${column} >= ? AND ${column} <= ?)`);
920
+ values.push(query.dateFrom, query.dateTo);
921
+ }
922
+ else if (query.dateFrom) {
923
+ conditions.push(`${column} < ?`);
924
+ values.push(query.dateFrom);
925
+ }
926
+ else if (query.dateTo) {
927
+ conditions.push(`${column} > ?`);
928
+ values.push(query.dateTo);
929
+ }
930
+ }
931
+ function escapeLike(value) {
932
+ return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
933
+ }
934
+ /** 屏蔽词命中判定(子串、大小写不敏感);空文本永不命中。 */
935
+ function matchesBlockword(text, words) {
936
+ if (text === undefined || text === "")
937
+ return false;
938
+ const lower = text.toLowerCase();
939
+ return words.some((word) => lower.includes(word));
940
+ }
941
+ /** 新落库消息是否屏蔽:命中任一屏蔽词,或发送者在屏蔽用户名单内。 */
942
+ function blockedOf(row, words, users) {
943
+ if (matchesBlockword(row.text, words))
944
+ return true;
945
+ return row.senderId !== undefined && users.some((user) => user.userId === row.senderId);
946
+ }
947
+ /**
948
+ * 增量解锁语句:已屏蔽且命中被删实体(match/matchValue)的行,
949
+ * 在剩余规则(holds/values,全不命中)时解锁。
950
+ * 命中行先经子查询物化,避免 UPDATE 边扫部分索引边改 blocked 时漏行。
951
+ */
952
+ function unblockSql(match, matchValue, holds, holdValues) {
953
+ const not = holds.length ? ` AND NOT (${holds.join(" OR ")})` : "";
954
+ return {
955
+ sql: `UPDATE messages SET blocked = 0
956
+ WHERE id IN (SELECT id FROM messages WHERE blocked = 1 AND ${match}${not})`,
957
+ values: [matchValue, ...holdValues]
958
+ };
959
+ }
960
+ function toBlockedUserInfo(row) {
961
+ return {
962
+ userId: String(row.user_id),
963
+ name: optionalString(row.name),
964
+ username: optionalString(row.username)
965
+ };
966
+ }
967
+ function toSenderInfo(row) {
968
+ return {
969
+ senderId: String(row.sender_id),
970
+ username: optionalString(row.sender_username),
971
+ firstName: optionalString(row.sender_first_name),
972
+ lastName: optionalString(row.sender_last_name)
973
+ };
974
+ }
975
+ function optionalString(value) {
976
+ return value === undefined || value === null || value === "" ? undefined : String(value);
977
+ }
978
+ function optionalNumber(value) {
979
+ return value === undefined || value === null || value === "" ? undefined : Number(value);
980
+ }
981
+ //# sourceMappingURL=sqlite.js.map