@snakechat/commskit-node 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -2
  3. package/dist/adapters-8ztjEYtz.d.ts +83 -0
  4. package/dist/call.d.ts +34 -0
  5. package/dist/call.js +51 -0
  6. package/dist/call.js.map +1 -0
  7. package/dist/chat.d.ts +64 -0
  8. package/dist/chat.js +30 -0
  9. package/dist/chat.js.map +1 -0
  10. package/dist/chunk-5BJ2SNM5.js +26 -0
  11. package/dist/chunk-5BJ2SNM5.js.map +1 -0
  12. package/dist/chunk-6I27CI32.js +586 -0
  13. package/dist/chunk-6I27CI32.js.map +1 -0
  14. package/dist/chunk-BBFVG7QU.js +52 -0
  15. package/dist/chunk-BBFVG7QU.js.map +1 -0
  16. package/dist/chunk-GR4PMGCS.js +82 -0
  17. package/dist/chunk-GR4PMGCS.js.map +1 -0
  18. package/dist/chunk-KL4WRZKZ.js +799 -0
  19. package/dist/chunk-KL4WRZKZ.js.map +1 -0
  20. package/dist/chunk-KLKRK6OG.js +107 -0
  21. package/dist/chunk-KLKRK6OG.js.map +1 -0
  22. package/dist/chunk-LY2GJWN7.js +708 -0
  23. package/dist/chunk-LY2GJWN7.js.map +1 -0
  24. package/dist/chunk-SEDRF62Q.js +39 -0
  25. package/dist/chunk-SEDRF62Q.js.map +1 -0
  26. package/dist/chunk-U4Z5INIW.js +1192 -0
  27. package/dist/chunk-U4Z5INIW.js.map +1 -0
  28. package/dist/chunk-XOXZZYSW.js +247 -0
  29. package/dist/chunk-XOXZZYSW.js.map +1 -0
  30. package/dist/chunk-XQ6FLRX7.js +179 -0
  31. package/dist/chunk-XQ6FLRX7.js.map +1 -0
  32. package/dist/chunk-XQFQN73U.js +38 -0
  33. package/dist/chunk-XQFQN73U.js.map +1 -0
  34. package/dist/connections-CGEPiZPF.d.ts +128 -0
  35. package/dist/core.d.ts +26 -0
  36. package/dist/core.js +89 -0
  37. package/dist/core.js.map +1 -0
  38. package/dist/devices-oLIB_M19.d.ts +97 -0
  39. package/dist/express.d.ts +19 -0
  40. package/dist/express.js +34 -0
  41. package/dist/express.js.map +1 -0
  42. package/dist/fastify.d.ts +22 -0
  43. package/dist/fastify.js +42 -0
  44. package/dist/fastify.js.map +1 -0
  45. package/dist/feature-B2bEXLZG.d.ts +233 -0
  46. package/dist/feature-DQUbnYE-.d.ts +284 -0
  47. package/dist/feature-DwKkuOBh.d.ts +195 -0
  48. package/dist/hub-CPrWmIvB.d.ts +174 -0
  49. package/dist/index.d.ts +33 -0
  50. package/dist/index.js +138 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/integration.d.ts +97 -0
  53. package/dist/integration.js +151 -0
  54. package/dist/integration.js.map +1 -0
  55. package/dist/models-D1lehXIu.d.ts +31 -0
  56. package/dist/models-DTXEX1Q8.d.ts +34 -0
  57. package/dist/moderation.d.ts +9 -0
  58. package/dist/moderation.js +48 -0
  59. package/dist/moderation.js.map +1 -0
  60. package/dist/push.d.ts +198 -0
  61. package/dist/push.js +475 -0
  62. package/dist/push.js.map +1 -0
  63. package/dist/redis.d.ts +103 -0
  64. package/dist/redis.js +296 -0
  65. package/dist/redis.js.map +1 -0
  66. package/dist/s3.d.ts +75 -0
  67. package/dist/s3.js +315 -0
  68. package/dist/s3.js.map +1 -0
  69. package/dist/util-B32rIBVA.d.ts +22 -0
  70. package/package.json +146 -4
