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