@snakechat/commskit-node 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +148 -6
@@ -0,0 +1,586 @@
1
+ import {
2
+ HttpError,
3
+ dbTimestamp,
4
+ detectDialect,
5
+ firstQuery,
6
+ ok,
7
+ validationError
8
+ } from "./chunk-GR4PMGCS.js";
9
+ import {
10
+ iso,
11
+ pyBool
12
+ } from "./chunk-KLKRK6OG.js";
13
+ import {
14
+ logger
15
+ } from "./chunk-5BJ2SNM5.js";
16
+
17
+ // src/moderation/service.ts
18
+ var REPORT_CATEGORIES = ["spam", "harassment", "inappropriate", "fake_profile", "scam", "underage", "other"];
19
+ var REPORT_STATUSES = ["open", "resolved", "dismissed"];
20
+ var MAX_NOTE_CHARS = 4e3;
21
+ var MAX_REASON_CHARS = 2e3;
22
+ var MAX_CATEGORY_CHARS = 64;
23
+ function reportDict(r) {
24
+ return {
25
+ report_id: Number(r.id),
26
+ reporter_id: r.reporter_id ?? null,
27
+ reported_id: r.reported_id ?? null,
28
+ reason: r.reason ?? null,
29
+ category: r.category ?? null,
30
+ message_id: r.message_id === null || r.message_id === void 0 ? null : Number(r.message_id),
31
+ status: r.status,
32
+ created_at: iso(r.created_at),
33
+ resolved_at: iso(r.resolved_at),
34
+ resolved_by: r.resolved_by ?? null,
35
+ note: r.note ?? null
36
+ };
37
+ }
38
+ var isUniqueViolation = (err) => /unique|duplicate/i.test(String(err?.message ?? err)) || err?.code === "23505";
39
+ var ModerationService = class {
40
+ constructor(db, dialect) {
41
+ this.db = db;
42
+ this.dialect = dialect ?? detectDialect(db);
43
+ }
44
+ db;
45
+ dialect;
46
+ // --------------------------------------------------------------- blocks
47
+ /** True if a new block was stored (blocking twice is a no-op). */
48
+ async block(blockerId, blockedId) {
49
+ if (await this.hasBlocked(blockerId, blockedId)) return false;
50
+ try {
51
+ await this.db.insertInto("comms_blocks").values({ blocker_id: blockerId, blocked_id: blockedId, created_at: dbTimestamp(this.dialect) }).execute();
52
+ } catch (err) {
53
+ if (isUniqueViolation(err)) return false;
54
+ throw err;
55
+ }
56
+ return true;
57
+ }
58
+ async unblock(blockerId, blockedId) {
59
+ const r = await this.db.deleteFrom("comms_blocks").where("blocker_id", "=", blockerId).where("blocked_id", "=", blockedId).executeTakeFirst();
60
+ return Number(r.numDeletedRows) > 0;
61
+ }
62
+ async hasBlocked(blockerId, blockedId) {
63
+ const r = await this.db.selectFrom("comms_blocks").select("id").where("blocker_id", "=", blockerId).where("blocked_id", "=", blockedId).limit(1).executeTakeFirst();
64
+ return r !== void 0;
65
+ }
66
+ /** Either direction. */
67
+ async isBlockedBetween(userA, userB) {
68
+ const r = await this.db.selectFrom("comms_blocks").select("id").where(
69
+ (eb) => eb.or([
70
+ eb.and([eb("blocker_id", "=", userA), eb("blocked_id", "=", userB)]),
71
+ eb.and([eb("blocker_id", "=", userB), eb("blocked_id", "=", userA)])
72
+ ])
73
+ ).limit(1).executeTakeFirst();
74
+ return r !== void 0;
75
+ }
76
+ /** `{otherId: [isBlocked, blockedByOther]}` for ids with any block. */
77
+ async blockStatus(viewerId, otherIds) {
78
+ const ids = [...new Set(otherIds)].filter(Boolean);
79
+ if (!ids.length) return {};
80
+ const rows = await this.db.selectFrom("comms_blocks").select(["blocker_id", "blocked_id"]).where(
81
+ (eb) => eb.or([
82
+ eb.and([eb("blocker_id", "=", viewerId), eb("blocked_id", "in", ids)]),
83
+ eb.and([eb("blocked_id", "=", viewerId), eb("blocker_id", "in", ids)])
84
+ ])
85
+ ).execute();
86
+ const status = {};
87
+ for (const { blocker_id: blocker, blocked_id: blocked } of rows) {
88
+ if (blocker === viewerId) {
89
+ const cur = status[blocked] ?? [false, false];
90
+ status[blocked] = [true, cur[1]];
91
+ } else {
92
+ const cur = status[blocker] ?? [false, false];
93
+ status[blocker] = [cur[0], true];
94
+ }
95
+ }
96
+ return status;
97
+ }
98
+ /** `[blockedId, blockedAt]`, newest first. */
99
+ async listBlocked(blockerId) {
100
+ const rows = await this.db.selectFrom("comms_blocks").select(["blocked_id", "created_at"]).where("blocker_id", "=", blockerId).orderBy("created_at", "desc").orderBy("id", "desc").execute();
101
+ return rows.map((r) => [r.blocked_id, r.created_at]);
102
+ }
103
+ // -------------------------------------------------------------- reports
104
+ async createReport(reporterId, reportedId, opts = {}) {
105
+ const reason = opts.reason === null || opts.reason === void 0 ? null : String(opts.reason).trim().slice(0, MAX_REASON_CHARS) || null;
106
+ const category = opts.category === null || opts.category === void 0 ? null : String(opts.category).trim().toLowerCase().slice(0, MAX_CATEGORY_CHARS) || null;
107
+ const messageId = opts.messageId ?? null;
108
+ const now = /* @__PURE__ */ new Date();
109
+ const r = await this.db.insertInto("comms_reports").values({ reporter_id: reporterId, reported_id: reportedId, reason, category, message_id: messageId, status: "open", created_at: dbTimestamp(this.dialect, now) }).returning("id").executeTakeFirstOrThrow();
110
+ return { id: Number(r.id), reporter_id: reporterId, reported_id: reportedId, reason, category, message_id: messageId, status: "open", created_at: now, blocked: false };
111
+ }
112
+ // --------------------------------------------------------- admin review
113
+ async getReport(reportId) {
114
+ const row = await this.db.selectFrom("comms_reports").selectAll().where("id", "=", reportId).executeTakeFirst();
115
+ return row ? reportDict(row) : null;
116
+ }
117
+ /** Newest first; `status` filters (open | resolved | dismissed). */
118
+ async listReports(status, limit = 50, offset = 0) {
119
+ let q = this.db.selectFrom("comms_reports").selectAll();
120
+ if (status) q = q.where("status", "=", status);
121
+ const rows = await q.orderBy("created_at", "desc").orderBy("id", "desc").limit(limit).offset(offset).execute();
122
+ return rows.map(reportDict);
123
+ }
124
+ /** Set a report to resolved / dismissed (or back to open). Null if it doesn't exist; throws Error on a bad status. */
125
+ async resolveReport(reportId, status, resolvedBy = null, note = null) {
126
+ if (!REPORT_STATUSES.includes(status)) throw new Error(`status must be one of: ${REPORT_STATUSES.join(", ")}`);
127
+ const row = await this.db.selectFrom("comms_reports").selectAll().where("id", "=", reportId).executeTakeFirst();
128
+ if (!row) return null;
129
+ const patch = { status };
130
+ if (status === "open") {
131
+ patch.resolved_at = null;
132
+ patch.resolved_by = null;
133
+ } else {
134
+ patch.resolved_at = dbTimestamp(this.dialect);
135
+ patch.resolved_by = resolvedBy === null || resolvedBy === void 0 ? null : String(resolvedBy).slice(0, 50) || null;
136
+ }
137
+ if (note !== null && note !== void 0) patch.note = String(note).trim().slice(0, MAX_NOTE_CHARS) || null;
138
+ await this.db.updateTable("comms_reports").set(patch).where("id", "=", reportId).execute();
139
+ return reportDict({ ...row, ...patch });
140
+ }
141
+ // ------------------------------------------------------------- deletion
142
+ /**
143
+ * Account deletion: drop every block involving `userId`; keep reports (moderation history) but replace the user's id
144
+ * with NULL as reporter and as reported user. Use a transaction handle (`trx`) for `db` to make it atomic.
145
+ */
146
+ async deleteUserData(userId) {
147
+ const blocks = await this.db.deleteFrom("comms_blocks").where((eb) => eb.or([eb("blocker_id", "=", userId), eb("blocked_id", "=", userId)])).executeTakeFirst();
148
+ const asReporter = await this.db.updateTable("comms_reports").set({ reporter_id: null }).where("reporter_id", "=", userId).executeTakeFirst();
149
+ const asReported = await this.db.updateTable("comms_reports").set({ reported_id: null }).where("reported_id", "=", userId).executeTakeFirst();
150
+ return { blocks: Number(blocks.numDeletedRows), reports_anonymized: Number(asReporter.numUpdatedRows) + Number(asReported.numUpdatedRows) };
151
+ }
152
+ };
153
+
154
+ // src/moderation/handlers.ts
155
+ var BLOCKED_TEXT = "User blocked successfully";
156
+ var BLOCKED_BY_TEXT = "You have been blocked by this user";
157
+ var UNBLOCKED_TEXT = "User unblocked successfully";
158
+ var UNBLOCKED_BY_TEXT = "You have been unblocked by this user";
159
+ var moderationError = (error, extra = {}) => ({
160
+ type: "error",
161
+ error,
162
+ ...extra,
163
+ context: "moderation"
164
+ });
165
+ var ModerationError = class extends Error {
166
+ };
167
+ function targetId(userId, value) {
168
+ if (value === null || value === void 0 || String(value).trim() === "") {
169
+ throw new ModerationError("Missing required fields: candidate_user_id");
170
+ }
171
+ const target = String(value).trim();
172
+ if (target === userId) throw new ModerationError("You cannot block or report yourself.");
173
+ return target;
174
+ }
175
+ function messageIdOf(value) {
176
+ if (value === null || value === void 0 || value === "") return null;
177
+ const s = String(value).trim();
178
+ if (!/^[+-]?\d+$/.test(s)) throw new ModerationError("message_id must be an integer");
179
+ return Number(s);
180
+ }
181
+ var ModerationActions = class {
182
+ constructor(feature) {
183
+ this.feature = feature;
184
+ }
185
+ feature;
186
+ get hub() {
187
+ return this.feature.hub;
188
+ }
189
+ async block(db, userId, target) {
190
+ const created = await new ModerationService(db, this.hub.dialect).block(userId, target);
191
+ await this.hub.send(userId, { type: "user_blocked", candidate_user_id: target, message: BLOCKED_TEXT });
192
+ if (this.feature.notifyBlockedUser) {
193
+ await this.hub.send(target, { type: "blocked_by_user", candidate_user_id: userId, message: BLOCKED_BY_TEXT });
194
+ }
195
+ await this.hub.events.emit("moderation.blocked", userId, target);
196
+ return created;
197
+ }
198
+ async unblock(db, userId, target) {
199
+ const removed = await new ModerationService(db, this.hub.dialect).unblock(userId, target);
200
+ await this.hub.send(userId, { type: "user_unblocked", candidate_user_id: target, message: UNBLOCKED_TEXT });
201
+ if (this.feature.notifyBlockedUser && removed) {
202
+ await this.hub.send(target, { type: "unblocked_by_user", candidate_user_id: userId, message: UNBLOCKED_BY_TEXT });
203
+ }
204
+ if (removed) await this.hub.events.emit("moderation.unblocked", userId, target);
205
+ return removed;
206
+ }
207
+ async report(db, userId, target, opts = {}) {
208
+ let info = await new ModerationService(db, this.hub.dialect).createReport(userId, target, {
209
+ reason: opts.reason === null || opts.reason === void 0 ? null : String(opts.reason),
210
+ category: opts.category === null || opts.category === void 0 ? null : String(opts.category),
211
+ messageId: messageIdOf(opts.messageId)
212
+ });
213
+ if (opts.block ?? true) {
214
+ await this.block(db, userId, target);
215
+ info = { ...info, blocked: true };
216
+ }
217
+ await this.hub.send(userId, { type: "report_received", report_id: info.id, candidate_user_id: target });
218
+ await this.hub.events.emit("moderation.reported", info);
219
+ const hook = this.feature.onReport;
220
+ if (hook) {
221
+ const reported = info;
222
+ this.hub.spawn(async () => {
223
+ try {
224
+ await hook(reported);
225
+ } catch (err) {
226
+ logger.error(`onReport hook failed for report ${reported.id}`, err);
227
+ }
228
+ });
229
+ }
230
+ return info;
231
+ }
232
+ };
233
+ var ModerationHandlers = class {
234
+ constructor(feature) {
235
+ this.feature = feature;
236
+ }
237
+ feature;
238
+ get hub() {
239
+ return this.feature.hub;
240
+ }
241
+ register() {
242
+ this.hub.on("report_and_block", this.guard((u, m, db) => this.reportAndBlock(u, m, db), "Failed to report user"));
243
+ this.hub.on("block", this.guard((u, m, db) => this.block(u, m, db), "Failed to block user"));
244
+ this.hub.on("unblock", this.guard((u, m, db) => this.unblock(u, m, db), "Failed to unblock user"));
245
+ }
246
+ guard(fn, label) {
247
+ return async (userId, message, db) => {
248
+ try {
249
+ await fn(userId, message, db);
250
+ } catch (err) {
251
+ if (err instanceof ModerationError) {
252
+ await this.hub.send(userId, moderationError(err.message));
253
+ return;
254
+ }
255
+ logger.error(`${message.type} handler failed`, err);
256
+ await this.hub.reportHandlerError(userId, message, err);
257
+ await this.hub.send(userId, moderationError(`${label}: ${err?.message ?? err}`));
258
+ }
259
+ };
260
+ }
261
+ async reportAndBlock(userId, message, db) {
262
+ const target = targetId(userId, message.candidate_user_id);
263
+ await this.feature.actions.report(db, userId, target, {
264
+ reason: message.reason,
265
+ category: message.category,
266
+ messageId: message.message_id,
267
+ block: message.block !== false
268
+ });
269
+ }
270
+ async block(userId, message, db) {
271
+ await this.feature.actions.block(db, userId, targetId(userId, message.candidate_user_id));
272
+ }
273
+ async unblock(userId, message, db) {
274
+ await this.feature.actions.unblock(db, userId, targetId(userId, message.candidate_user_id));
275
+ }
276
+ };
277
+
278
+ // src/moderation/policy.ts
279
+ var CHAT_REFUSAL = "Cannot send message. User is blocked.";
280
+ var CALL_REFUSAL = "Cannot call. User is blocked.";
281
+ var ModerationChatPolicy = class {
282
+ constructor(inner = null, blockedMessage = CHAT_REFUSAL) {
283
+ this.inner = inner;
284
+ this.blockedMessage = blockedMessage;
285
+ }
286
+ inner;
287
+ blockedMessage;
288
+ /**
289
+ * The chat feature reads `refusalMessage` right after `canMessage` returned false; `lastRefusal` remembers whose refusal
290
+ * it was (the block list or the inner policy). Both messages are constant per policy, so concurrent frames cannot mix
291
+ * up different texts except when the host policy's message differs from the block text.
292
+ */
293
+ lastRefusal = null;
294
+ get refusalMessage() {
295
+ return this.lastRefusal ?? this.inner?.refusalMessage ?? this.blockedMessage;
296
+ }
297
+ async canMessage(db, senderId, recipientId) {
298
+ if (await new ModerationService(db).isBlockedBetween(senderId, recipientId)) {
299
+ this.lastRefusal = this.blockedMessage;
300
+ return false;
301
+ }
302
+ if (this.inner && !await this.inner.canMessage(db, senderId, recipientId)) {
303
+ this.lastRefusal = this.inner.refusalMessage || this.blockedMessage;
304
+ return false;
305
+ }
306
+ this.lastRefusal = null;
307
+ return true;
308
+ }
309
+ /** Kit block list OR the inner policy's (e.g. a legacy host block table). */
310
+ async blockStatus(db, viewerId, otherIds) {
311
+ const merged = { ...await new ModerationService(db).blockStatus(viewerId, otherIds) };
312
+ if (this.inner) {
313
+ for (const [other, [blocked, byOther]] of Object.entries(await this.inner.blockStatus(db, viewerId, otherIds))) {
314
+ const mine = merged[other] ?? [false, false];
315
+ merged[other] = [mine[0] || Boolean(blocked), mine[1] || Boolean(byOther)];
316
+ }
317
+ }
318
+ return merged;
319
+ }
320
+ async extraConversationInfo(db, viewerId, otherId) {
321
+ if (this.inner) return this.inner.extraConversationInfo(db, viewerId, otherId);
322
+ return { chat_consent_given: true };
323
+ }
324
+ };
325
+ var ModerationCallPolicy = class {
326
+ constructor(inner = null, blockedMessage = CALL_REFUSAL) {
327
+ this.inner = inner;
328
+ this.blockedMessage = blockedMessage;
329
+ }
330
+ inner;
331
+ blockedMessage;
332
+ async authorize(db, callerId, receiverId, message) {
333
+ if (await new ModerationService(db).isBlockedBetween(callerId, receiverId)) {
334
+ return { allowed: false, error: { error: this.blockedMessage, reason: "blocked" } };
335
+ }
336
+ if (this.inner) return this.inner.authorize(db, callerId, receiverId, message);
337
+ return { allowed: true };
338
+ }
339
+ };
340
+
341
+ // src/moderation/router.ts
342
+ function bodyObject(ctx) {
343
+ const b = ctx.body;
344
+ if (b === null || typeof b !== "object" || Array.isArray(b)) {
345
+ throw new HttpError(422, [{ type: "missing", loc: ["body"], msg: "Field required", input: null }]);
346
+ }
347
+ return b;
348
+ }
349
+ function intParam(where, name, raw, def, min, max) {
350
+ if (raw === void 0) {
351
+ if (def === void 0) throw validationError(where, name, "missing", "Field required");
352
+ return def;
353
+ }
354
+ if (!/^\s*[+-]?\d+\s*$/.test(raw)) {
355
+ throw validationError(where, name, "int_parsing", "Input should be a valid integer, unable to parse string as an integer", raw);
356
+ }
357
+ const n = Number(raw);
358
+ if (min !== void 0 && n < min) throw validationError(where, name, "greater_than_equal", `Input should be greater than or equal to ${min}`, raw);
359
+ if (max !== void 0 && n > max) throw validationError(where, name, "less_than_equal", `Input should be less than or equal to ${max}`, raw);
360
+ return n;
361
+ }
362
+ function buildModerationRoutes(feature) {
363
+ const hub = feature.hub;
364
+ const actions = feature.actions;
365
+ const targetOr422 = (userId, value) => {
366
+ try {
367
+ return targetId(userId, value);
368
+ } catch (err) {
369
+ if (err instanceof ModerationError) throw new HttpError(422, err.message);
370
+ throw err;
371
+ }
372
+ };
373
+ const routes = [
374
+ {
375
+ method: "GET",
376
+ path: "/moderation/blocks",
377
+ handler: async (ctx) => {
378
+ const rows = await new ModerationService(hub.db, hub.dialect).listBlocked(ctx.userId);
379
+ const profiles = await hub.profiles(rows.map((r) => r[0]));
380
+ return ok(
381
+ rows.map(([blockedId, blockedAt]) => {
382
+ const p = profiles[blockedId];
383
+ return { user_id: blockedId, username: p?.name ? p.name : blockedId, avatar: p ? p.avatar ?? null : null, blocked_at: iso(blockedAt) };
384
+ })
385
+ );
386
+ }
387
+ },
388
+ {
389
+ method: "POST",
390
+ path: "/moderation/blocks",
391
+ handler: async (ctx) => {
392
+ const target = targetOr422(ctx.userId, bodyObject(ctx).user_id);
393
+ await actions.block(hub.db, ctx.userId, target);
394
+ return ok({ status: true });
395
+ }
396
+ },
397
+ {
398
+ method: "DELETE",
399
+ path: "/moderation/blocks/:blockedId",
400
+ handler: async (ctx) => {
401
+ await actions.unblock(hub.db, ctx.userId, targetOr422(ctx.userId, ctx.params.blockedId));
402
+ return ok({ status: true });
403
+ }
404
+ },
405
+ {
406
+ method: "POST",
407
+ path: "/moderation/reports",
408
+ handler: async (ctx) => {
409
+ const p = bodyObject(ctx);
410
+ const target = targetOr422(ctx.userId, p.user_id);
411
+ if (p.reason === null || p.reason === void 0 || !String(p.reason).trim()) throw new HttpError(422, "reason is required");
412
+ try {
413
+ const info = await actions.report(hub.db, ctx.userId, target, {
414
+ reason: p.reason,
415
+ category: p.category,
416
+ messageId: p.message_id,
417
+ block: pyBool(p.block ?? false)
418
+ });
419
+ return ok({ report_id: info.id });
420
+ } catch (err) {
421
+ if (err instanceof ModerationError) throw new HttpError(422, err.message);
422
+ throw err;
423
+ }
424
+ }
425
+ }
426
+ ];
427
+ const admin = feature.adminDependency;
428
+ if (admin) {
429
+ routes.push(
430
+ {
431
+ method: "GET",
432
+ path: "/moderation/admin/reports",
433
+ auth: false,
434
+ handler: async (ctx) => {
435
+ await admin(ctx);
436
+ const status = firstQuery(ctx.query, "status");
437
+ if (status !== void 0 && !REPORT_STATUSES.includes(status)) {
438
+ throw new HttpError(422, `status must be one of: ${REPORT_STATUSES.join(", ")}`);
439
+ }
440
+ const limit = intParam("query", "limit", firstQuery(ctx.query, "limit"), 50, 1, 200);
441
+ const offset = intParam("query", "offset", firstQuery(ctx.query, "offset"), 0, 0);
442
+ return ok(await feature.listReports(status ?? null, { limit, offset }));
443
+ }
444
+ },
445
+ {
446
+ method: "POST",
447
+ path: "/moderation/admin/reports/:reportId",
448
+ auth: false,
449
+ handler: async (ctx) => {
450
+ const adminId = await admin(ctx);
451
+ const reportId = intParam("path", "report_id", ctx.params.reportId);
452
+ const p = bodyObject(ctx);
453
+ if (!["resolved", "dismissed", "open"].includes(p.status)) throw new HttpError(422, "status must be resolved or dismissed");
454
+ const report = await feature.resolveReport(reportId, p.status, {
455
+ resolvedBy: adminId === null || adminId === void 0 ? null : String(adminId),
456
+ note: p.note === null || p.note === void 0 ? null : String(p.note)
457
+ });
458
+ if (!report) throw new HttpError(404, "Report not found");
459
+ return ok(report);
460
+ }
461
+ },
462
+ {
463
+ method: "GET",
464
+ path: "/moderation/admin/reports/:reportId/context",
465
+ auth: false,
466
+ handler: async (ctx) => {
467
+ await admin(ctx);
468
+ const reportId = intParam("path", "report_id", ctx.params.reportId);
469
+ const before = intParam("query", "before", firstQuery(ctx.query, "before"), 20, 0, 200);
470
+ const after = intParam("query", "after", firstQuery(ctx.query, "after"), 20, 0, 200);
471
+ const context = await feature.reportContext(reportId, { before, after });
472
+ if (!context) throw new HttpError(404, "Report not found");
473
+ return ok(context);
474
+ }
475
+ }
476
+ );
477
+ }
478
+ return routes;
479
+ }
480
+
481
+ // src/moderation/feature.ts
482
+ var ModerationFeature = class {
483
+ name = "moderation";
484
+ onReport;
485
+ notifyBlockedUser;
486
+ adminDependency;
487
+ contextProvider;
488
+ actions;
489
+ handlers;
490
+ /** Set by linkModeration. */
491
+ link = null;
492
+ _hub = null;
493
+ constructor(opts = {}) {
494
+ this.onReport = opts.onReport;
495
+ this.notifyBlockedUser = opts.notifyBlockedUser ?? true;
496
+ this.adminDependency = opts.adminDependency;
497
+ this.contextProvider = opts.contextProvider;
498
+ }
499
+ get hub() {
500
+ if (!this._hub) throw new Error("ModerationFeature is not installed; call hub.install(feature) first");
501
+ return this._hub;
502
+ }
503
+ install(hub) {
504
+ hub.registerFeature(this.name, this);
505
+ this._hub = hub;
506
+ this.actions = new ModerationActions(this);
507
+ this.handlers = new ModerationHandlers(this);
508
+ this.handlers.register();
509
+ hub.includeRoutes(buildModerationRoutes(this));
510
+ return this;
511
+ }
512
+ service(db) {
513
+ return new ModerationService(db ?? this.hub.db, this.hub.dialect);
514
+ }
515
+ // ------------------------------------------------------------ policies
516
+ /** ChatPolicy: refuses a blocked pair, otherwise asks `inner` (allow-all if null). */
517
+ chatPolicy(inner = null) {
518
+ return new ModerationChatPolicy(inner);
519
+ }
520
+ /** CallPolicy: denies a blocked pair (`reason: "blocked"`), otherwise `inner`. */
521
+ callPolicy(inner = null) {
522
+ return new ModerationCallPolicy(inner);
523
+ }
524
+ // -------------------------------------------------------------- queries
525
+ /** `{otherId: [isBlocked, blockedByOther]}` for `res_users` / `res_messages.info`. */
526
+ blockStatus(viewerId, otherIds, db) {
527
+ return this.service(db).blockStatus(viewerId, otherIds);
528
+ }
529
+ isBlockedBetween(userA, userB, db) {
530
+ return this.service(db).isBlockedBetween(userA, userB);
531
+ }
532
+ // --------------------------------------------------------- admin review
533
+ listReports(status = null, opts = {}) {
534
+ return this.service().listReports(status, opts.limit ?? 50, opts.offset ?? 0);
535
+ }
536
+ /** `status`: resolved | dismissed (| open). Null if the report doesn't exist. */
537
+ async resolveReport(reportId, status, opts = {}) {
538
+ const report = await this.service().resolveReport(reportId, status, opts.resolvedBy ?? null, opts.note ?? null);
539
+ if (report) await this.hub.events.emit("moderation.report_resolved", report);
540
+ return report;
541
+ }
542
+ /**
543
+ * `{report, messages}`: messages around the reported message (or the latest between the pair), newest first, from
544
+ * `contextProvider` (empty without one, e.g. call-only apps).
545
+ */
546
+ async reportContext(reportId, opts = {}) {
547
+ const report = await this.service().getReport(reportId);
548
+ if (!report) return null;
549
+ let messages = [];
550
+ if (this.contextProvider && report.reporter_id && report.reported_id) {
551
+ messages = [...await this.contextProvider(report.reporter_id, report.reported_id, report.message_id, Math.max(0, opts.before ?? 20), Math.max(0, opts.after ?? 20))];
552
+ }
553
+ return { report, messages };
554
+ }
555
+ // ------------------------------------------------------------- deletion
556
+ /**
557
+ * Account deletion: blocks involving the user are deleted; reports are kept for the moderation history with the user's
558
+ * id set to NULL (as reporter and as reported user).
559
+ */
560
+ async deleteUserData(userId) {
561
+ const hub = this.hub;
562
+ return hub.db.transaction().execute((trx) => new ModerationService(trx, hub.dialect).deleteUserData(String(userId)));
563
+ }
564
+ };
565
+
566
+ export {
567
+ REPORT_CATEGORIES,
568
+ REPORT_STATUSES,
569
+ reportDict,
570
+ ModerationService,
571
+ BLOCKED_TEXT,
572
+ BLOCKED_BY_TEXT,
573
+ UNBLOCKED_TEXT,
574
+ UNBLOCKED_BY_TEXT,
575
+ moderationError,
576
+ ModerationError,
577
+ targetId,
578
+ ModerationActions,
579
+ ModerationHandlers,
580
+ CHAT_REFUSAL,
581
+ CALL_REFUSAL,
582
+ ModerationChatPolicy,
583
+ ModerationCallPolicy,
584
+ ModerationFeature
585
+ };
586
+ //# sourceMappingURL=chunk-6I27CI32.js.map