@@ -0,0 +1,1192 @@
1
+ import {
2
+ LocalDiskStorage,
3
+ UploadRejected,
4
+ deleteMedia,
5
+ detectMediaType
6
+ } from "./chunk-XQ6FLRX7.js";
7
+ import {
8
+ HttpError,
9
+ dbBool,
10
+ dbNow,
11
+ dbTimestamp,
12
+ ddlTypes,
13
+ detectDialect,
14
+ firstQuery,
15
+ ok,
16
+ validationError
17
+ } from "./chunk-GR4PMGCS.js";
18
+ import {
19
+ RateLimit,
20
+ retryAfterSeconds
21
+ } from "./chunk-BBFVG7QU.js";
22
+ import {
23
+ LRU,
24
+ codePointLength,
25
+ iso,
26
+ nowIso,
27
+ parseDbDate,
28
+ pyBool,
29
+ pyStr,
30
+ truncateCodePoints
31
+ } from "./chunk-KLKRK6OG.js";
32
+ import {
33
+ logger
34
+ } from "./chunk-5BJ2SNM5.js";
35
+
36
+ // src/chat/dto.ts
37
+ function messageDto(m, viewerId, replyTo = null, overrides = {}) {
38
+ const senderType = m.message_type === "system" ? "system" : m.sender_id === viewerId ? "user" : "other";
39
+ return {
40
+ id: m.id,
41
+ chat_id: m.chat_id,
42
+ sender_id: m.sender_id,
43
+ sender_type: senderType,
44
+ content: m.content,
45
+ message_type: m.message_type,
46
+ timestamp: iso(m.created_at),
47
+ file_url: m.file_url,
48
+ file_name: m.file_name,
49
+ file_size: m.file_size,
50
+ file_type: m.file_type,
51
+ thumbnail_url: m.thumbnail_url,
52
+ duration: m.duration ? Math.trunc(m.duration) : m.duration,
53
+ is_read: Boolean(m.is_read),
54
+ is_delivered: Boolean(m.is_delivered),
55
+ call_id: m.call_id,
56
+ reply_to: replyTo ? { ...replyTo } : null,
57
+ ...overrides
58
+ };
59
+ }
60
+
61
+ // src/chat/service.ts
62
+ import { sql } from "kysely";
63
+ var DELETED_TYPE = "deleted";
64
+ var SYSTEM_TYPE = "system";
65
+ var RESERVED_TYPES = /* @__PURE__ */ new Set([DELETED_TYPE, SYSTEM_TYPE]);
66
+ var otherOf = (chat, userId) => chat.user1_id === userId ? chat.user2_id : chat.user1_id;
67
+ var ChatActionError = class extends Error {
68
+ constructor(error, code, status = 400, extra = {}) {
69
+ super(error);
70
+ this.error = error;
71
+ this.code = code;
72
+ this.status = status;
73
+ this.extra = extra;
74
+ this.name = "ChatActionError";
75
+ }
76
+ error;
77
+ code;
78
+ status;
79
+ extra;
80
+ frame() {
81
+ const payload = { type: "error", error: this.error };
82
+ if (this.code) payload.code = this.code;
83
+ return { ...payload, ...this.extra };
84
+ }
85
+ };
86
+ var lastMessageSummary = (m) => ({
87
+ content: m.content,
88
+ sender_id: m.sender_id,
89
+ timestamp: iso(m.created_at),
90
+ message_type: m.message_type,
91
+ file_url: m.file_url,
92
+ file_size: m.file_size,
93
+ file_type: m.file_type,
94
+ file_name: m.file_name,
95
+ // Additive vs. the legacy server: lets the conversation list show ticks.
96
+ is_read: Boolean(m.is_read),
97
+ is_delivered: Boolean(m.is_delivered)
98
+ });
99
+ var toMessageRow = (r) => ({
100
+ id: Number(r.id),
101
+ chat_id: Number(r.chat_id),
102
+ sender_id: String(r.sender_id),
103
+ content: r.content ?? null,
104
+ message_type: r.message_type ?? null,
105
+ file_url: r.file_url ?? null,
106
+ file_name: r.file_name ?? null,
107
+ file_size: r.file_size === null || r.file_size === void 0 ? null : Number(r.file_size),
108
+ file_type: r.file_type ?? null,
109
+ thumbnail_url: r.thumbnail_url ?? null,
110
+ duration: r.duration === null || r.duration === void 0 ? null : Number(r.duration),
111
+ is_read: Boolean(r.is_read),
112
+ is_delivered: Boolean(r.is_delivered),
113
+ call_id: r.call_id ?? null,
114
+ reply_to_id: r.reply_to_id === null || r.reply_to_id === void 0 ? null : Number(r.reply_to_id),
115
+ created_at: r.created_at ?? null
116
+ });
117
+ var toChatRow = (r) => ({
118
+ id: Number(r.id),
119
+ user1_id: String(r.user1_id),
120
+ user2_id: String(r.user2_id),
121
+ created_at: r.created_at ?? null,
122
+ updated_at: r.updated_at ?? null
123
+ });
124
+ function visibleTo(userId) {
125
+ return sql`(messages.id > coalesce((select c.up_to_message_id from comms_chat_cleared c where c.user_id = ${userId} and c.chat_id = messages.chat_id), 0)
126
+ and not exists (select 1 from comms_message_hidden h where h.user_id = ${userId} and h.message_id = messages.id))`;
127
+ }
128
+ var notDeleted = sql`(messages.message_type is null or messages.message_type != ${DELETED_TYPE})`;
129
+ var notRead = sql`coalesce(messages.is_read, false) = false`;
130
+ var notDelivered = sql`coalesce(messages.is_delivered, false) = false`;
131
+ var ChatService = class {
132
+ constructor(db, dialect) {
133
+ this.db = db;
134
+ this.dialect = dialect ?? detectDialect(db);
135
+ }
136
+ db;
137
+ dialect;
138
+ ts() {
139
+ return dbTimestamp(this.dialect);
140
+ }
141
+ // ---------------------------------------------------------------- chats
142
+ async getChatBetween(userA, userB) {
143
+ const [u1, u2] = [userA, userB].sort();
144
+ const row = await this.db.selectFrom("chats").selectAll().where("user1_id", "=", u1).where("user2_id", "=", u2).executeTakeFirst();
145
+ return row ? toChatRow(row) : null;
146
+ }
147
+ /** Idempotent per pair; survives a concurrent create (unique constraint). */
148
+ async getOrCreateChat(userA, userB) {
149
+ const existing = await this.getChatBetween(userA, userB);
150
+ if (existing) return existing;
151
+ const [u1, u2] = [userA, userB].sort();
152
+ try {
153
+ const now = this.ts();
154
+ await this.db.insertInto("chats").values({ user1_id: u1, user2_id: u2, created_at: now, updated_at: now }).execute();
155
+ } catch {
156
+ }
157
+ const found = await this.getChatBetween(userA, userB);
158
+ if (!found) throw new Error("Could not create chat");
159
+ return found;
160
+ }
161
+ /** Chat only if `userId` is a participant. */
162
+ async getChatForUser(chatId, userId) {
163
+ const row = await this.db.selectFrom("chats").selectAll().where("id", "=", chatId).where((eb) => eb.or([eb("user1_id", "=", userId), eb("user2_id", "=", userId)])).executeTakeFirst();
164
+ return row ? toChatRow(row) : null;
165
+ }
166
+ async partnerIds(userId) {
167
+ const rows = await this.db.selectFrom("chats").select(["user1_id", "user2_id"]).where((eb) => eb.or([eb("user1_id", "=", userId), eb("user2_id", "=", userId)])).execute();
168
+ return rows.map((r) => String(r.user1_id === userId ? r.user2_id : r.user1_id));
169
+ }
170
+ /** Chat summaries, newest first. "Last message per chat" via MAX(id) (ids grow with time). */
171
+ async getConversations(userId) {
172
+ const chats = (await this.db.selectFrom("chats").selectAll().where((eb) => eb.or([eb("user1_id", "=", userId), eb("user2_id", "=", userId)])).execute()).map(toChatRow);
173
+ if (!chats.length) return [];
174
+ const chatIds = chats.map((c) => c.id);
175
+ const lastRows = await this.db.selectFrom("messages").select(["chat_id", (eb) => eb.fn.max("id").as("last_id")]).where("chat_id", "in", chatIds).where(visibleTo(userId)).groupBy("chat_id").execute();
176
+ const lastIds = lastRows.map((r) => Number(r.last_id));
177
+ const lastById = /* @__PURE__ */ new Map();
178
+ if (lastIds.length) {
179
+ for (const m of await this.db.selectFrom("messages").selectAll().where("id", "in", lastIds).execute()) {
180
+ const row = toMessageRow(m);
181
+ lastById.set(row.chat_id, row);
182
+ }
183
+ }
184
+ const unreadRows = await this.db.selectFrom("messages").select(["chat_id", (eb) => eb.fn.countAll().as("n")]).where("chat_id", "in", chatIds).where("sender_id", "!=", userId).where(notRead).where(notDeleted).where(visibleTo(userId)).groupBy("chat_id").execute();
185
+ const unread = new Map(unreadRows.map((r) => [Number(r.chat_id), Number(r.n)]));
186
+ const convs = chats.map((c) => ({
187
+ chat_id: c.id,
188
+ other_user_id: otherOf(c, userId),
189
+ updated_at: iso(c.updated_at),
190
+ last_message: lastById.has(c.id) ? lastMessageSummary(lastById.get(c.id)) : null,
191
+ unread_count: unread.get(c.id) ?? 0
192
+ }));
193
+ convs.sort((a, b) => {
194
+ const ua = a.updated_at ?? "";
195
+ const ub = b.updated_at ?? "";
196
+ return ua === ub ? b.chat_id - a.chat_id : ua < ub ? 1 : -1;
197
+ });
198
+ return convs;
199
+ }
200
+ // ------------------------------------------------------------- messages
201
+ /** Stores a message and bumps the chat's `updated_at` (one transaction). */
202
+ async sendMessage(chatId, senderId, content, messageType = "text", opts = {}) {
203
+ const replyTo = await this.validReplyTarget(chatId, opts.replyToId);
204
+ const now = this.ts();
205
+ const media = opts.media;
206
+ const row = await this.db.transaction().execute(async (trx) => {
207
+ const inserted = await trx.insertInto("messages").values({
208
+ chat_id: chatId,
209
+ sender_id: senderId,
210
+ content,
211
+ message_type: messageType,
212
+ reply_to_id: replyTo,
213
+ is_read: dbBool(this.dialect, false),
214
+ is_delivered: dbBool(this.dialect, false),
215
+ is_call: dbBool(this.dialect, Boolean(opts.callId)),
216
+ call_id: opts.callId || null,
217
+ file_url: media?.file_url ?? null,
218
+ file_name: media?.file_name ?? null,
219
+ file_size: media?.file_size ?? null,
220
+ file_type: media?.file_type ?? null,
221
+ thumbnail_url: media?.thumbnail_url ?? null,
222
+ duration: media?.duration ?? null,
223
+ created_at: now
224
+ }).returningAll().executeTakeFirstOrThrow();
225
+ await trx.updateTable("chats").set({ updated_at: now }).where("id", "=", chatId).execute();
226
+ return inserted;
227
+ });
228
+ return toMessageRow(row);
229
+ }
230
+ /** Only messages from the same chat can be quoted. */
231
+ async validReplyTarget(chatId, replyToId) {
232
+ if (replyToId === null || replyToId === void 0 || typeof replyToId === "boolean") return null;
233
+ const id = typeof replyToId === "number" ? Math.trunc(replyToId) : /^\s*-?\d+\s*$/.test(String(replyToId)) ? Number(replyToId) : NaN;
234
+ if (!Number.isFinite(id)) return null;
235
+ const r = await this.db.selectFrom("messages").select("id").where("id", "=", id).where("chat_id", "=", chatId).executeTakeFirst();
236
+ return r ? Number(r.id) : null;
237
+ }
238
+ async getReplyPreviews(messageIds) {
239
+ const ids = [...new Set(messageIds.filter((i) => i !== null && i !== void 0))];
240
+ const out = /* @__PURE__ */ new Map();
241
+ if (!ids.length) return out;
242
+ const rows = await this.db.selectFrom("messages").select(["id", "sender_id", "content", "message_type", "file_name"]).where("id", "in", ids).execute();
243
+ for (const r of rows) {
244
+ out.set(Number(r.id), {
245
+ id: Number(r.id),
246
+ sender_id: r.sender_id,
247
+ content: r.content,
248
+ message_type: r.message_type,
249
+ file_name: r.file_name
250
+ });
251
+ }
252
+ return out;
253
+ }
254
+ /** Newest first. With `viewerId`, that user's hidden / cleared messages are left out. */
255
+ async getMessagesPage(chatId, limit = 50, offset = 0, viewerId) {
256
+ let q = this.db.selectFrom("messages").selectAll().where("chat_id", "=", chatId);
257
+ if (viewerId) q = q.where(visibleTo(viewerId));
258
+ const rows = await q.orderBy("created_at", "desc").orderBy("id", "desc").limit(limit).offset(offset).execute();
259
+ return rows.map(toMessageRow);
260
+ }
261
+ async getLastMessage(chatId, viewerId) {
262
+ return (await this.getMessagesPage(chatId, 1, 0, viewerId))[0] ?? null;
263
+ }
264
+ async getMessage(messageId) {
265
+ const row = await this.db.selectFrom("messages").selectAll().where("id", "=", messageId).executeTakeFirst();
266
+ return row ? toMessageRow(row) : null;
267
+ }
268
+ /** Read receipts are per chat: everything the other person sent becomes read + delivered. */
269
+ async markMessagesAsRead(chatId, userId) {
270
+ await this.db.updateTable("messages").set({ is_read: dbBool(this.dialect, true), is_delivered: dbBool(this.dialect, true) }).where("chat_id", "=", chatId).where("sender_id", "!=", userId).where(notRead).execute();
271
+ }
272
+ async markDelivered(messageIds) {
273
+ if (!messageIds.length) return;
274
+ await this.db.updateTable("messages").set({ is_delivered: dbBool(this.dialect, true) }).where("id", "in", messageIds).execute();
275
+ }
276
+ /** Mark everything sent to `userId` delivered; returns [chat_id, sender_id, message_id] (system messages excluded). */
277
+ async markDeliveredForRecipient(userId) {
278
+ const rows = await this.db.selectFrom("messages").select(["chat_id", "sender_id", "id", "message_type"]).where(
279
+ "chat_id",
280
+ "in",
281
+ (eb) => eb.selectFrom("chats").select("id").where((e2) => e2.or([e2("user1_id", "=", userId), e2("user2_id", "=", userId)]))
282
+ ).where("sender_id", "!=", userId).where(notDelivered).execute();
283
+ if (rows.length) await this.markDelivered(rows.map((r) => Number(r.id)));
284
+ return rows.filter((r) => r.message_type !== SYSTEM_TYPE).map((r) => [Number(r.chat_id), String(r.sender_id), Number(r.id)]);
285
+ }
286
+ async getUnreadCount(userId) {
287
+ const r = await this.db.selectFrom("messages").innerJoin("chats", "messages.chat_id", "chats.id").select((eb) => eb.fn.count("messages.id").as("n")).where((eb) => eb.or([eb("chats.user1_id", "=", userId), eb("chats.user2_id", "=", userId)])).where("messages.sender_id", "!=", userId).where(notRead).where(notDeleted).where(visibleTo(userId)).executeTakeFirst();
288
+ return Number(r?.n ?? 0);
289
+ }
290
+ // ------------------------------------------------- delete / unsend / clear
291
+ /** Delete for me (idempotent). Does not check access; callers do. */
292
+ async hideMessage(userId, messageId) {
293
+ const existing = await this.db.selectFrom("comms_message_hidden").select("id").where("user_id", "=", userId).where("message_id", "=", messageId).executeTakeFirst();
294
+ if (existing) return;
295
+ try {
296
+ await this.db.insertInto("comms_message_hidden").values({ user_id: userId, message_id: messageId, created_at: this.ts() }).execute();
297
+ } catch {
298
+ }
299
+ }
300
+ /**
301
+ * Rewrite the row as `deleted` for everyone; returns the new row and the media URLs to delete.
302
+ * `windowSeconds` > 0: throws `window_expired` for older messages.
303
+ */
304
+ async unsendMessage(messageId, windowSeconds) {
305
+ const message = await this.getMessage(messageId);
306
+ if (!message) throw new ChatActionError("Message not found", "not_found", 404);
307
+ const created = parseDbDate(message.created_at);
308
+ if (windowSeconds > 0 && created) {
309
+ const age = (dbNow().getTime() - created.getTime()) / 1e3;
310
+ if (age > windowSeconds) {
311
+ throw new ChatActionError("This message can no longer be deleted for everyone", "window_expired", 403);
312
+ }
313
+ }
314
+ const urls = [message.file_url, message.thumbnail_url].filter((u) => !!u);
315
+ await this.db.updateTable("messages").set({
316
+ message_type: DELETED_TYPE,
317
+ content: null,
318
+ file_url: null,
319
+ file_name: null,
320
+ file_size: null,
321
+ file_type: null,
322
+ thumbnail_url: null,
323
+ duration: null
324
+ }).where("id", "=", messageId).execute();
325
+ const row = await this.getMessage(messageId);
326
+ return [row, urls];
327
+ }
328
+ /** Hide every message of the chat, up to now, for `userId` only. Returns `cleared_at`. */
329
+ async clearChat(userId, chatId) {
330
+ const maxRow = await this.db.selectFrom("messages").select((eb) => eb.fn.max("id").as("m")).where("chat_id", "=", chatId).executeTakeFirst();
331
+ const upTo = Number(maxRow?.m ?? 0);
332
+ const now = dbNow();
333
+ const stamp = dbTimestamp(this.dialect, now);
334
+ const row = await this.db.selectFrom("comms_chat_cleared").selectAll().where("user_id", "=", userId).where("chat_id", "=", chatId).executeTakeFirst();
335
+ if (!row) {
336
+ await this.db.insertInto("comms_chat_cleared").values({ user_id: userId, chat_id: chatId, cleared_at: stamp, up_to_message_id: upTo }).execute();
337
+ } else {
338
+ await this.db.updateTable("comms_chat_cleared").set({ cleared_at: stamp, up_to_message_id: Math.max(Number(row.up_to_message_id ?? 0), upTo) }).where("id", "=", row.id).execute();
339
+ }
340
+ await this.db.deleteFrom("comms_message_hidden").where("user_id", "=", userId).where("message_id", "in", (eb) => eb.selectFrom("messages").select("id").where("chat_id", "=", chatId).where("id", "<=", upTo)).execute();
341
+ return now;
342
+ }
343
+ // ----------------------------------------------------------- moderation
344
+ /**
345
+ * Newest first: up to `before` older messages, the message itself, up to `after` newer ones.
346
+ * Without a `messageId` from this chat: the latest `before + after + 1` messages. Ignores hides / clears.
347
+ */
348
+ async messagesAround(chatId, messageId, before = 20, after = 20) {
349
+ before = Math.max(0, before);
350
+ after = Math.max(0, after);
351
+ let anchor = null;
352
+ if (messageId !== null) {
353
+ const r = await this.db.selectFrom("messages").select("id").where("id", "=", messageId).where("chat_id", "=", chatId).executeTakeFirst();
354
+ anchor = r ? Number(r.id) : null;
355
+ }
356
+ if (anchor === null) return this.getMessagesPage(chatId, before + after + 1);
357
+ const older = await this.db.selectFrom("messages").selectAll().where("chat_id", "=", chatId).where("id", "<=", anchor).orderBy("id", "desc").limit(before + 1).execute();
358
+ const newer = after ? await this.db.selectFrom("messages").selectAll().where("chat_id", "=", chatId).where("id", ">", anchor).orderBy("id", "asc").limit(after).execute() : [];
359
+ return [...newer.reverse().map(toMessageRow), ...older.map(toMessageRow)];
360
+ }
361
+ // -------------------------------------------------------------- deletion
362
+ /**
363
+ * Delete every chat `userId` is in, with all its messages (a 1:1 chat has one shared copy, so the
364
+ * partner loses that conversation too), hides and clears. Returns counts and the media URLs to delete.
365
+ */
366
+ async deleteUserData(userId) {
367
+ const chatIds = (await this.db.selectFrom("chats").select("id").where((eb) => eb.or([eb("user1_id", "=", userId), eb("user2_id", "=", userId)])).execute()).map((r) => Number(r.id));
368
+ const counts = { chats: chatIds.length, messages: 0 };
369
+ const urls = [];
370
+ await this.db.transaction().execute(async (trx) => {
371
+ if (chatIds.length) {
372
+ for (const r of await trx.selectFrom("messages").select(["file_url", "thumbnail_url"]).where("chat_id", "in", chatIds).execute()) {
373
+ for (const u of [r.file_url, r.thumbnail_url]) if (u) urls.push(u);
374
+ }
375
+ const messageIds = (eb) => eb.selectFrom("messages").select("id").where("chat_id", "in", chatIds);
376
+ await trx.deleteFrom("comms_message_hidden").where("message_id", "in", messageIds).execute();
377
+ await trx.deleteFrom("comms_chat_cleared").where("chat_id", "in", chatIds).execute();
378
+ await trx.updateTable("messages").set({ reply_to_id: null }).where("chat_id", "in", chatIds).execute();
379
+ const res = await trx.deleteFrom("messages").where("chat_id", "in", chatIds).executeTakeFirst();
380
+ counts.messages = Number(res.numDeletedRows ?? 0);
381
+ await trx.deleteFrom("chats").where("id", "in", chatIds).execute();
382
+ }
383
+ await trx.deleteFrom("comms_message_hidden").where("user_id", "=", userId).execute();
384
+ await trx.deleteFrom("comms_chat_cleared").where("user_id", "=", userId).execute();
385
+ });
386
+ return [counts, urls];
387
+ }
388
+ // ---------------------------------------------------------- call entries
389
+ /** Set a call entry's text; returns its message id (null when none). Used by the call/chat link. */
390
+ async rewriteCallMessage(callId, content) {
391
+ const r = await this.db.selectFrom("messages").select("id").where("call_id", "=", callId).executeTakeFirst();
392
+ if (!r) return null;
393
+ await this.db.updateTable("messages").set({ content }).where("id", "=", r.id).execute();
394
+ return Number(r.id);
395
+ }
396
+ };
397
+
398
+ // src/chat/handlers.ts
399
+ var ATTACHMENT_PREVIEW = "\u{1F4CE} Attachment";
400
+ var errorFrame = (error, extra = {}) => ({ type: "error", error, ...extra });
401
+ function pyInt(value) {
402
+ if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
403
+ if (typeof value === "boolean") return value ? 1 : 0;
404
+ if (typeof value === "string" && /^\s*[+-]?\d+\s*$/.test(value)) return Number(value);
405
+ throw new Error(`invalid literal for int() with base 10: ${JSON.stringify(value)}`);
406
+ }
407
+ function intOrNull(value) {
408
+ if (value === null || value === void 0 || value === "" || typeof value === "boolean") return null;
409
+ try {
410
+ return pyInt(value);
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
415
+ var errMsg = (e) => e instanceof Error ? e.message : String(e);
416
+ var ChatHandlers = class {
417
+ constructor(feature) {
418
+ this.feature = feature;
419
+ this.hub = feature.hub;
420
+ }
421
+ feature;
422
+ hub;
423
+ pairChatIds = new LRU(2e4);
424
+ get config() {
425
+ return this.feature.config;
426
+ }
427
+ // Read on every use: integrations (e.g. moderation) may wrap feature.policy after install.
428
+ get policy() {
429
+ return this.feature.policy;
430
+ }
431
+ service(db) {
432
+ return new ChatService(db, this.hub.dialect);
433
+ }
434
+ register() {
435
+ const on = (t, fn) => this.hub.on(t, fn.bind(this));
436
+ on("req_users", this.reqUsers);
437
+ on("get_messages", this.getMessages);
438
+ on("send_message", this.sendMessage);
439
+ on("mark_read", this.markRead);
440
+ on("typing", this.typing);
441
+ on("media_upload", this.mediaUpload);
442
+ on("delete_message", this.deleteMessage);
443
+ on("clear_chat", this.clearChat);
444
+ }
445
+ forgetUser(userId) {
446
+ this.pairChatIds.discardWhere((pair) => pair.split("\0").includes(userId));
447
+ }
448
+ reply(userId, payload) {
449
+ return this.hub.send(userId, payload);
450
+ }
451
+ async failed(userId, message, err, error) {
452
+ logger.error(`${message?.type ?? "chat handler"} failed`, err);
453
+ await this.hub.reportHandlerError(userId, message, err);
454
+ await this.reply(userId, errorFrame(error));
455
+ }
456
+ /** Seconds to wait (0 = allowed). Takes a token when allowed. */
457
+ async rateLimited(userId, action, limit) {
458
+ if (!limit) return 0;
459
+ return this.hub.rateLimiter.hit(`${action}:${userId}`, limit);
460
+ }
461
+ // ---------------------------------------------------------------- lists
462
+ async reqUsers(userId, message, db) {
463
+ try {
464
+ const convs = (await this.service(db).getConversations(userId)).filter((c) => c.other_user_id !== userId);
465
+ const otherIds = convs.map((c) => c.other_user_id);
466
+ const profiles = await this.hub.profiles(otherIds);
467
+ const online = otherIds.length ? await this.hub.connections.getOnlineUsers(otherIds) : /* @__PURE__ */ new Set();
468
+ const blocks = otherIds.length ? await this.policy.blockStatus(db, userId, otherIds) : {};
469
+ const users = convs.map((conv) => {
470
+ const other = conv.other_user_id;
471
+ const profile = profiles[other];
472
+ const last = conv.last_message;
473
+ const [isBlocked, blockedByOther] = blocks[other] ?? [false, false];
474
+ return {
475
+ user_id: other,
476
+ username: profile?.name ? profile.name : other,
477
+ avatar: profile ? profile.avatar ?? null : null,
478
+ status: online.has(other) ? "online" : "offline",
479
+ updated_at: last ? last.timestamp : null,
480
+ unread_count: conv.unread_count,
481
+ last_message: last,
482
+ chat_id: conv.chat_id,
483
+ is_blocked: Boolean(isBlocked),
484
+ blocked_by_other: Boolean(blockedByOther)
485
+ };
486
+ });
487
+ users.sort((a, b) => {
488
+ const ka = `${a.updated_at ?? ""}\0${a.user_id}`;
489
+ const kb = `${b.updated_at ?? ""}\0${b.user_id}`;
490
+ return ka < kb ? 1 : ka > kb ? -1 : 0;
491
+ });
492
+ await this.reply(userId, { type: "res_users", users });
493
+ } catch (err) {
494
+ logger.error("req_users failed", err);
495
+ await this.hub.reportHandlerError(userId, message, err);
496
+ await this.reply(userId, { type: "req_users_error", error: `Failed to retrieve users: ${errMsg(err)}` });
497
+ }
498
+ }
499
+ async getMessages(userId, message, db) {
500
+ try {
501
+ const service = this.service(db);
502
+ const cfg = this.config;
503
+ const limit = Math.max(1, Math.min(pyInt(message.limit ?? cfg.defaultPageSize), cfg.maxPageSize));
504
+ const offset = Math.max(0, pyInt(message.offset ?? 0));
505
+ let chat;
506
+ let otherId;
507
+ if (pyBool(message.chat_id)) {
508
+ chat = await service.getChatForUser(pyInt(message.chat_id), userId);
509
+ if (!chat) {
510
+ await this.reply(userId, errorFrame("Chat not found"));
511
+ return;
512
+ }
513
+ otherId = otherOf(chat, userId);
514
+ } else {
515
+ if (!pyBool(message.candidate_user_id)) {
516
+ await this.reply(userId, errorFrame("Missing required fields: chat_id or candidate_user_id"));
517
+ return;
518
+ }
519
+ otherId = pyStr(message.candidate_user_id);
520
+ chat = await service.getChatBetween(userId, otherId);
521
+ }
522
+ let rows = chat ? await service.getMessagesPage(chat.id, limit + 1, offset, userId) : [];
523
+ const hasMore = rows.length > limit;
524
+ rows = rows.slice(0, limit);
525
+ const last = rows.length && offset === 0 ? rows[0] : chat ? await service.getLastMessage(chat.id, userId) : null;
526
+ const profile = await this.hub.profile(otherId);
527
+ const blocks = await this.policy.blockStatus(db, userId, [otherId]);
528
+ const [isBlocked, blockedByOther] = blocks[otherId] ?? [false, false];
529
+ const info = {
530
+ user_id: otherId,
531
+ username: profile?.name ? profile.name : otherId,
532
+ avatar: profile ? profile.avatar ?? null : null,
533
+ status: await this.hub.connections.isOnline(otherId) ? "online" : "offline",
534
+ last_seen: last ? iso(last.created_at) : null,
535
+ ...await this.policy.extraConversationInfo(db, userId, otherId),
536
+ is_blocked: Boolean(isBlocked),
537
+ blocked_by_other: Boolean(blockedByOther)
538
+ };
539
+ const previews = await service.getReplyPreviews(rows.map((m) => m.reply_to_id));
540
+ const messages = rows.map((m) => messageDto(m, userId, m.reply_to_id ? previews.get(m.reply_to_id) : null));
541
+ await this.reply(userId, { type: "res_messages", messages, offset, has_more: hasMore, info });
542
+ } catch (err) {
543
+ await this.failed(userId, message, err, `Failed to retrieve messages: ${errMsg(err)}`);
544
+ }
545
+ }
546
+ // -------------------------------------------------------------- sending
547
+ /**
548
+ * Store + deliver a message (WS `send_message`, `POST /chats/send`, `sendMessageAs`).
549
+ * Returns the MessageDto as the sender sees it. Throws `ChatActionError` when refused.
550
+ */
551
+ async sendText(db, sender, recipient, content, opts = {}) {
552
+ const checkPolicy = opts.checkPolicy ?? true;
553
+ const rateLimit = opts.rateLimit ?? true;
554
+ let messageType = opts.messageType ?? "text";
555
+ if (sender === recipient) throw new ChatActionError("Cannot send a message to yourself.", void 0, 400);
556
+ const text = typeof content === "string" ? content : pyStr(content);
557
+ const maxLen = this.config.maxTextLength;
558
+ if (maxLen && codePointLength(text) > maxLen) {
559
+ throw new ChatActionError(`Message is too long (max ${maxLen} characters).`, "too_long", 422, { max: maxLen });
560
+ }
561
+ if (rateLimit) {
562
+ const wait = await this.rateLimited(sender, "chat.message", this.config.messageRate);
563
+ if (wait) {
564
+ const seconds = retryAfterSeconds(wait);
565
+ throw new ChatActionError(`Too many messages. Try again in ${seconds} s.`, "rate_limited", 429, { retry_after: seconds });
566
+ }
567
+ }
568
+ if (checkPolicy && !await this.policy.canMessage(db, sender, recipient)) {
569
+ throw new ChatActionError(this.policy.refusalMessage, void 0, 403);
570
+ }
571
+ if (RESERVED_TYPES.has(messageType)) messageType = "text";
572
+ const service = this.service(db);
573
+ const chat = await service.getOrCreateChat(sender, recipient);
574
+ const row = await service.sendMessage(chat.id, sender, text, messageType, { replyToId: opts.replyToId });
575
+ const replyTo = row.reply_to_id ? (await service.getReplyPreviews([row.reply_to_id])).get(row.reply_to_id) : null;
576
+ const preview = messageType === "text" ? truncateCodePoints(text, this.config.pushPreviewChars) : ATTACHMENT_PREVIEW;
577
+ return this.deliver(db, row, recipient, replyTo, text, text, preview);
578
+ }
579
+ async sendMessage(userId, message, db) {
580
+ try {
581
+ const recipient = message.candidate_user_id;
582
+ const content = message.content;
583
+ if (!pyBool(recipient) || !pyBool(content)) {
584
+ await this.reply(userId, errorFrame("Missing required fields: candidate_user_id, content"));
585
+ return;
586
+ }
587
+ await this.sendText(db, userId, pyStr(recipient), content, {
588
+ messageType: pyBool(message.message_type) ? pyStr(message.message_type) : "text",
589
+ replyToId: message.reply_to_id
590
+ });
591
+ } catch (err) {
592
+ if (err instanceof ChatActionError) await this.reply(userId, err.frame());
593
+ else await this.failed(userId, message, err, `Failed to send message: ${errMsg(err)}`);
594
+ }
595
+ }
596
+ /** new_message -> recipient, mark delivered, offline push, message_sent -> sender. */
597
+ async deliver(db, row, recipient, replyTo, recipientContent, senderContent, preview) {
598
+ const sender = row.sender_id;
599
+ const delivered = await this.hub.send(recipient, {
600
+ type: "new_message",
601
+ message: messageDto(row, recipient, replyTo, { content: recipientContent, is_read: false, is_delivered: true })
602
+ });
603
+ if (delivered) await this.service(db).markDelivered([row.id]);
604
+ await this.notifyOffline(recipient, sender, row.chat_id, preview);
605
+ const sent = messageDto(row, sender, replyTo, { content: senderContent, is_read: false, is_delivered: delivered });
606
+ await this.hub.send(sender, { type: "message_sent", message: sent });
607
+ await this.hub.events.emit("chat.message_sent", sent);
608
+ return sent;
609
+ }
610
+ /** Push when the recipient has no foreground socket anywhere. */
611
+ async notifyOffline(recipient, sender, chatId, preview, title, extra) {
612
+ if (await this.hub.connections.isOnline(recipient)) return;
613
+ const t = title ?? await this.hub.displayName(sender, "New message");
614
+ const data = {
615
+ ...extra ?? {},
616
+ type: "chat",
617
+ cid: `messaging:${sender}`,
618
+ // legacy routing key used by installed apps
619
+ chat_id: chatId,
620
+ sender_id: sender
621
+ };
622
+ this.hub.spawn(this.hub.push(recipient, t, preview, data, this.config.pushChannel));
623
+ }
624
+ async mediaUpload(userId, message, db) {
625
+ const recipientRaw = message.target_user_id;
626
+ const fileName = message.file_name;
627
+ const fileData = message.file_data;
628
+ if (!pyBool(recipientRaw) || !pyBool(fileName) || !pyBool(fileData)) {
629
+ await this.reply(userId, errorFrame("Missing required fields: target_user_id, file_name, file_data"));
630
+ return;
631
+ }
632
+ const recipient = pyStr(recipientRaw);
633
+ const maxBytes = this.config.maxUploadBytes;
634
+ const tooLarge = errorFrame(`File too large. Maximum allowed size is ${Math.floor(maxBytes / (1024 * 1024))} MB.`);
635
+ try {
636
+ if (userId === recipient) {
637
+ await this.reply(userId, errorFrame("Cannot send a message to yourself."));
638
+ return;
639
+ }
640
+ if (!await this.policy.canMessage(db, userId, recipient)) {
641
+ await this.reply(userId, errorFrame(this.policy.refusalMessage));
642
+ return;
643
+ }
644
+ if (typeof fileData !== "string") {
645
+ await this.reply(userId, errorFrame("file_data must be a base64 string"));
646
+ return;
647
+ }
648
+ if (fileData.length > Math.floor(maxBytes * 4 / 3) + 4) {
649
+ await this.reply(userId, tooLarge);
650
+ return;
651
+ }
652
+ const wait = await this.rateLimited(userId, "chat.upload", this.config.uploadRate);
653
+ if (wait) {
654
+ const seconds = retryAfterSeconds(wait);
655
+ await this.reply(userId, errorFrame(`Too many uploads. Try again in ${seconds} s.`, { code: "rate_limited", retry_after: seconds }));
656
+ return;
657
+ }
658
+ if (!/^[A-Za-z0-9+/\s]*={0,2}\s*$/.test(fileData) || fileData.replace(/\s/g, "").length % 4 === 1) {
659
+ await this.reply(userId, errorFrame("Invalid base64 file_data"));
660
+ return;
661
+ }
662
+ const content = Buffer.from(fileData, "base64");
663
+ if (content.length > maxBytes) {
664
+ await this.reply(userId, tooLarge);
665
+ return;
666
+ }
667
+ const mediaType = detectMediaType(pyStr(fileName), message.media_type);
668
+ if (mediaType === null) {
669
+ await this.reply(userId, errorFrame(`Unsupported file type: ${pyStr(fileName)}`));
670
+ return;
671
+ }
672
+ const service = this.service(db);
673
+ const chat = await service.getOrCreateChat(userId, recipient);
674
+ let media;
675
+ try {
676
+ media = await this.feature.storage.upload(chat.id, pyStr(fileName), content, mediaType);
677
+ } catch (err) {
678
+ if (err instanceof UploadRejected) {
679
+ await this.reply(userId, errorFrame(err.message));
680
+ return;
681
+ }
682
+ throw err;
683
+ }
684
+ if (pyBool(message.file_type) && !media.file_type) media.file_type = pyStr(message.file_type);
685
+ const text = `[${media.media_type.toUpperCase()}] ${pyStr(fileName)}`;
686
+ const row = await service.sendMessage(chat.id, userId, text, media.media_type, { media, replyToId: message.reply_to_id });
687
+ const replyTo = row.reply_to_id ? (await service.getReplyPreviews([row.reply_to_id])).get(row.reply_to_id) : null;
688
+ await this.reply(userId, {
689
+ type: "media_upload_success",
690
+ message: messageDto(row, userId, replyTo, { content: null, is_read: false, is_delivered: true })
691
+ });
692
+ await this.deliver(db, row, recipient, replyTo, text, null, ATTACHMENT_PREVIEW);
693
+ } catch (err) {
694
+ await this.failed(userId, message, err, `Server error: ${errMsg(err)}`);
695
+ }
696
+ }
697
+ // ------------------------------------------------------ receipts/typing
698
+ /** `chat_id` wins; otherwise the chat with `candidate_user_id`. Null if not a participant. */
699
+ async resolveChat(service, userId, chatId, candidate) {
700
+ const cid = intOrNull(chatId);
701
+ if (cid !== null) return service.getChatForUser(cid, userId);
702
+ if (candidate !== null && candidate !== void 0 && candidate !== "" && pyStr(candidate) !== userId) {
703
+ return service.getChatBetween(userId, pyStr(candidate));
704
+ }
705
+ return null;
706
+ }
707
+ async markRead(userId, message, db) {
708
+ const { chat_id: chatId, candidate_user_id: candidate } = message;
709
+ if (!pyBool(chatId) && !pyBool(candidate)) {
710
+ await this.reply(userId, errorFrame("Missing chat_id"));
711
+ return;
712
+ }
713
+ const service = this.service(db);
714
+ const chat = await this.resolveChat(service, userId, chatId, candidate);
715
+ if (!chat) {
716
+ await this.reply(userId, errorFrame("Chat not found"));
717
+ return;
718
+ }
719
+ await service.markMessagesAsRead(chat.id, userId);
720
+ await this.notifyRead(chat.id, userId, otherOf(chat, userId));
721
+ }
722
+ async notifyRead(chatId, reader, other) {
723
+ await this.hub.send(reader, { type: "chat_read", chat_id: chatId });
724
+ await this.hub.send(other, { type: "messages_read", chat_id: chatId, read_by: reader });
725
+ }
726
+ async typing(userId, message, db) {
727
+ const targetRaw = message.target_user_id;
728
+ if (!pyBool(targetRaw) || pyStr(targetRaw) === userId) return;
729
+ const target = pyStr(targetRaw);
730
+ const isTyping = pyBool(message.is_typing ?? false);
731
+ if (isTyping && await this.rateLimited(userId, "chat.typing", this.config.typingRate)) return;
732
+ if (!await this.policy.canMessage(db, userId, target)) return;
733
+ const pair = [userId, target].sort().join("\0");
734
+ let chatId = this.pairChatIds.get(pair);
735
+ if (chatId === void 0) {
736
+ const chat = await this.service(db).getChatBetween(userId, target);
737
+ if (!chat) return;
738
+ chatId = chat.id;
739
+ this.pairChatIds.put(pair, chatId);
740
+ }
741
+ await this.hub.send(target, { type: "typing_status", user_id: userId, chat_id: chatId, is_typing: isTyping, timestamp: nowIso() });
742
+ }
743
+ // --------------------------------------------------------- delete / clear
744
+ async deleteMessage(userId, message, db) {
745
+ const messageId = intOrNull(message.message_id);
746
+ if (messageId === null) {
747
+ await this.reply(userId, errorFrame("Missing required fields: message_id", { code: "not_found" }));
748
+ return;
749
+ }
750
+ try {
751
+ await this.deleteFor(db, userId, messageId, pyBool(message.for_everyone ?? false));
752
+ } catch (err) {
753
+ if (err instanceof ChatActionError) await this.reply(userId, err.frame());
754
+ else await this.failed(userId, message, err, `Failed to delete message: ${errMsg(err)}`);
755
+ }
756
+ }
757
+ /** Delete for me / unsend for everyone. Sends `message_deleted`; throws `ChatActionError`. */
758
+ async deleteFor(db, userId, messageId, forEveryone) {
759
+ const service = this.service(db);
760
+ const row = await service.getMessage(messageId);
761
+ const chat = row ? await service.getChatForUser(row.chat_id, userId) : null;
762
+ if (!row || !chat) throw new ChatActionError("Message not found", "not_found", 404);
763
+ const payload = { type: "message_deleted", chat_id: row.chat_id, message_id: row.id, for_everyone: forEveryone };
764
+ if (!forEveryone) {
765
+ await service.hideMessage(userId, row.id);
766
+ await this.hub.send(userId, payload);
767
+ await this.hub.events.emit("chat.message_deleted", { ...payload, user_id: userId });
768
+ return payload;
769
+ }
770
+ const windowSeconds = Math.trunc(this.config.unsendWindowSeconds);
771
+ if (windowSeconds < 0) throw new ChatActionError("Deleting for everyone is disabled", "not_allowed", 403);
772
+ if (row.sender_id !== userId) throw new ChatActionError("Only the sender can delete a message for everyone", "not_allowed", 403);
773
+ if (row.call_id || row.message_type === SYSTEM_TYPE) {
774
+ throw new ChatActionError("This message can't be deleted for everyone", "not_allowed", 403);
775
+ }
776
+ if (row.message_type !== DELETED_TYPE) {
777
+ const [, urls] = await service.unsendMessage(row.id, windowSeconds);
778
+ if (urls.length) this.hub.spawn(deleteMedia(this.feature.storage, ...urls));
779
+ }
780
+ await this.hub.send(userId, payload);
781
+ await this.hub.send(otherOf(chat, userId), payload);
782
+ await this.hub.events.emit("chat.message_deleted", { ...payload, user_id: userId });
783
+ return payload;
784
+ }
785
+ async clearChat(userId, message, db) {
786
+ const { chat_id: chatId, candidate_user_id: candidate } = message;
787
+ try {
788
+ if (!pyBool(chatId) && !pyBool(candidate)) {
789
+ throw new ChatActionError("Missing required fields: chat_id or candidate_user_id", "not_found", 404);
790
+ }
791
+ await this.clearFor(db, userId, chatId, candidate);
792
+ } catch (err) {
793
+ if (err instanceof ChatActionError) await this.reply(userId, err.frame());
794
+ else await this.failed(userId, message, err, `Failed to clear chat: ${errMsg(err)}`);
795
+ }
796
+ }
797
+ async clearFor(db, userId, chatId, candidate) {
798
+ const service = this.service(db);
799
+ const chat = await this.resolveChat(service, userId, chatId, candidate);
800
+ if (!chat) throw new ChatActionError("Chat not found", "not_found", 404);
801
+ const clearedAt = await service.clearChat(userId, chat.id);
802
+ const payload = { type: "chat_cleared", chat_id: chat.id, cleared_at: iso(clearedAt) };
803
+ await this.hub.send(userId, payload);
804
+ return payload;
805
+ }
806
+ // -------------------------------------------------------------- connect
807
+ /** Flush delivery receipts for messages that arrived while the user was offline. */
808
+ async onConnect(userId, _ws) {
809
+ const rows = await this.service(this.hub.db).markDeliveredForRecipient(userId);
810
+ const grouped = /* @__PURE__ */ new Map();
811
+ for (const [chatId, senderId, messageId] of rows) {
812
+ const key = `${chatId}\0${senderId}`;
813
+ if (!grouped.has(key)) grouped.set(key, { chatId, senderId, ids: [] });
814
+ grouped.get(key).ids.push(messageId);
815
+ }
816
+ for (const { chatId, senderId, ids } of grouped.values()) {
817
+ await this.hub.send(senderId, {
818
+ type: "delivery_status",
819
+ chat_id: chatId,
820
+ message_ids: ids.sort((a, b) => a - b),
821
+ status: "delivered",
822
+ timestamp: nowIso()
823
+ });
824
+ }
825
+ }
826
+ };
827
+
828
+ // src/chat/policy.ts
829
+ var AllowAllChatPolicy = class {
830
+ refusalMessage = "Cannot send message. User is blocked.";
831
+ async canMessage() {
832
+ return true;
833
+ }
834
+ async blockStatus() {
835
+ return {};
836
+ }
837
+ async extraConversationInfo() {
838
+ return { chat_consent_given: true };
839
+ }
840
+ };
841
+
842
+ // src/chat/router.ts
843
+ function bodyObject(ctx) {
844
+ const b = ctx.body;
845
+ if (b === null || typeof b !== "object" || Array.isArray(b)) {
846
+ throw new HttpError(422, [{ type: "missing", loc: ["body"], msg: "Field required", input: null }]);
847
+ }
848
+ return b;
849
+ }
850
+ function intParam(where, name, raw, def, min, max) {
851
+ if (raw === void 0) {
852
+ if (def === void 0) throw validationError(where, name, "missing", "Field required");
853
+ return def;
854
+ }
855
+ if (!/^\s*[+-]?\d+\s*$/.test(raw)) {
856
+ throw validationError(where, name, "int_parsing", "Input should be a valid integer, unable to parse string as an integer", raw);
857
+ }
858
+ const n = Number(raw);
859
+ if (min !== void 0 && n < min) {
860
+ throw validationError(where, name, "greater_than_equal", `Input should be greater than or equal to ${min}`, raw);
861
+ }
862
+ if (max !== void 0 && n > max) {
863
+ throw validationError(where, name, "less_than_equal", `Input should be less than or equal to ${max}`, raw);
864
+ }
865
+ return n;
866
+ }
867
+ function buildChatRoutes(feature) {
868
+ const hub = feature.hub;
869
+ const svc = () => feature.handlers.service(hub.db);
870
+ const ensureSelf = (pathUserId, authUserId) => {
871
+ if (pathUserId !== authUserId) throw new HttpError(403, "Not allowed");
872
+ };
873
+ const actionError = (exc) => {
874
+ const { type: _type, ...body } = exc.frame();
875
+ const headers = "retry_after" in exc.extra ? { "Retry-After": String(exc.extra.retry_after) } : void 0;
876
+ return { status: exc.status, headers, body: { detail: body, ...body } };
877
+ };
878
+ const routes = [
879
+ {
880
+ method: "POST",
881
+ path: "/chats/start",
882
+ async handler(ctx) {
883
+ const candidate = firstQuery(ctx.query, "candidate_id");
884
+ if (candidate === void 0) throw validationError("query", "candidate_id", "missing", "Field required");
885
+ const userId = ctx.userId;
886
+ if (candidate === userId) throw new HttpError(400, "Cannot start a chat with yourself");
887
+ const chat = await svc().getOrCreateChat(userId, candidate);
888
+ return ok({
889
+ chat_id: chat.id,
890
+ user1_id: chat.user1_id,
891
+ user2_id: chat.user2_id,
892
+ created_at: iso(chat.created_at),
893
+ updated_at: iso(chat.updated_at)
894
+ });
895
+ }
896
+ }
897
+ ];
898
+ if (feature.devices && !("devices" in hub.features)) {
899
+ const devices = feature.devices;
900
+ routes.push(
901
+ { method: "POST", path: "/chats/expo/push/token", handler: async (ctx) => ok(await devices.register(ctx.userId, bodyObject(ctx))) },
902
+ { method: "POST", path: "/chats/expo/push/token/remove", handler: async (ctx) => ok(await devices.remove(ctx.userId, bodyObject(ctx))) }
903
+ );
904
+ }
905
+ routes.push(
906
+ {
907
+ // Same rules and events as WS send_message (policy, limits, push); returns the sender's MessageDto.
908
+ method: "POST",
909
+ path: "/chats/send",
910
+ async handler(ctx) {
911
+ const payload = bodyObject(ctx);
912
+ const recipient = payload.candidate_user_id;
913
+ const content = payload.content;
914
+ if (!recipient || !content) throw new HttpError(422, "Missing required fields: candidate_user_id, content");
915
+ try {
916
+ return ok(await feature.handlers.sendText(hub.db, ctx.userId, String(recipient), content, { replyToId: payload.reply_to_id }));
917
+ } catch (err) {
918
+ if (err instanceof ChatActionError) return actionError(err);
919
+ throw err;
920
+ }
921
+ }
922
+ },
923
+ {
924
+ // {chat_id?} or {candidate_user_id?} (chat_id wins)
925
+ method: "POST",
926
+ path: "/chats/read",
927
+ async handler(ctx) {
928
+ const payload = bodyObject(ctx);
929
+ const service = svc();
930
+ const chat = await feature.handlers.resolveChat(service, ctx.userId, payload.chat_id, payload.candidate_user_id);
931
+ if (!chat) throw new HttpError(404, "Chat not found");
932
+ await service.markMessagesAsRead(chat.id, ctx.userId);
933
+ await feature.handlers.notifyRead(chat.id, ctx.userId, otherOf(chat, ctx.userId));
934
+ return ok({ message: "Messages marked as read" });
935
+ }
936
+ },
937
+ {
938
+ method: "GET",
939
+ path: "/chats/:userId",
940
+ async handler(ctx) {
941
+ const userId = ctx.params.userId;
942
+ ensureSelf(userId, ctx.userId);
943
+ const convs = await svc().getConversations(userId);
944
+ const profiles = await hub.profiles(convs.map((c) => c.other_user_id));
945
+ return ok(
946
+ convs.map((conv) => {
947
+ const other = conv.other_user_id;
948
+ const profile = profiles[other];
949
+ return {
950
+ id: conv.chat_id,
951
+ other_user: { id: other, username: profile?.name ? profile.name : other, avatar: profile ? profile.avatar ?? null : null },
952
+ last_message: conv.last_message,
953
+ unread_count: conv.unread_count,
954
+ updated_at: conv.updated_at
955
+ };
956
+ })
957
+ );
958
+ }
959
+ },
960
+ {
961
+ method: "GET",
962
+ path: "/chats/:chatId/messages",
963
+ async handler(ctx) {
964
+ const chatId = intParam("path", "chatId", ctx.params.chatId);
965
+ const limit = intParam("query", "limit", firstQuery(ctx.query, "limit"), 50, 1, 100);
966
+ const offset = intParam("query", "offset", firstQuery(ctx.query, "offset"), 0, 0);
967
+ const userId = ctx.userId;
968
+ const service = svc();
969
+ if (!await service.getChatForUser(chatId, userId)) throw new HttpError(404, "Chat not found");
970
+ const rows = await service.getMessagesPage(chatId, limit, offset, userId);
971
+ const previews = await service.getReplyPreviews(rows.map((m) => m.reply_to_id));
972
+ return ok(rows.map((m) => messageDto(m, userId, m.reply_to_id ? previews.get(m.reply_to_id) : null)));
973
+ }
974
+ },
975
+ {
976
+ method: "POST",
977
+ path: "/chats/:chatId/read",
978
+ async handler(ctx) {
979
+ const chatId = intParam("path", "chatId", ctx.params.chatId);
980
+ const userId = ctx.userId;
981
+ const service = svc();
982
+ const chat = await service.getChatForUser(chatId, userId);
983
+ if (!chat) throw new HttpError(404, "Chat not found");
984
+ await service.markMessagesAsRead(chatId, userId);
985
+ await feature.handlers.notifyRead(chatId, userId, otherOf(chat, userId));
986
+ return ok({ message: "Messages marked as read" });
987
+ }
988
+ },
989
+ {
990
+ method: "GET",
991
+ path: "/chats/:userId/unread-count",
992
+ async handler(ctx) {
993
+ ensureSelf(ctx.params.userId, ctx.userId);
994
+ return ok({ unread_count: await svc().getUnreadCount(ctx.params.userId) });
995
+ }
996
+ },
997
+ {
998
+ method: "GET",
999
+ path: "/chats/:userId/online-status",
1000
+ async handler(ctx) {
1001
+ const userId = ctx.params.userId;
1002
+ if (userId !== ctx.userId && !await svc().getChatBetween(ctx.userId, userId)) throw new HttpError(403, "Not allowed");
1003
+ return ok({ user_id: userId, is_online: await hub.connections.isOnline(userId) });
1004
+ }
1005
+ }
1006
+ );
1007
+ return routes;
1008
+ }
1009
+
1010
+ // src/chat/feature.ts
1011
+ var defaultChatConfig = () => ({
1012
+ maxUploadBytes: 10 * 1024 * 1024,
1013
+ defaultPageSize: 10,
1014
+ maxPageSize: 100,
1015
+ pushPreviewChars: 120,
1016
+ pushChannel: "messages",
1017
+ maxTextLength: 4e3,
1018
+ messageRate: new RateLimit(20, 10),
1019
+ uploadRate: new RateLimit(5, 60),
1020
+ typingRate: new RateLimit(5, 5),
1021
+ unsendWindowSeconds: 3600,
1022
+ systemSenderId: null,
1023
+ systemPushTitle: null
1024
+ });
1025
+ var ChatFeature = class {
1026
+ name = "chat";
1027
+ policy;
1028
+ storage;
1029
+ devices;
1030
+ config;
1031
+ handlers;
1032
+ _hub = null;
1033
+ constructor(opts = {}) {
1034
+ this.policy = opts.policy ?? new AllowAllChatPolicy();
1035
+ this.storage = opts.storage ?? new LocalDiskStorage();
1036
+ this.devices = opts.devices;
1037
+ this.config = { ...defaultChatConfig(), ...opts.config ?? {} };
1038
+ }
1039
+ get hub() {
1040
+ if (!this._hub) throw new Error("ChatFeature is not installed; call hub.install(feature) first");
1041
+ return this._hub;
1042
+ }
1043
+ install(hub) {
1044
+ hub.registerFeature(this.name, this);
1045
+ this._hub = hub;
1046
+ this.handlers = new ChatHandlers(this);
1047
+ this.handlers.register();
1048
+ hub.onConnect((uid, ws) => this.handlers.onConnect(uid, ws));
1049
+ hub.connections.setPartnersLookup((uid) => this.partnerIds(uid));
1050
+ hub.includeRoutes(buildChatRoutes(this));
1051
+ return this;
1052
+ }
1053
+ /** Conversation partners (receivers of this user's `user_status`). */
1054
+ async partnerIds(userId) {
1055
+ return this.handlers.service(this.hub.db).partnerIds(userId);
1056
+ }
1057
+ // ------------------------------------------------------- server-side API
1058
+ /**
1059
+ * Send a text message as `senderId` from trusted server code (bots, admin tools, tests). Same storage,
1060
+ * events and push as WS `send_message`; no rate limit. The policy is only checked with `enforcePolicy`.
1061
+ * Returns the MessageDto as the sender sees it; throws `ChatActionError` (self-message, too long, policy).
1062
+ */
1063
+ async sendMessageAs(senderId, toUserId, content, opts = {}) {
1064
+ return this.handlers.sendText(this.hub.db, String(senderId), String(toUserId), content, {
1065
+ replyToId: opts.replyToId,
1066
+ checkPolicy: opts.enforcePolicy ?? false,
1067
+ rateLimit: false
1068
+ });
1069
+ }
1070
+ /**
1071
+ * A `message_type: "system"` message shown to `toUserId` only (e.g. "You matched with Asha"). It goes into
1072
+ * the chat between `toUserId` and `aboutUserId` (created if needed), stored with `sender_id = aboutUserId`
1073
+ * and hidden for `aboutUserId`. Without `aboutUserId`, `config.systemSenderId` is the chat partner
1074
+ * (throws if neither is set). `data` is added to the live frame (`message.data`) and to the push data; it is
1075
+ * not stored. Returns the MessageDto as `toUserId` sees it.
1076
+ */
1077
+ async sendSystemMessage(toUserId, content, opts = {}) {
1078
+ const to = String(toUserId);
1079
+ const about = opts.aboutUserId;
1080
+ const hasAbout = about !== null && about !== void 0 && about !== "";
1081
+ const sender = hasAbout ? String(about) : this.config.systemSenderId;
1082
+ if (!sender) throw new Error("sendSystemMessage needs aboutUserId (or ChatConfig.systemSenderId)");
1083
+ if (sender === to) throw new Error("aboutUserId must differ from toUserId");
1084
+ const text = String(content);
1085
+ const hub = this.hub;
1086
+ const service = new ChatService(hub.db, hub.dialect);
1087
+ const chat = await service.getOrCreateChat(to, sender);
1088
+ const row = await service.sendMessage(chat.id, sender, text, SYSTEM_TYPE);
1089
+ if (hasAbout) await service.hideMessage(sender, row.id);
1090
+ const dto = messageDto(row, to, null, { is_read: false, is_delivered: true });
1091
+ if (opts.data && Object.keys(opts.data).length) dto.data = { ...opts.data };
1092
+ const delivered = await hub.send(to, { type: "new_message", message: dto });
1093
+ if (delivered) await service.markDelivered([row.id]);
1094
+ if (opts.push ?? true) {
1095
+ let title = this.config.systemPushTitle;
1096
+ if (title === null) title = hasAbout ? await hub.displayName(sender, "Notification") : "Notification";
1097
+ await this.handlers.notifyOffline(
1098
+ to,
1099
+ sender,
1100
+ row.chat_id,
1101
+ Array.from(text).slice(0, this.config.pushPreviewChars).join(""),
1102
+ title,
1103
+ { ...opts.data ?? {}, system: true }
1104
+ );
1105
+ }
1106
+ dto.is_delivered = delivered;
1107
+ await hub.events.emit("chat.system_message_sent", { ...dto });
1108
+ return dto;
1109
+ }
1110
+ /**
1111
+ * Account deletion: every chat `userId` is in, all its messages (the partner's view goes too: a 1:1 chat has
1112
+ * one copy), hides / clears, and the stored media (through `MediaStorage.delete`). Returns counts.
1113
+ */
1114
+ async deleteUserData(userId) {
1115
+ const uid = String(userId);
1116
+ const [counts, urls] = await new ChatService(this.hub.db, this.hub.dialect).deleteUserData(uid);
1117
+ const media = await deleteMedia(this.storage, ...urls);
1118
+ this.handlers.forgetUser(uid);
1119
+ return { ...counts, media };
1120
+ }
1121
+ };
1122
+
1123
+ // src/chat/models.ts
1124
+ function chatDdl(dialect) {
1125
+ const t = ddlTypes(dialect);
1126
+ return [
1127
+ `CREATE TABLE IF NOT EXISTS chats (
1128
+ id ${t.pk},
1129
+ user1_id VARCHAR(50) NOT NULL,
1130
+ user2_id VARCHAR(50) NOT NULL,
1131
+ created_at ${t.tsDefaultNow},
1132
+ updated_at ${t.ts},
1133
+ CONSTRAINT check_user_order CHECK (user1_id < user2_id),
1134
+ CONSTRAINT unique_chat_users UNIQUE (user1_id, user2_id)
1135
+ )`,
1136
+ `CREATE TABLE IF NOT EXISTS messages (
1137
+ id ${t.pk},
1138
+ chat_id INTEGER NOT NULL REFERENCES chats (id) ON DELETE CASCADE,
1139
+ sender_id VARCHAR(50) NOT NULL,
1140
+ content TEXT,
1141
+ message_type VARCHAR(20),
1142
+ file_url VARCHAR(500),
1143
+ file_name VARCHAR(255),
1144
+ file_size ${t.big},
1145
+ file_type VARCHAR(50),
1146
+ thumbnail_url VARCHAR(500),
1147
+ duration INTEGER,
1148
+ is_read ${t.bool},
1149
+ is_delivered ${t.bool},
1150
+ is_call ${t.bool},
1151
+ call_id VARCHAR(100),
1152
+ reply_to_id INTEGER REFERENCES messages (id) ON DELETE SET NULL,
1153
+ created_at ${t.tsDefaultNow},
1154
+ updated_at ${t.ts},
1155
+ UNIQUE (call_id)
1156
+ )`,
1157
+ `CREATE TABLE IF NOT EXISTS comms_message_hidden (
1158
+ id ${t.pk},
1159
+ user_id VARCHAR(50) NOT NULL,
1160
+ message_id INTEGER NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
1161
+ created_at ${t.tsDefaultNow},
1162
+ CONSTRAINT uq_comms_message_hidden UNIQUE (user_id, message_id)
1163
+ )`,
1164
+ `CREATE TABLE IF NOT EXISTS comms_chat_cleared (
1165
+ id ${t.pk},
1166
+ user_id VARCHAR(50) NOT NULL,
1167
+ chat_id INTEGER NOT NULL REFERENCES chats (id) ON DELETE CASCADE,
1168
+ cleared_at ${t.ts} NOT NULL,
1169
+ up_to_message_id INTEGER NOT NULL,
1170
+ CONSTRAINT uq_comms_chat_cleared UNIQUE (user_id, chat_id)
1171
+ )`,
1172
+ "CREATE INDEX IF NOT EXISTS ix_chats_id ON chats (id)",
1173
+ "CREATE INDEX IF NOT EXISTS ix_messages_id ON messages (id)",
1174
+ "CREATE INDEX IF NOT EXISTS ix_comms_message_hidden_user_id ON comms_message_hidden (user_id)",
1175
+ "CREATE INDEX IF NOT EXISTS ix_comms_message_hidden_message_id ON comms_message_hidden (message_id)",
1176
+ "CREATE INDEX IF NOT EXISTS ix_comms_chat_cleared_user_id ON comms_chat_cleared (user_id)"
1177
+ ];
1178
+ }
1179
+
1180
+ export {
1181
+ messageDto,
1182
+ DELETED_TYPE,
1183
+ SYSTEM_TYPE,
1184
+ RESERVED_TYPES,
1185
+ ChatActionError,
1186
+ ChatService,
1187
+ AllowAllChatPolicy,
1188
+ defaultChatConfig,
1189
+ ChatFeature,
1190
+ chatDdl
1191
+ };
1192
+ //# sourceMappingURL=chunk-U4Z5INIW.js.map