@openora/core 0.4.1-canary.111 → 0.4.1-canary.113

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 (138) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/plugin.d.ts +9 -0
  3. package/dist/admin-console/plugin.d.ts.map +1 -1
  4. package/dist/analytics/plugin.d.ts +9 -0
  5. package/dist/analytics/plugin.d.ts.map +1 -1
  6. package/dist/audit/plugin.d.ts +9 -0
  7. package/dist/audit/plugin.d.ts.map +1 -1
  8. package/dist/casino/gaming/plugin.d.ts +9 -0
  9. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  10. package/dist/casino/lobby/plugin.d.ts +9 -0
  11. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  12. package/dist/cms/plugin.d.ts +9 -0
  13. package/dist/cms/plugin.d.ts.map +1 -1
  14. package/dist/compliance/plugin.d.ts +9 -0
  15. package/dist/compliance/plugin.d.ts.map +1 -1
  16. package/dist/contracts/schemas/__tests__/platform-config.test.d.ts +2 -0
  17. package/dist/contracts/schemas/__tests__/platform-config.test.d.ts.map +1 -0
  18. package/dist/contracts/schemas/__tests__/platform-config.test.js +16 -0
  19. package/dist/contracts/schemas/__tests__/platform-config.test.js.map +1 -0
  20. package/dist/contracts/schemas/chat-attachment.d.ts +20 -0
  21. package/dist/contracts/schemas/chat-attachment.d.ts.map +1 -0
  22. package/dist/contracts/schemas/chat-attachment.js +14 -0
  23. package/dist/contracts/schemas/chat-attachment.js.map +1 -0
  24. package/dist/contracts/schemas/index.d.ts +1 -0
  25. package/dist/contracts/schemas/index.d.ts.map +1 -1
  26. package/dist/contracts/schemas/index.js +1 -0
  27. package/dist/contracts/schemas/index.js.map +1 -1
  28. package/dist/contracts/schemas/platform-config.d.ts +29 -0
  29. package/dist/contracts/schemas/platform-config.d.ts.map +1 -1
  30. package/dist/contracts/schemas/platform-config.js +45 -0
  31. package/dist/contracts/schemas/platform-config.js.map +1 -1
  32. package/dist/engagement/chat/__tests__/chat.service.int.test.js +255 -14
  33. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  34. package/dist/engagement/chat/__tests__/contract.test.d.ts +2 -0
  35. package/dist/engagement/chat/__tests__/contract.test.d.ts.map +1 -0
  36. package/dist/engagement/chat/__tests__/contract.test.js +44 -0
  37. package/dist/engagement/chat/__tests__/contract.test.js.map +1 -0
  38. package/dist/engagement/chat/__tests__/global-chat-room.int.test.js +10 -1
  39. package/dist/engagement/chat/__tests__/global-chat-room.int.test.js.map +1 -1
  40. package/dist/engagement/chat/__tests__/validate-attachment.test.d.ts +2 -0
  41. package/dist/engagement/chat/__tests__/validate-attachment.test.d.ts.map +1 -0
  42. package/dist/engagement/chat/__tests__/validate-attachment.test.js +38 -0
  43. package/dist/engagement/chat/__tests__/validate-attachment.test.js.map +1 -0
  44. package/dist/engagement/chat/contract/index.d.ts +185 -2
  45. package/dist/engagement/chat/contract/index.d.ts.map +1 -1
  46. package/dist/engagement/chat/contract/index.js +21 -3
  47. package/dist/engagement/chat/contract/index.js.map +1 -1
  48. package/dist/engagement/chat/drizzle/migrations/0008_robust_korvac.sql +1 -0
  49. package/dist/engagement/chat/drizzle/migrations/meta/0008_snapshot.json +1462 -0
  50. package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +7 -0
  51. package/dist/engagement/chat/moderation/index.d.ts +1 -0
  52. package/dist/engagement/chat/moderation/index.d.ts.map +1 -1
  53. package/dist/engagement/chat/moderation/index.js +1 -0
  54. package/dist/engagement/chat/moderation/index.js.map +1 -1
  55. package/dist/engagement/chat/moderation/validate-attachment.d.ts +9 -0
  56. package/dist/engagement/chat/moderation/validate-attachment.d.ts.map +1 -0
  57. package/dist/engagement/chat/moderation/validate-attachment.js +27 -0
  58. package/dist/engagement/chat/moderation/validate-attachment.js.map +1 -0
  59. package/dist/engagement/chat/plugin.d.ts +9 -0
  60. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  61. package/dist/engagement/chat/plugin.js +14 -2
  62. package/dist/engagement/chat/plugin.js.map +1 -1
  63. package/dist/engagement/chat/router/index.d.ts +118 -2
  64. package/dist/engagement/chat/router/index.d.ts.map +1 -1
  65. package/dist/engagement/chat/router/index.js +10 -4
  66. package/dist/engagement/chat/router/index.js.map +1 -1
  67. package/dist/engagement/chat/schema/index.d.ts +37 -0
  68. package/dist/engagement/chat/schema/index.d.ts.map +1 -1
  69. package/dist/engagement/chat/schema/index.js +1 -0
  70. package/dist/engagement/chat/schema/index.js.map +1 -1
  71. package/dist/engagement/chat/service/chat.service.d.ts +85 -6
  72. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  73. package/dist/engagement/chat/service/chat.service.js +83 -5
  74. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  75. package/dist/engagement/chat-commands/plugin.d.ts +9 -0
  76. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  77. package/dist/engagement/notifications/__tests__/notification-event-map.test.d.ts +2 -0
  78. package/dist/engagement/notifications/__tests__/notification-event-map.test.d.ts.map +1 -0
  79. package/dist/engagement/notifications/__tests__/notification-event-map.test.js +235 -0
  80. package/dist/engagement/notifications/__tests__/notification-event-map.test.js.map +1 -0
  81. package/dist/engagement/notifications/__tests__/notifications.router.int.test.d.ts +2 -0
  82. package/dist/engagement/notifications/__tests__/notifications.router.int.test.d.ts.map +1 -0
  83. package/dist/engagement/notifications/__tests__/notifications.router.int.test.js +142 -0
  84. package/dist/engagement/notifications/__tests__/notifications.router.int.test.js.map +1 -0
  85. package/dist/engagement/notifications/__tests__/notifications.service.int.test.js +113 -7
  86. package/dist/engagement/notifications/__tests__/notifications.service.int.test.js.map +1 -1
  87. package/dist/engagement/notifications/contract/index.d.ts +98 -17
  88. package/dist/engagement/notifications/contract/index.d.ts.map +1 -1
  89. package/dist/engagement/notifications/contract/index.js +34 -4
  90. package/dist/engagement/notifications/contract/index.js.map +1 -1
  91. package/dist/engagement/notifications/drizzle/migrations/0001_classy_pixie.sql +1 -0
  92. package/dist/engagement/notifications/drizzle/migrations/0002_open_madame_web.sql +2 -0
  93. package/dist/engagement/notifications/drizzle/migrations/meta/0001_snapshot.json +98 -0
  94. package/dist/engagement/notifications/drizzle/migrations/meta/0002_snapshot.json +119 -0
  95. package/dist/engagement/notifications/drizzle/migrations/meta/_journal.json +14 -0
  96. package/dist/engagement/notifications/plugin.d.ts +23 -3
  97. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  98. package/dist/engagement/notifications/plugin.js +219 -75
  99. package/dist/engagement/notifications/plugin.js.map +1 -1
  100. package/dist/engagement/notifications/react/notifications.d.ts +20 -0
  101. package/dist/engagement/notifications/react/notifications.d.ts.map +1 -0
  102. package/dist/engagement/notifications/react/notifications.js +39 -0
  103. package/dist/engagement/notifications/react/notifications.js.map +1 -0
  104. package/dist/engagement/notifications/router/index.d.ts +70 -17
  105. package/dist/engagement/notifications/router/index.d.ts.map +1 -1
  106. package/dist/engagement/notifications/router/index.js +37 -16
  107. package/dist/engagement/notifications/router/index.js.map +1 -1
  108. package/dist/engagement/notifications/schema/index.d.ts +36 -0
  109. package/dist/engagement/notifications/schema/index.d.ts.map +1 -1
  110. package/dist/engagement/notifications/schema/index.js +7 -2
  111. package/dist/engagement/notifications/schema/index.js.map +1 -1
  112. package/dist/engagement/notifications/service/notifications.service.d.ts +27 -19
  113. package/dist/engagement/notifications/service/notifications.service.d.ts.map +1 -1
  114. package/dist/engagement/notifications/service/notifications.service.js +41 -11
  115. package/dist/engagement/notifications/service/notifications.service.js.map +1 -1
  116. package/dist/engagement/react.d.ts +2 -0
  117. package/dist/engagement/react.d.ts.map +1 -0
  118. package/dist/engagement/react.js +3 -0
  119. package/dist/engagement/react.js.map +1 -0
  120. package/dist/engagement/social/plugin.d.ts +9 -0
  121. package/dist/engagement/social/plugin.d.ts.map +1 -1
  122. package/dist/iam/plugin.d.ts +9 -0
  123. package/dist/iam/plugin.d.ts.map +1 -1
  124. package/dist/pam/identity/plugin.d.ts +9 -0
  125. package/dist/pam/identity/plugin.d.ts.map +1 -1
  126. package/dist/pam/player-management/plugin.d.ts +9 -0
  127. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  128. package/dist/pam/player-note/plugin.d.ts +9 -0
  129. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  130. package/dist/pam/profile/plugin.d.ts +9 -0
  131. package/dist/pam/profile/plugin.d.ts.map +1 -1
  132. package/dist/pam/tag/plugin.d.ts +9 -0
  133. package/dist/pam/tag/plugin.d.ts.map +1 -1
  134. package/dist/server/runtime/core-token-catalog.d.ts +9 -0
  135. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  136. package/dist/wallet/plugin.d.ts +9 -0
  137. package/dist/wallet/plugin.d.ts.map +1 -1
  138. package/package.json +6 -1
