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.
- package/README.md +81 -0
- package/data/flows.example.yml +65 -0
- package/data/settings.example.yml +13 -0
- package/dist/data/hooks/weekend-slot.d.ts +12 -0
- package/dist/data/hooks/weekend-slot.js +107 -0
- package/dist/data/hooks/weekend-slot.js.map +1 -0
- package/dist/packages/account-health/src/index.d.ts +8 -0
- package/dist/packages/account-health/src/index.js +37 -0
- package/dist/packages/account-health/src/index.js.map +1 -0
- package/dist/packages/archive/src/archiver.d.ts +98 -0
- package/dist/packages/archive/src/archiver.js +399 -0
- package/dist/packages/archive/src/archiver.js.map +1 -0
- package/dist/packages/archive/src/config.d.ts +22 -0
- package/dist/packages/archive/src/config.js +62 -0
- package/dist/packages/archive/src/config.js.map +1 -0
- package/dist/packages/archive/src/console/live.d.ts +24 -0
- package/dist/packages/archive/src/console/live.js +144 -0
- package/dist/packages/archive/src/console/live.js.map +1 -0
- package/dist/packages/archive/src/console/media.d.ts +66 -0
- package/dist/packages/archive/src/console/media.js +368 -0
- package/dist/packages/archive/src/console/media.js.map +1 -0
- package/dist/packages/archive/src/console/server.d.ts +12 -0
- package/dist/packages/archive/src/console/server.js +583 -0
- package/dist/packages/archive/src/console/server.js.map +1 -0
- package/dist/packages/archive/src/console/service.d.ts +13 -0
- package/dist/packages/archive/src/console/service.js +86 -0
- package/dist/packages/archive/src/console/service.js.map +1 -0
- package/dist/packages/archive/src/storage/index.d.ts +6 -0
- package/dist/packages/archive/src/storage/index.js +23 -0
- package/dist/packages/archive/src/storage/index.js.map +1 -0
- package/dist/packages/archive/src/storage/model.d.ts +296 -0
- package/dist/packages/archive/src/storage/model.js +151 -0
- package/dist/packages/archive/src/storage/model.js.map +1 -0
- package/dist/packages/archive/src/storage/postgres.d.ts +67 -0
- package/dist/packages/archive/src/storage/postgres.js +1021 -0
- package/dist/packages/archive/src/storage/postgres.js.map +1 -0
- package/dist/packages/archive/src/storage/sqlite.d.ts +59 -0
- package/dist/packages/archive/src/storage/sqlite.js +981 -0
- package/dist/packages/archive/src/storage/sqlite.js.map +1 -0
- package/dist/packages/notify-bark/src/index.d.ts +33 -0
- package/dist/packages/notify-bark/src/index.js +96 -0
- package/dist/packages/notify-bark/src/index.js.map +1 -0
- package/dist/packages/runtime-console/src/console/errors.d.ts +5 -0
- package/dist/packages/runtime-console/src/console/errors.js +11 -0
- package/dist/packages/runtime-console/src/console/errors.js.map +1 -0
- package/dist/packages/runtime-console/src/console/logs.d.ts +1 -0
- package/dist/packages/runtime-console/src/console/logs.js +27 -0
- package/dist/packages/runtime-console/src/console/logs.js.map +1 -0
- package/dist/packages/runtime-console/src/console/server.d.ts +6 -0
- package/dist/packages/runtime-console/src/console/server.js +182 -0
- package/dist/packages/runtime-console/src/console/server.js.map +1 -0
- package/dist/packages/runtime-console/src/index.d.ts +10 -0
- package/dist/packages/runtime-console/src/index.js +83 -0
- package/dist/packages/runtime-console/src/index.js.map +1 -0
- package/dist/src/app.d.ts +15 -0
- package/dist/src/app.js +34 -0
- package/dist/src/app.js.map +1 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +250 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config/editor.d.ts +2 -0
- package/dist/src/config/editor.js +19 -0
- package/dist/src/config/editor.js.map +1 -0
- package/dist/src/config/loader.d.ts +6 -0
- package/dist/src/config/loader.js +188 -0
- package/dist/src/config/loader.js.map +1 -0
- package/dist/src/config/model.d.ts +71 -0
- package/dist/src/config/model.js +97 -0
- package/dist/src/config/model.js.map +1 -0
- package/dist/src/config/paths.d.ts +3 -0
- package/dist/src/config/paths.js +12 -0
- package/dist/src/config/paths.js.map +1 -0
- package/dist/src/config/settings.d.ts +19 -0
- package/dist/src/config/settings.js +44 -0
- package/dist/src/config/settings.js.map +1 -0
- package/dist/src/control/process-lock.d.ts +4 -0
- package/dist/src/control/process-lock.js +57 -0
- package/dist/src/control/process-lock.js.map +1 -0
- package/dist/src/control/socket.d.ts +14 -0
- package/dist/src/control/socket.js +131 -0
- package/dist/src/control/socket.js.map +1 -0
- package/dist/src/engine/hooks.d.ts +4 -0
- package/dist/src/engine/hooks.js +38 -0
- package/dist/src/engine/hooks.js.map +1 -0
- package/dist/src/engine/logger.d.ts +17 -0
- package/dist/src/engine/logger.js +78 -0
- package/dist/src/engine/logger.js.map +1 -0
- package/dist/src/engine/runtime.d.ts +78 -0
- package/dist/src/engine/runtime.js +799 -0
- package/dist/src/engine/runtime.js.map +1 -0
- package/dist/src/engine/scheduler.d.ts +9 -0
- package/dist/src/engine/scheduler.js +58 -0
- package/dist/src/engine/scheduler.js.map +1 -0
- package/dist/src/extensions/loader.d.ts +11 -0
- package/dist/src/extensions/loader.js +202 -0
- package/dist/src/extensions/loader.js.map +1 -0
- package/dist/src/extensions/manager.d.ts +1 -0
- package/dist/src/extensions/manager.js +121 -0
- package/dist/src/extensions/manager.js.map +1 -0
- package/dist/src/extensions/profile.d.ts +14 -0
- package/dist/src/extensions/profile.js +57 -0
- package/dist/src/extensions/profile.js.map +1 -0
- package/dist/src/extensions/registry.d.ts +19 -0
- package/dist/src/extensions/registry.js +62 -0
- package/dist/src/extensions/registry.js.map +1 -0
- package/dist/src/telegram/client.d.ts +20 -0
- package/dist/src/telegram/client.js +54 -0
- package/dist/src/telegram/client.js.map +1 -0
- package/dist/src/telegram/failure.d.ts +2 -0
- package/dist/src/telegram/failure.js +98 -0
- package/dist/src/telegram/failure.js.map +1 -0
- package/dist/src/telegram/login.d.ts +1 -0
- package/dist/src/telegram/login.js +31 -0
- package/dist/src/telegram/login.js.map +1 -0
- package/dist/src/telegram/pool.d.ts +47 -0
- package/dist/src/telegram/pool.js +300 -0
- package/dist/src/telegram/pool.js.map +1 -0
- package/dist/src/telegram/session-files.d.ts +4 -0
- package/dist/src/telegram/session-files.js +34 -0
- package/dist/src/telegram/session-files.js.map +1 -0
- 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
|