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