@@ -18,7 +18,7 @@ import { migrate } from '../migrate.js';
18
18
  // NO_CLIENT_META,
19
19
  // } from '../../../testing/mock.js';
20
20
  import { chatChannel } from '@openora/core/contracts';
21
- import { ChatService, ChatMessageBlockedError, ChatSelfBlockError, ChatSelfIgnoreError, ChatRoomSlugConflictError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, ChatRoomLimitReachedError, ChatRoomLastModeratorError, ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatRoomSelfModerationError, ChatRoomOwnershipError, } from '../service/chat.service.js';
21
+ import { ChatService, ChatMessageBlockedError, ChatAttachmentRejectedError, ChatSelfBlockError, ChatSelfIgnoreError, ChatRoomSlugConflictError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, ChatRoomLimitReachedError, ChatRoomLastModeratorError, ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatRoomSelfModerationError, ChatRoomOwnershipError, } from '../service/chat.service.js';
22
22
  import { ChatModerationService, ChatRoomNotFoundError, ChatMessageNotFoundError, ChatPlayerMutedError, ChatPlayerBannedError, ChatAdminPrivateRoomModerationError, } from '../service/chat-moderation.service.js';
23
23
  import { chatMessage, chatRoom, chatRoomMember, chatRoomBan, chatRoomRule, chatRoomConfiguration, chatRoomMute, chatRoomRemove, chatUserBlock, chatUserIgnore, chatMute, chatPlatformBan, } from '../schema/index.js';
24
24
  import { ChatRoomMembershipService } from '../service/chat-room-membership.service.js';
@@ -35,7 +35,7 @@ function makeService(directory = mock({
35
35
  isActive: true,
36
36
  role: 'player',
37
37
  })),
38
- }), socialCommands) {
38
+ }), socialCommands, allowedAttachmentHosts = []) {
39
39
  const transport = new InProcessRealtimeTransport();
40
40
  const events = makeEventBus();
41
41
  const audit = mock({
@@ -44,7 +44,17 @@ function makeService(directory = mock({
44
44
  });
45
45
  const moderation = new ChatModerationService(db.drizzle, transport, audit);
46
46
  const identityReader = makeIdentityReader();
47
- const chatService = new ChatService(db.drizzle, events, transport, directory, audit, moderation, identityReader, socialCommands);
47
+ const chatService = new ChatService({
48
+ drizzle: db.drizzle,
49
+ events,
50
+ transport,
51
+ directory,
52
+ audit,
53
+ moderation,
54
+ identityReader,
55
+ allowedAttachmentHosts,
56
+ socialCommands,
57
+ });
48
58
  const membership = new ChatRoomMembershipService(db.drizzle, events, audit, transport, identityReader);
49
59
  const roomBan = new ChatRoomBanService(db.drizzle, events, audit, transport, identityReader);
50
60
  const roomMute = new ChatRoomMuteService(db.drizzle, audit);
@@ -128,6 +138,7 @@ describe('ChatService realtime wiring', () => {
128
138
  content: 'hi',
129
139
  type: 'user',
130
140
  metadata: null,
141
+ attachment: null,
131
142
  isDeleted: false,
132
143
  createdAt: '2026-05-29T00:00:00.000Z',
133
144
  };
@@ -188,6 +199,7 @@ describe('ChatService.subscribeMessages per-viewer block filtering (real PG)', (
188
199
  content: 'hi',
189
200
  type: 'user',
190
201
  metadata: null,
202
+ attachment: null,
191
203
  isDeleted: false,
192
204
  createdAt: '2026-05-29T00:00:00.000Z',
193
205
  };
@@ -240,7 +252,11 @@ describe('ChatService.sendGlobalMessage (real PG)', () => {
240
252
  it('stores the username from the verified user, ignoring the header fallback', async () => {
241
253
  const { svc, events } = makeService();
242
254
  const account = await seedUser(db, { name: 'Platform Admin', username: 'platform_admin' });
243
- const msg = await svc.sendGlobalMessage(account.id, 'spoofed-header-name', 'hi');
255
+ const msg = await svc.sendGlobalMessage({
256
+ userId: account.id,
257
+ username: 'spoofed-header-name',
258
+ content: 'hi',
259
+ });
244
260
  expect(msg.username).toBe('platform_admin');
245
261
  const [stored] = await db.drizzle.db.select().from(chatMessage);
246
262
  expect(stored).toMatchObject({ roomId: null, username: 'platform_admin' });
@@ -253,20 +269,24 @@ describe('ChatService.sendGlobalMessage (real PG)', () => {
253
269
  it('falls back to the passed name, then anonymous, when no user row exists', async () => {
254
270
  const { svc } = makeService();
255
271
  const userId = randomUUID();
256
- expect((await svc.sendGlobalMessage(userId, 'fallback', 'hi')).username).toBe('fallback');
257
- expect((await svc.sendGlobalMessage(userId, '', 'hi')).username).toBe('anonymous');
272
+ expect((await svc.sendGlobalMessage({ userId, username: 'fallback', content: 'hi' })).username).toBe('fallback');
273
+ expect((await svc.sendGlobalMessage({ userId, username: '', content: 'hi' })).username).toBe('anonymous');
258
274
  });
259
275
  it('blocks profane messages before they are stored or published', async () => {
260
276
  const { svc, transport } = makeService();
261
277
  const delivered = [];
262
278
  transport.subscribe('chat:global', (m) => delivered.push(m));
263
- await expect(svc.sendGlobalMessage(randomUUID(), 'Alice', 'this is shit')).rejects.toThrow(ChatMessageBlockedError);
279
+ await expect(svc.sendGlobalMessage({ userId: randomUUID(), username: 'Alice', content: 'this is shit' })).rejects.toThrow(ChatMessageBlockedError);
264
280
  expect(delivered).toHaveLength(0);
265
281
  expect(await db.drizzle.db.select().from(chatMessage)).toHaveLength(0);
266
282
  });
267
283
  it('persists URL-sanitized content', async () => {
268
284
  const { svc } = makeService();
269
- const msg = await svc.sendGlobalMessage(randomUUID(), 'Alice', 'click javascript:alert(1)');
285
+ const msg = await svc.sendGlobalMessage({
286
+ userId: randomUUID(),
287
+ username: 'Alice',
288
+ content: 'click javascript:alert(1)',
289
+ });
270
290
  expect(msg.content).toBe('click javascript alert(1)');
271
291
  const [stored] = await db.drizzle.db.select().from(chatMessage);
272
292
  expect(stored?.content).toBe('click javascript alert(1)');
@@ -275,9 +295,60 @@ describe('ChatService.sendGlobalMessage (real PG)', () => {
275
295
  const { svc, transport } = makeService();
276
296
  const delivered = [];
277
297
  transport.subscribe('chat:global', (m) => delivered.push(m));
278
- const msg = await svc.sendGlobalMessage(randomUUID(), 'Alice', 'hello');
298
+ const msg = await svc.sendGlobalMessage({
299
+ userId: randomUUID(),
300
+ username: 'Alice',
301
+ content: 'hello',
302
+ });
279
303
  expect(delivered.map((m) => m.id)).toEqual([msg.id]);
280
304
  });
305
+ it('persists an attachment whose host is on the allow-list and publishes it', async () => {
306
+ const { svc, transport } = makeService(undefined, undefined, ['media.example.com']);
307
+ const delivered = [];
308
+ transport.subscribe('chat:global', (m) => delivered.push(m));
309
+ const attachment = {
310
+ kind: 'gif',
311
+ provider: 'example',
312
+ externalId: 'abc123',
313
+ url: 'https://media.example.com/abc123.gif',
314
+ previewUrl: 'https://media.example.com/abc123-preview.gif',
315
+ width: 320,
316
+ height: 240,
317
+ title: 'a gif',
318
+ };
319
+ const msg = await svc.sendGlobalMessage({
320
+ userId: randomUUID(),
321
+ username: 'Alice',
322
+ content: '',
323
+ attachment,
324
+ });
325
+ expect(msg.type).toBe('user');
326
+ if (msg.type === 'user') {
327
+ expect(msg.attachment).toEqual(attachment);
328
+ }
329
+ const [stored] = await db.drizzle.db.select().from(chatMessage);
330
+ expect(stored?.attachment).toEqual(attachment);
331
+ expect(delivered.map((m) => m.id)).toEqual([msg.id]);
332
+ });
333
+ it('rejects an attachment whose host is not on the allow-list', async () => {
334
+ const { svc } = makeService(undefined, undefined, ['media.example.com']);
335
+ await expect(svc.sendGlobalMessage({
336
+ userId: randomUUID(),
337
+ username: 'Alice',
338
+ content: '',
339
+ attachment: {
340
+ kind: 'gif',
341
+ provider: 'example',
342
+ externalId: 'abc123',
343
+ url: 'https://evil.example.net/abc123.gif',
344
+ previewUrl: 'https://evil.example.net/abc123-preview.gif',
345
+ width: 320,
346
+ height: 240,
347
+ title: 'a gif',
348
+ },
349
+ })).rejects.toThrow(ChatAttachmentRejectedError);
350
+ expect(await db.drizzle.db.select().from(chatMessage)).toHaveLength(0);
351
+ });
281
352
  });
282
353
  describe('ChatService message reads (real PG)', () => {
283
354
  it('returns global messages newest-first, excluding soft-deleted ones', async () => {
@@ -362,6 +433,176 @@ describe('ChatService.sendRoomMessage (real PG)', () => {
362
433
  })).rejects.toBeInstanceOf(ChatRoomNotMemberError);
363
434
  expect(await db.drizzle.db.select().from(chatMessage)).toHaveLength(0);
364
435
  });
436
+ it('persists an attachment whose host is on the allow-list and publishes it', async () => {
437
+ const { svc, transport } = makeService(undefined, undefined, ['media.example.com']);
438
+ const room = await seedRoom();
439
+ const account = await seedUser(db, { name: 'Alice', username: 'alice' });
440
+ const delivered = [];
441
+ transport.subscribe(chatChannel(room.id), (m) => delivered.push(m));
442
+ const attachment = {
443
+ kind: 'gif',
444
+ provider: 'example',
445
+ externalId: 'abc123',
446
+ url: 'https://media.example.com/abc123.gif',
447
+ previewUrl: 'https://media.example.com/abc123-preview.gif',
448
+ width: 320,
449
+ height: 240,
450
+ title: 'a gif',
451
+ };
452
+ const msg = await svc.sendRoomMessage({
453
+ userId: account.id,
454
+ username: 'ignored',
455
+ roomId: room.id,
456
+ content: '',
457
+ attachment,
458
+ });
459
+ expect(msg.type).toBe('user');
460
+ if (msg.type === 'user') {
461
+ expect(msg.attachment).toEqual(attachment);
462
+ }
463
+ const [stored] = await db.drizzle.db.select().from(chatMessage);
464
+ expect(stored?.attachment).toEqual(attachment);
465
+ expect(delivered.map((m) => m.id)).toEqual([msg.id]);
466
+ });
467
+ it('rejects an attachment whose host is not on the allow-list', async () => {
468
+ const { svc } = makeService(undefined, undefined, ['media.example.com']);
469
+ const room = await seedRoom();
470
+ const account = await seedUser(db, { name: 'Alice', username: 'alice' });
471
+ await expect(svc.sendRoomMessage({
472
+ userId: account.id,
473
+ username: 'ignored',
474
+ roomId: room.id,
475
+ content: '',
476
+ attachment: {
477
+ kind: 'gif',
478
+ provider: 'example',
479
+ externalId: 'abc123',
480
+ url: 'https://evil.example.net/abc123.gif',
481
+ previewUrl: 'https://evil.example.net/abc123-preview.gif',
482
+ width: 320,
483
+ height: 240,
484
+ title: 'a gif',
485
+ },
486
+ })).rejects.toThrow(ChatAttachmentRejectedError);
487
+ expect(await db.drizzle.db.select().from(chatMessage)).toHaveLength(0);
488
+ });
489
+ });
490
+ describe('ChatService @mention detection (real PG)', () => {
491
+ function mentionDirectory(summaries) {
492
+ return mock({
493
+ lookupPlayers: async () => [],
494
+ getPlayerByUsername: vi
495
+ .fn()
496
+ .mockImplementation((username) => Promise.resolve(summaries.find((s) => s.username.toLowerCase() === username.toLowerCase()) ?? null)),
497
+ });
498
+ }
499
+ function summaryFor(userId, username) {
500
+ return {
501
+ playerId: randomUUID(),
502
+ userId,
503
+ username,
504
+ email: `${username}@example.com`,
505
+ kycStatus: null,
506
+ language: null,
507
+ avatarUrl: null,
508
+ createdAt: new Date(),
509
+ level: 1,
510
+ currency: 'USD',
511
+ };
512
+ }
513
+ it('resolves an @username in a room message and emits chat.user.mentioned', async () => {
514
+ const bobId = randomUUID();
515
+ const { svc, events } = makeService(mentionDirectory([summaryFor(bobId, 'bob')]));
516
+ const room = await seedRoom();
517
+ const msg = await svc.sendRoomMessage({
518
+ userId: randomUUID(),
519
+ username: 'alice',
520
+ roomId: room.id,
521
+ content: 'hello @bob, welcome',
522
+ });
523
+ await waitFor(() => vi.mocked(events.emit).mock.calls.some(([topic]) => topic === 'chat.user.mentioned'));
524
+ expect(events.emit).toHaveBeenCalledWith('chat.user.mentioned', {
525
+ mentionedUserId: bobId,
526
+ byUserId: msg.userId,
527
+ roomId: room.id,
528
+ messageId: msg.id,
529
+ });
530
+ });
531
+ it('resolves an @username in a global message with a null roomId', async () => {
532
+ const bobId = randomUUID();
533
+ const { svc, events } = makeService(mentionDirectory([summaryFor(bobId, 'bob')]));
534
+ const authorId = randomUUID();
535
+ const msg = await svc.sendGlobalMessage({
536
+ userId: authorId,
537
+ username: 'alice',
538
+ content: 'hey @bob',
539
+ });
540
+ await waitFor(() => vi.mocked(events.emit).mock.calls.some(([topic]) => topic === 'chat.user.mentioned'));
541
+ expect(events.emit).toHaveBeenCalledWith('chat.user.mentioned', {
542
+ mentionedUserId: bobId,
543
+ byUserId: authorId,
544
+ roomId: null,
545
+ messageId: msg.id,
546
+ });
547
+ });
548
+ it('excludes a self-mention', async () => {
549
+ const authorId = randomUUID();
550
+ const { svc, events } = makeService(mentionDirectory([summaryFor(authorId, 'alice')]));
551
+ await svc.sendGlobalMessage({
552
+ userId: authorId,
553
+ username: 'alice',
554
+ content: 'talking to myself @alice',
555
+ });
556
+ await new Promise((resolve) => setTimeout(resolve, 20));
557
+ expect(events.emit).not.toHaveBeenCalledWith('chat.user.mentioned', expect.anything());
558
+ });
559
+ it('emits once per uniquely mentioned user even when repeated in the message', async () => {
560
+ const bobId = randomUUID();
561
+ const { svc, events } = makeService(mentionDirectory([summaryFor(bobId, 'bob')]));
562
+ await svc.sendGlobalMessage({
563
+ userId: randomUUID(),
564
+ username: 'alice',
565
+ content: '@bob @bob are you there @Bob?',
566
+ });
567
+ await waitFor(() => vi.mocked(events.emit).mock.calls.some(([topic]) => topic === 'chat.user.mentioned'));
568
+ const mentionCalls = vi
569
+ .mocked(events.emit)
570
+ .mock.calls.filter(([topic]) => topic === 'chat.user.mentioned');
571
+ expect(mentionCalls).toHaveLength(1);
572
+ });
573
+ it('silently drops an @token that does not resolve to a known username', async () => {
574
+ const { svc, events } = makeService(mentionDirectory([]));
575
+ await expect(svc.sendGlobalMessage({ userId: randomUUID(), username: 'alice', content: 'hello @nobody' })).resolves.toBeDefined();
576
+ await new Promise((resolve) => setTimeout(resolve, 20));
577
+ expect(events.emit).not.toHaveBeenCalledWith('chat.user.mentioned', expect.anything());
578
+ });
579
+ it('does not treat the domain part of an email address as a mention', async () => {
580
+ const acmeId = randomUUID();
581
+ const { svc, events } = makeService(mentionDirectory([summaryFor(acmeId, 'acme')]));
582
+ await expect(svc.sendGlobalMessage({
583
+ userId: randomUUID(),
584
+ username: 'alice',
585
+ content: 'contact me at info@acme.com',
586
+ })).resolves.toBeDefined();
587
+ await new Promise((resolve) => setTimeout(resolve, 20));
588
+ expect(events.emit).not.toHaveBeenCalledWith('chat.user.mentioned', expect.anything());
589
+ });
590
+ it('does not emit chat.user.mentioned when the mentioned user has blocked the sender', async () => {
591
+ const bobId = randomUUID();
592
+ const authorId = randomUUID();
593
+ const { svc, events } = makeService(mentionDirectory([summaryFor(bobId, 'bob')]));
594
+ await svc.blockUser(bobId, authorId);
595
+ await svc.sendGlobalMessage({ userId: authorId, username: 'alice', content: 'hello @bob' });
596
+ await new Promise((resolve) => setTimeout(resolve, 20));
597
+ expect(events.emit).not.toHaveBeenCalledWith('chat.user.mentioned', expect.anything());
598
+ });
599
+ it('fails closed (does not emit) when the block check throws', async () => {
600
+ const authorId = randomUUID();
601
+ const { svc, events } = makeService(mentionDirectory([summaryFor('not-a-valid-uuid', 'bob')]));
602
+ await expect(svc.sendGlobalMessage({ userId: authorId, username: 'alice', content: 'hello @bob' })).resolves.toBeDefined();
603
+ await new Promise((resolve) => setTimeout(resolve, 20));
604
+ expect(events.emit).not.toHaveBeenCalledWith('chat.user.mentioned', expect.anything());
605
+ });
365
606
  });
366
607
  describe('ChatService.deleteMessage (real PG)', () => {
367
608
  it('soft-deletes a message for a room owner and publishes a tombstone', async () => {
@@ -1200,7 +1441,7 @@ describe('ChatService moderation (real PG)', () => {
1200
1441
  roomId: globalRoom.id,
1201
1442
  readOnlyMode: true,
1202
1443
  });
1203
- await expect(svc.sendGlobalMessage(randomUUID(), 'ReadOnly', 'hello')).rejects.toBeInstanceOf(ChatPlayerMutedError);
1444
+ await expect(svc.sendGlobalMessage({ userId: randomUUID(), username: 'ReadOnly', content: 'hello' })).rejects.toBeInstanceOf(ChatPlayerMutedError);
1204
1445
  });
1205
1446
  it.each(['__all_public', '__all'])('enforces %s mutes on global sends', async (scope) => {
1206
1447
  const { svc, moderation } = makeService();
@@ -1213,7 +1454,7 @@ describe('ChatService moderation (real PG)', () => {
1213
1454
  ...NO_CLIENT_META,
1214
1455
  });
1215
1456
  const [mute] = await db.drizzle.db.select().from(chatMute).limit(1);
1216
- await expect(svc.sendGlobalMessage(mute.userId, 'Muted', 'hello')).rejects.toBeInstanceOf(ChatPlayerMutedError);
1457
+ await expect(svc.sendGlobalMessage({ userId: mute.userId, username: 'Muted', content: 'hello' })).rejects.toBeInstanceOf(ChatPlayerMutedError);
1217
1458
  });
1218
1459
  it('supports a global mute when the virtual global room has no row', async () => {
1219
1460
  const { svc, moderation } = makeService();
@@ -1226,7 +1467,7 @@ describe('ChatService moderation (real PG)', () => {
1226
1467
  actorId: randomUUID(),
1227
1468
  ...NO_CLIENT_META,
1228
1469
  })).resolves.toEqual({ success: true });
1229
- await expect(svc.sendGlobalMessage(userId, 'Muted', 'hello')).rejects.toBeInstanceOf(ChatPlayerMutedError);
1470
+ await expect(svc.sendGlobalMessage({ userId, username: 'Muted', content: 'hello' })).rejects.toBeInstanceOf(ChatPlayerMutedError);
1230
1471
  });
1231
1472
  it('enforces an all-chat mute in private rooms', async () => {
1232
1473
  const { svc, moderation } = makeService();
@@ -1278,7 +1519,7 @@ describe('ChatService moderation (real PG)', () => {
1278
1519
  actorId: randomUUID(),
1279
1520
  ...NO_CLIENT_META,
1280
1521
  });
1281
- await expect(svc.sendGlobalMessage(userId, 'Banned', 'hello')).rejects.toBeInstanceOf(ChatPlayerBannedError);
1522
+ await expect(svc.sendGlobalMessage({ userId, username: 'Banned', content: 'hello' })).rejects.toBeInstanceOf(ChatPlayerBannedError);
1282
1523
  await expect(svc.sendRoomMessage({ userId, username: 'Banned', roomId: room.id, content: 'hello' })).resolves.toMatchObject({ userId });
1283
1524
  await moderation.unban({
1284
1525
  userId,
@@ -1286,7 +1527,7 @@ describe('ChatService moderation (real PG)', () => {
1286
1527
  actorId: randomUUID(),
1287
1528
  ...NO_CLIENT_META,
1288
1529
  });
1289
- await expect(svc.sendGlobalMessage(userId, 'Banned', 'hello')).resolves.toMatchObject({
1530
+ await expect(svc.sendGlobalMessage({ userId, username: 'Banned', content: 'hello' })).resolves.toMatchObject({
1290
1531
  userId,
1291
1532
  });
1292
1533
  });