@devchitchat/chat 0.0.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 (131) hide show
  1. package/README.md +313 -0
  2. package/index.js +148 -0
  3. package/migrate/001-drop-channel-invites.js +3 -0
  4. package/migrate/002-invite-initial-roles.js +5 -0
  5. package/migrate/003-dm-channels.js +35 -0
  6. package/migrate/004-notifications.js +15 -0
  7. package/migrate/005-uploads.js +21 -0
  8. package/migrate/006-mention-priority.js +3 -0
  9. package/migrate/007-push-subscriptions.js +14 -0
  10. package/migrate/008-messages-channel-seq-index.js +3 -0
  11. package/migrate/009-message-reactions.js +15 -0
  12. package/migrate/010-edit-messages.js +7 -0
  13. package/package.json +51 -0
  14. package/pages/_error.html +12 -0
  15. package/pages/_layout.html +31 -0
  16. package/pages/_layout.js +13 -0
  17. package/pages/admin/_layout.html +52 -0
  18. package/pages/admin/_layout.js +8 -0
  19. package/pages/admin/bots/[userId].js +88 -0
  20. package/pages/admin/bots/[userId].phtml +89 -0
  21. package/pages/admin/bots/index.js +41 -0
  22. package/pages/admin/bots/index.phtml +58 -0
  23. package/pages/admin/index.js +8 -0
  24. package/pages/admin/invites/index.js +72 -0
  25. package/pages/admin/invites/index.phtml +88 -0
  26. package/pages/admin/users/[userId].js +60 -0
  27. package/pages/admin/users/[userId].phtml +57 -0
  28. package/pages/admin/users/index.js +20 -0
  29. package/pages/admin/users/index.phtml +37 -0
  30. package/pages/api/uploads/index.js +66 -0
  31. package/pages/api/user/settings.js +26 -0
  32. package/pages/auth/signout.js +14 -0
  33. package/pages/channels/[channelId].js +99 -0
  34. package/pages/channels/[channelId].phtml +173 -0
  35. package/pages/index.js +33 -0
  36. package/pages/invite/[token].js +10 -0
  37. package/pages/login/index.js +57 -0
  38. package/pages/login/index.phtml +29 -0
  39. package/pages/public/client/action-sheet.js +77 -0
  40. package/pages/public/client/app.js +38 -0
  41. package/pages/public/client/auth-tabs.js +13 -0
  42. package/pages/public/client/emoji-data.js +197 -0
  43. package/pages/public/client/islands/call.js +1770 -0
  44. package/pages/public/client/islands/sidebar.js +1197 -0
  45. package/pages/public/client/long-press.js +59 -0
  46. package/pages/public/client/modal.js +50 -0
  47. package/pages/public/client/router.js +87 -0
  48. package/pages/public/client/rtc-peer-manager.js +344 -0
  49. package/pages/public/client/settings-sync.js +76 -0
  50. package/pages/public/client/shared/messages.js +147 -0
  51. package/pages/public/client/swipe-nav.js +98 -0
  52. package/pages/public/client/theme.js +27 -0
  53. package/pages/public/client/ws.js +71 -0
  54. package/pages/public/favicon.ico +0 -0
  55. package/pages/public/favicon.png +0 -0
  56. package/pages/public/icon.png +0 -0
  57. package/pages/public/manifest.json +11 -0
  58. package/pages/public/sw.js +38 -0
  59. package/pages/public/themes/base.css +1786 -0
  60. package/pages/public/themes/dark.css +22 -0
  61. package/pages/public/themes/forest.css +22 -0
  62. package/pages/public/themes/light.css +23 -0
  63. package/pages/public/themes/ocean.css +22 -0
  64. package/pages/public/themes/rose.css +22 -0
  65. package/pages/registration/index.js +35 -0
  66. package/pages/registration/index.phtml +38 -0
  67. package/pages/uploads/[uploadId]/[filename].js +45 -0
  68. package/src/adapters/InMemoryAuthRepository.js +74 -0
  69. package/src/adapters/InMemoryChannelRepository.js +138 -0
  70. package/src/adapters/InMemoryDeliveryRepository.js +52 -0
  71. package/src/adapters/InMemoryFileStore.js +53 -0
  72. package/src/adapters/InMemoryHubRepository.js +85 -0
  73. package/src/adapters/InMemoryMessageRepository.js +35 -0
  74. package/src/adapters/InMemoryReactionRepository.js +45 -0
  75. package/src/adapters/InMemorySearchRepository.js +37 -0
  76. package/src/adapters/InMemorySignalingRepository.js +35 -0
  77. package/src/adapters/InMemoryUploadRepository.js +36 -0
  78. package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
  79. package/src/adapters/LocalFileStore.js +40 -0
  80. package/src/adapters/SqliteAuthRepository.js +184 -0
  81. package/src/adapters/SqliteChannelRepository.js +149 -0
  82. package/src/adapters/SqliteDeliveryRepository.js +53 -0
  83. package/src/adapters/SqliteHubRepository.js +99 -0
  84. package/src/adapters/SqliteMessageRepository.js +90 -0
  85. package/src/adapters/SqlitePushRepository.js +39 -0
  86. package/src/adapters/SqliteReactionRepository.js +50 -0
  87. package/src/adapters/SqliteSearchRepository.js +42 -0
  88. package/src/adapters/SqliteSignalingRepository.js +50 -0
  89. package/src/adapters/SqliteUploadRepository.js +34 -0
  90. package/src/adapters/SqliteUserSettingsRepository.js +23 -0
  91. package/src/adminAuth.js +25 -0
  92. package/src/config.js +11 -0
  93. package/src/context.js +77 -0
  94. package/src/core/dm.js +10 -0
  95. package/src/core/mentions.js +27 -0
  96. package/src/core/messages.js +21 -0
  97. package/src/core/reactions.js +6 -0
  98. package/src/core/roles.js +5 -0
  99. package/src/core/uploads.js +107 -0
  100. package/src/db/initDb.js +225 -0
  101. package/src/db/openDb.js +18 -0
  102. package/src/db/runMigrations.js +45 -0
  103. package/src/db/transaction.js +11 -0
  104. package/src/ports/IFileStore.js +34 -0
  105. package/src/services/AuthService.js +208 -0
  106. package/src/services/BotService.js +148 -0
  107. package/src/services/ChannelService.js +176 -0
  108. package/src/services/DeliveryService.js +28 -0
  109. package/src/services/HubService.js +133 -0
  110. package/src/services/MessageService.js +122 -0
  111. package/src/services/NotificationService.js +45 -0
  112. package/src/services/PresenceService.js +55 -0
  113. package/src/services/ReactionService.js +57 -0
  114. package/src/services/SearchService.js +21 -0
  115. package/src/services/SignalingService.js +177 -0
  116. package/src/services/UploadService.js +111 -0
  117. package/src/services/UserSettingsService.js +30 -0
  118. package/src/services/WebPushService.js +217 -0
  119. package/src/util/crypto.js +21 -0
  120. package/src/util/errors.js +14 -0
  121. package/src/util/ids.js +3 -0
  122. package/src/util/logger.js +21 -0
  123. package/src/ws/ChatServer.js +478 -0
  124. package/src/ws/handlers/authHandlers.js +152 -0
  125. package/src/ws/handlers/channelHandlers.js +166 -0
  126. package/src/ws/handlers/hubHandlers.js +82 -0
  127. package/src/ws/handlers/messageHandlers.js +88 -0
  128. package/src/ws/handlers/pushHandlers.js +25 -0
  129. package/src/ws/handlers/reactionHandlers.js +27 -0
  130. package/src/ws/handlers/rtcHandlers.js +126 -0
  131. package/styles.css +22 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Auth & admin WS handlers.
3
+ *
4
+ * Each function receives (ws, msg, ctx) where ctx is the shared context
5
+ * object built in ChatServer — services, state maps, and bound helpers.
6
+ */
7
+
8
+ export function handleHello(ws, msg, ctx) {
9
+ const { auth, botService, sendWs, sendDigest, attachUser } = ctx
10
+ const { session_token, bot_token } = msg.body?.resume ?? {}
11
+ let user = null
12
+ let lastSeenAt = null
13
+
14
+ if (bot_token) {
15
+ const bot = botService.authenticateToken(bot_token)
16
+ user = { user_id: bot.userId, handle: bot.handle, display_name: bot.displayName, roles: bot.roles }
17
+ attachUser(ws, user, null)
18
+ } else if (session_token) {
19
+ const session = auth.validateSession(session_token)
20
+ if (session) {
21
+ user = session.user
22
+ lastSeenAt = session.last_seen_at
23
+ attachUser(ws, user, session.session_id)
24
+ }
25
+ }
26
+
27
+ sendWs(ws, {
28
+ t: 'hello_ack', reply_to: msg.id, ok: true,
29
+ body: {
30
+ server: { name: 'devchitchat', ver: '2.0.0' },
31
+ session: { authenticated: !!user, user: user ?? null, session_token: session_token ?? null },
32
+ limits: { max_channels: 200, max_group_members: 20, max_message_bytes: 8000, max_signaling_bytes: 64000 }
33
+ }
34
+ })
35
+
36
+ if (user) sendDigest(ws, user.user_id, lastSeenAt)
37
+ }
38
+
39
+ export async function handleInviteRedeem(ws, msg, ctx) {
40
+ const { auth, sendWs, sendDigest, attachUser } = ctx
41
+ const { invite_token, profile, password } = msg.body || {}
42
+ const result = await auth.redeemInvite({ inviteToken: invite_token, profile, password })
43
+ const session = auth.validateSession(result.sessionToken)
44
+ attachUser(ws, session.user, session.session_id)
45
+ sendWs(ws, { t: 'auth.session', reply_to: msg.id, ok: true, body: { session_token: result.sessionToken, user: result.user } })
46
+ sendDigest(ws, session.user.user_id, null)
47
+ }
48
+
49
+ export async function handleSignIn(ws, msg, ctx) {
50
+ const { auth, sendWs, sendDigest, attachUser } = ctx
51
+ const { handle, password } = msg.body || {}
52
+ const result = await auth.signInWithPassword({ handle, password })
53
+ const session = auth.validateSession(result.sessionToken)
54
+ attachUser(ws, session.user, session.session_id)
55
+ sendWs(ws, { t: 'auth.session', reply_to: msg.id, ok: true, body: { session_token: result.sessionToken, user: result.user } })
56
+ sendDigest(ws, session.user.user_id, session.last_seen_at)
57
+ }
58
+
59
+ export function handleSignOut(ws, msg, ctx) {
60
+ const { auth, sendWs } = ctx
61
+ if (ws.data.sessionId) auth.revokeSession(ws.data.sessionId)
62
+ ws.data.userId = null
63
+ ws.data.sessionId = null
64
+ sendWs(ws, { t: 'auth.signed_out', reply_to: msg.id, ok: true, body: {} })
65
+ }
66
+
67
+ // ── Admin — invites ────────────────────────────────────────────────────────────
68
+
69
+ export function handleAdminInviteCreate(ws, msg, ctx) {
70
+ const { auth, sendWs } = ctx
71
+ const { ttl_ms, max_uses, note, roles } = msg.body || {}
72
+ const invite = auth.createInvite({ createdByUserId: ws.data.userId, ttlMs: ttl_ms, maxUses: max_uses, note, roles })
73
+ sendWs(ws, { t: 'admin.invite', reply_to: msg.id, ok: true, body: { invite_token: invite.inviteToken, expires_at: invite.expiresAt, max_uses: invite.maxUses, roles: invite.roles } })
74
+ }
75
+
76
+ export function handleAdminInviteList(ws, msg, ctx) {
77
+ const { auth, sendWs } = ctx
78
+ const invites = auth.listInvites({ requestingUserId: ws.data.userId })
79
+ sendWs(ws, { t: 'admin.invite_list_result', reply_to: msg.id, ok: true, body: { invites } })
80
+ }
81
+
82
+ export function handleAdminInviteRevoke(ws, msg, ctx) {
83
+ const { auth, sendWs } = ctx
84
+ const { invite_id } = msg.body || {}
85
+ auth.revokeInvite({ inviteId: invite_id, requestingUserId: ws.data.userId })
86
+ sendWs(ws, { t: 'admin.invite_revoked', reply_to: msg.id, ok: true, body: { invite_id } })
87
+ }
88
+
89
+ // ── Admin — users ──────────────────────────────────────────────────────────────
90
+
91
+ export function handleAdminUserList(ws, msg, ctx) {
92
+ const { auth, sendWs } = ctx
93
+ const users = auth.listUsers({ requestingUserId: ws.data.userId })
94
+ sendWs(ws, { t: 'admin.user_list_result', reply_to: msg.id, ok: true, body: { users } })
95
+ }
96
+
97
+ export function handleAdminUserSetRoles(ws, msg, ctx) {
98
+ const { auth, sendWs } = ctx
99
+ const { user_id, roles } = msg.body || {}
100
+ auth.setUserRoles({ targetUserId: user_id, roles, requestingUserId: ws.data.userId })
101
+ sendWs(ws, { t: 'admin.user_updated', reply_to: msg.id, ok: true, body: { user_id } })
102
+ }
103
+
104
+ export async function handleAdminUserSetPassword(ws, msg, ctx) {
105
+ const { auth, sendWs } = ctx
106
+ const { user_id, new_password } = msg.body || {}
107
+ await auth.adminSetPassword({ targetUserId: user_id, newPassword: new_password, requestingUserId: ws.data.userId })
108
+ sendWs(ws, { t: 'admin.user_updated', reply_to: msg.id, ok: true, body: { user_id } })
109
+ }
110
+
111
+ export function handleAdminUserSetDisplayName(ws, msg, ctx) {
112
+ const { auth, sendWs } = ctx
113
+ const { user_id, display_name } = msg.body || {}
114
+ auth.adminUpdateDisplayName({ targetUserId: user_id, displayName: display_name, requestingUserId: ws.data.userId })
115
+ sendWs(ws, { t: 'admin.user_updated', reply_to: msg.id, ok: true, body: { user_id } })
116
+ }
117
+
118
+ // ── Admin — bots ───────────────────────────────────────────────────────────────
119
+
120
+ export function handleAdminBotCreate(ws, msg, ctx) {
121
+ const { botService, sendWs } = ctx
122
+ const { handle, display_name, token_label } = msg.body || {}
123
+ const result = botService.createBot({ handle, displayName: display_name, tokenLabel: token_label, requestingUserId: ws.data.userId })
124
+ sendWs(ws, { t: 'admin.bot_created', reply_to: msg.id, ok: true, body: result })
125
+ }
126
+
127
+ export function handleAdminBotList(ws, msg, ctx) {
128
+ const { botService, sendWs } = ctx
129
+ const bots = botService.listBots({ requestingUserId: ws.data.userId })
130
+ sendWs(ws, { t: 'admin.bot_list_result', reply_to: msg.id, ok: true, body: { bots } })
131
+ }
132
+
133
+ export function handleAdminBotTokenCreate(ws, msg, ctx) {
134
+ const { botService, sendWs } = ctx
135
+ const { user_id, label } = msg.body || {}
136
+ const result = botService.createToken({ userId: user_id, label, requestingUserId: ws.data.userId })
137
+ sendWs(ws, { t: 'admin.bot_token_created', reply_to: msg.id, ok: true, body: result })
138
+ }
139
+
140
+ export function handleAdminBotTokenRevoke(ws, msg, ctx) {
141
+ const { botService, sendWs } = ctx
142
+ const { token_id } = msg.body || {}
143
+ botService.revokeToken({ tokenId: token_id, requestingUserId: ws.data.userId })
144
+ sendWs(ws, { t: 'admin.bot_token_revoked', reply_to: msg.id, ok: true, body: { token_id } })
145
+ }
146
+
147
+ export function handleAdminBotSetChannels(ws, msg, ctx) {
148
+ const { botService, sendWs } = ctx
149
+ const { user_id, channel_ids } = msg.body || {}
150
+ botService.setBotChannels({ userId: user_id, channelIds: channel_ids, requestingUserId: ws.data.userId })
151
+ sendWs(ws, { t: 'admin.bot_updated', reply_to: msg.id, ok: true, body: { user_id } })
152
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Channel, user, and DM WS handlers.
3
+ */
4
+
5
+ export function handleChannelList(ws, msg, ctx) {
6
+ const { auth, channelService, sendWs } = ctx
7
+ const user = auth.getUser(ws.data.userId)
8
+ const channels = channelService.listChannels(ws.data.userId, user?.roles || [], msg.body?.hub_id)
9
+ sendWs(ws, { t: 'channel.list_result', reply_to: msg.id, ok: true, body: { channels, hub_id: msg.body?.hub_id } })
10
+ }
11
+
12
+ export function handleChannelCreate(ws, msg, ctx) {
13
+ const { auth, hubService, channelService, sendWs, broadcastToChannelAudience } = ctx
14
+ const user = auth.getUser(ws.data.userId)
15
+ let { hub_id, kind, name, topic, visibility } = msg.body || {}
16
+ if (!hub_id || hub_id === 'default') {
17
+ hub_id = hubService.ensureDefaultHub(ws.data.userId).hub_id
18
+ }
19
+ const channel = channelService.createChannel({ hubId: hub_id, kind, name, topic, visibility, createdByUserId: ws.data.userId, userRoles: user?.roles || [] })
20
+ sendWs(ws, { t: 'channel.created', reply_to: msg.id, ok: true, body: { channel } })
21
+ broadcastToChannelAudience(channel.channel_id, { t: 'channel.created', ok: true, body: { channel } }, ws)
22
+ }
23
+
24
+ export function handleChannelUpdate(ws, msg, ctx) {
25
+ const { auth, channelService, sendWs, publishChannel } = ctx
26
+ const user = auth.getUser(ws.data.userId)
27
+ const { channel_id, name, topic, visibility } = msg.body || {}
28
+ const channel = channelService.updateChannel({ channelId: channel_id, userId: ws.data.userId, roles: user?.roles || [], name, topic, visibility })
29
+ sendWs(ws, { t: 'channel.updated', reply_to: msg.id, ok: true, body: { channel } })
30
+ publishChannel(channel_id, { t: 'channel.updated', ok: true, body: { channel } })
31
+ }
32
+
33
+ export function handleChannelDelete(ws, msg, ctx) {
34
+ const { auth, channelService, sendWs, collectChannelAudience } = ctx
35
+ const user = auth.getUser(ws.data.userId)
36
+ const { channel_id } = msg.body || {}
37
+ // Collect audience before deletion — access checks fail once deleted_at is set,
38
+ // and publishChannel only reaches pub/sub subscribers (not sidebar connections)
39
+ const audience = collectChannelAudience(channel_id, ws)
40
+ const result = channelService.deleteChannel({ channelId: channel_id, userId: ws.data.userId, roles: user?.roles || [] })
41
+ sendWs(ws, { t: 'channel.deleted', reply_to: msg.id, ok: true, body: result })
42
+ audience.forEach(conn => sendWs(conn, { t: 'channel.deleted', ok: true, body: result }))
43
+ }
44
+
45
+ export function handleChannelJoin(ws, msg, ctx) {
46
+ const { auth, channelService, deliveryService, presenceService, signalingService, sendWs } = ctx
47
+ const user = auth.getUser(ws.data.userId)
48
+ const { channel_id } = msg.body || {}
49
+ const result = channelService.joinChannel({ channelId: channel_id, userId: ws.data.userId, userRoles: user?.roles || [] })
50
+ ws.subscribe(`channel:${channel_id}`)
51
+ presenceService.joinChannel(ws.data.connectionId, channel_id)
52
+ deliveryService.getOrCreate({ channelId: channel_id, userId: ws.data.userId })
53
+ sendWs(ws, { t: 'channel.joined', reply_to: msg.id, ok: true, body: result })
54
+
55
+ // Push current call state so the joining client immediately sees "N in call" or nothing
56
+ const activeCall = signalingService.getActiveCallForChannel(channel_id)
57
+ const peers = activeCall ? Array.from(activeCall.peers.values()) : []
58
+ sendWs(ws, {
59
+ t: 'rtc.call_state', ok: true,
60
+ body: { channel_id, call_id: activeCall?.call_id ?? null, count: peers.length, users: peers.map(p => ({ user_id: p.user_id })) }
61
+ })
62
+ }
63
+
64
+ export function handleChannelLeave(ws, msg, ctx) {
65
+ const { channelService, presenceService, sendWs } = ctx
66
+ const { channel_id } = msg.body || {}
67
+ channelService.leaveChannel({ channelId: channel_id, userId: ws.data.userId })
68
+ ws.unsubscribe(`channel:${channel_id}`)
69
+ presenceService.leaveChannel(ws.data.connectionId, channel_id)
70
+ sendWs(ws, { t: 'channel.left', reply_to: msg.id, ok: true, body: { channel_id } })
71
+ }
72
+
73
+ export function handleChannelReorder(ws, msg, ctx) {
74
+ const { auth, channelService, broadcastToHubAudience } = ctx
75
+ const user = auth.getUser(ws.data.userId)
76
+ const { hub_id, channel_ids } = msg.body || {}
77
+ const channels = channelService.reorderChannels({ hubId: hub_id, channelIds: channel_ids, userId: ws.data.userId, userRoles: user?.roles || [] })
78
+ broadcastToHubAudience(hub_id, { t: 'channel.reordered', ok: true, body: { hub_id, channels } }, null)
79
+ }
80
+
81
+ export function handleChannelAddMember(ws, msg, ctx) {
82
+ const { channelService, sendWs } = ctx
83
+ const { channel_id, user_id } = msg.body || {}
84
+ const result = channelService.addMember({ channelId: channel_id, createdByUserId: ws.data.userId, targetUserId: user_id })
85
+ sendWs(ws, { t: 'channel.member_added', reply_to: msg.id, ok: true, body: result })
86
+ }
87
+
88
+ export function handleChannelRemoveMember(ws, msg, ctx) {
89
+ const { channelService, sendWs, publishChannel } = ctx
90
+ const { channel_id, user_id } = msg.body || {}
91
+ const result = channelService.removeMember({ channelId: channel_id, removedByUserId: ws.data.userId, targetUserId: user_id })
92
+ sendWs(ws, { t: 'channel.member_removed', reply_to: msg.id, ok: true, body: result })
93
+ publishChannel(channel_id, { t: 'channel.member_removed', ok: true, body: result })
94
+ }
95
+
96
+ export function handleChannelListMembers(ws, msg, ctx) {
97
+ const { auth, channelService, sendWs } = ctx
98
+ const { channel_id } = msg.body || {}
99
+ const user = auth.getUser(ws.data.userId)
100
+ const roles = user?.roles || []
101
+ if (!channelService.canAccessChannel(channel_id, ws.data.userId, roles)) {
102
+ return sendWs(ws, { t: 'error', reply_to: msg.id, ok: false, body: { code: 'FORBIDDEN', message: 'Access denied' } })
103
+ }
104
+ const members = channelService.listChannelMembers(channel_id)
105
+ const enriched = members.map(m => {
106
+ const u = auth.getUser(m.user_id)
107
+ return { user_id: m.user_id, handle: u?.handle ?? null, display_name: u?.display_name ?? null, role: m.role }
108
+ })
109
+ sendWs(ws, { t: 'channel.list_members_result', reply_to: msg.id, ok: true, body: { channel_id, members: enriched } })
110
+ }
111
+
112
+ // ── Users ──────────────────────────────────────────────────────────────────────
113
+
114
+ export function handleUserList(ws, msg, ctx) {
115
+ const { auth, sendWs } = ctx
116
+ const users = auth.listUsersBasic().filter(u => !u.roles.includes('bot'))
117
+ sendWs(ws, { t: 'user.list_result', reply_to: msg.id, ok: true, body: { users } })
118
+ }
119
+
120
+ export function handleBotList(ws, msg, ctx) {
121
+ const { auth, sendWs } = ctx
122
+ const bots = auth.listUsersBasic().filter(u => u.roles.includes('bot'))
123
+ sendWs(ws, { t: 'bot.list_result', reply_to: msg.id, ok: true, body: { bots } })
124
+ }
125
+
126
+ // ── Direct messages ────────────────────────────────────────────────────────────
127
+
128
+ export function handleDmOpen(ws, msg, ctx) {
129
+ const { auth, channelService, sendWs, subscribeUserToChannel } = ctx
130
+ const { target_user_id } = msg.body || {}
131
+ const result = channelService.findOrCreateDm({ userId: ws.data.userId, targetUserId: target_user_id })
132
+ const targetUser = auth.getUser(target_user_id)
133
+
134
+ // Subscribe all active connections for both users to the DM channel topic.
135
+ subscribeUserToChannel(ws.data.userId, result.channel_id)
136
+ subscribeUserToChannel(target_user_id, result.channel_id)
137
+
138
+ // Tell the initiating connection to navigate (no notify_only)
139
+ sendWs(ws, {
140
+ t: 'dm.opened', reply_to: msg.id, ok: true,
141
+ body: { channel_id: result.channel_id, is_new: result.is_new, with_user: { user_id: target_user_id, display_name: targetUser?.display_name ?? null } }
142
+ })
143
+
144
+ // Notify the initiating user's OTHER connections (sidebar) so the DM appears in the list
145
+ ctx.server?.publish(`user:${ws.data.userId}`, JSON.stringify({
146
+ v: 1, server_ts: Date.now(), t: 'dm.opened', ok: true,
147
+ body: { channel_id: result.channel_id, is_new: result.is_new, notify_only: true, with_user: { user_id: target_user_id, display_name: targetUser?.display_name ?? null } }
148
+ }))
149
+
150
+ // Notify the target user's connections — add to DM list, show unread dot, don't navigate
151
+ ctx.server?.publish(`user:${target_user_id}`, JSON.stringify({
152
+ v: 1, server_ts: Date.now(), t: 'dm.opened', ok: true,
153
+ body: { channel_id: result.channel_id, is_new: result.is_new, notify_only: true, with_user: { user_id: ws.data.userId, display_name: ws.data.displayName } }
154
+ }))
155
+ }
156
+
157
+ export function handleDmList(ws, msg, ctx) {
158
+ const { auth, channelService, sendWs } = ctx
159
+ const dms = channelService.listDms({ userId: ws.data.userId })
160
+ for (const dm of dms) ws.subscribe(`channel:${dm.channel_id}`)
161
+ const enriched = dms.map(dm => {
162
+ const other = auth.getUser(dm.other_user_id)
163
+ return { channel_id: dm.channel_id, with_user: { user_id: dm.other_user_id, display_name: other?.display_name ?? dm.other_user_id } }
164
+ })
165
+ sendWs(ws, { t: 'dm.list_result', reply_to: msg.id, ok: true, body: { dms: enriched } })
166
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Hub WS handlers.
3
+ */
4
+
5
+ export function handleHubList(ws, msg, ctx) {
6
+ const { auth, hubService, sendWs } = ctx
7
+ const user = auth.getUser(ws.data.userId)
8
+ const hubs = hubService.listHubs(ws.data.userId, user?.roles || [])
9
+ sendWs(ws, { t: 'hub.list_result', reply_to: msg.id, ok: true, body: { hubs } })
10
+ }
11
+
12
+ export function handleHubCreate(ws, msg, ctx) {
13
+ const { hubService, sendWs, broadcastToHubAudience } = ctx
14
+ const { name, description, visibility } = msg.body || {}
15
+ const hub = hubService.createHub({ name, description, visibility, createdByUserId: ws.data.userId })
16
+ sendWs(ws, { t: 'hub.created', reply_to: msg.id, ok: true, body: { hub } })
17
+ broadcastToHubAudience(hub.hub_id, { t: 'hub.created', ok: true, body: { hub } }, ws)
18
+ }
19
+
20
+ export function handleHubUpdate(ws, msg, ctx) {
21
+ const { auth, hubService, sendWs, broadcastToHubAudience } = ctx
22
+ const user = auth.getUser(ws.data.userId)
23
+ const { hub_id, name, description, visibility } = msg.body || {}
24
+ const hub = hubService.updateHub({ hubId: hub_id, userId: ws.data.userId, roles: user?.roles || [], name, description, visibility })
25
+ sendWs(ws, { t: 'hub.updated', reply_to: msg.id, ok: true, body: { hub } })
26
+ broadcastToHubAudience(hub.hub_id, { t: 'hub.updated', ok: true, body: { hub } }, ws)
27
+ }
28
+
29
+ export function handleHubDelete(ws, msg, ctx) {
30
+ const { auth, hubService, sendWs, collectHubAudience } = ctx
31
+ const user = auth.getUser(ws.data.userId)
32
+ const { hub_id } = msg.body || {}
33
+ // Collect audience before deletion — access checks fail once deleted_at is set
34
+ const audience = collectHubAudience(hub_id, ws)
35
+ const result = hubService.deleteHub({ hubId: hub_id, userId: ws.data.userId, roles: user?.roles || [] })
36
+ sendWs(ws, { t: 'hub.deleted', reply_to: msg.id, ok: true, body: result })
37
+ audience.forEach(conn => sendWs(conn, { t: 'hub.deleted', ok: true, body: result }))
38
+ }
39
+
40
+ export function handleHubAddMember(ws, msg, ctx) {
41
+ const { auth, hubService, sendWs, broadcastToHubAudience } = ctx
42
+ const user = auth.getUser(ws.data.userId)
43
+ const { hub_id, user_id } = msg.body || {}
44
+ const result = hubService.addHubMember({ hubId: hub_id, targetUserId: user_id, requestingUserId: ws.data.userId, requestingRoles: user?.roles || [] })
45
+ sendWs(ws, { t: 'hub.member_added', reply_to: msg.id, ok: true, body: result })
46
+ ctx.server?.publish(`user:${user_id}`, JSON.stringify({ v: 1, server_ts: Date.now(), t: 'hub.member_added', ok: true, body: result }))
47
+ broadcastToHubAudience(hub_id, { t: 'hub.member_added', ok: true, body: result }, ws)
48
+ }
49
+
50
+ export function handleHubRemoveMember(ws, msg, ctx) {
51
+ const { auth, hubService, sendWs, broadcastToHubAudience } = ctx
52
+ const user = auth.getUser(ws.data.userId)
53
+ const { hub_id, user_id } = msg.body || {}
54
+ const result = hubService.removeHubMember({ hubId: hub_id, targetUserId: user_id, requestingUserId: ws.data.userId, requestingRoles: user?.roles || [] })
55
+ sendWs(ws, { t: 'hub.member_removed', reply_to: msg.id, ok: true, body: result })
56
+ ctx.server?.publish(`user:${user_id}`, JSON.stringify({ v: 1, server_ts: Date.now(), t: 'hub.member_removed', ok: true, body: result }))
57
+ broadcastToHubAudience(hub_id, { t: 'hub.member_removed', ok: true, body: result }, ws)
58
+ }
59
+
60
+ export function handleHubReorder(ws, msg, ctx) {
61
+ const { auth, hubService, sendWs, connections } = ctx
62
+ const user = auth.getUser(ws.data.userId)
63
+ const { hub_ids } = msg.body || {}
64
+ const hubs = hubService.reorderHubs({ hubIds: hub_ids, userId: ws.data.userId, userRoles: user?.roles || [] })
65
+ // Broadcast to all authenticated connections — every sidebar needs to update
66
+ for (const [, conn] of connections) {
67
+ if (conn.data.userId) sendWs(conn, { t: 'hub.reordered', ok: true, body: { hubs } })
68
+ }
69
+ }
70
+
71
+ export function handleHubListMembers(ws, msg, ctx) {
72
+ const { auth, hubService, sendWs } = ctx
73
+ const user = auth.getUser(ws.data.userId)
74
+ const { hub_id } = msg.body || {}
75
+ const members = hubService.listHubMembers({ hubId: hub_id, requestingUserId: ws.data.userId, requestingRoles: user?.roles || [] })
76
+ const enriched = members.map(m => {
77
+ if (m.handle) return m // SqliteHubRepository joins users — already enriched
78
+ const u = auth.getUser(m.user_id)
79
+ return { user_id: m.user_id, handle: u?.handle ?? null, display_name: u?.display_name ?? null, joined_at: m.joined_at }
80
+ })
81
+ sendWs(ws, { t: 'hub.list_members_result', reply_to: msg.id, ok: true, body: { hub_id, members: enriched } })
82
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Message, search, and presence WS handlers.
3
+ */
4
+ import { renderMarkdown } from '@devchitchat/index97/markdown'
5
+
6
+ export function handleMsgSend(ws, msg, ctx) {
7
+ const { messageService, deliveryService, sendWs, publishChannel, dispatchMentions } = ctx
8
+ const { channel_id, text, client_msg_id, priority, attachments } = msg.body || {}
9
+ const result = messageService.sendMessage({
10
+ channelId: channel_id, userId: ws.data.userId, text, clientMsgId: client_msg_id, priority,
11
+ attachments: Array.isArray(attachments) ? attachments : []
12
+ })
13
+
14
+ sendWs(ws, { t: 'msg.ack', reply_to: msg.id, ok: true, body: { msg_id: result.msg_id, seq: result.seq, client_msg_id, priority: result.priority } })
15
+
16
+ publishChannel(channel_id, {
17
+ t: 'msg.event', ok: true,
18
+ body: {
19
+ msg_id: result.msg_id, channel_id, seq: result.seq,
20
+ user_id: ws.data.userId, user_display_name: ws.data.displayName,
21
+ ts: result.ts, text, rendered_text: renderMarkdown(text).html,
22
+ priority: result.priority, attachments: result.attachments ?? []
23
+ }
24
+ })
25
+
26
+ deliveryService.advance({ channelId: channel_id, userId: ws.data.userId, afterSeq: result.seq })
27
+ dispatchMentions({ channelId: channel_id, senderId: ws.data.userId, text, seq: result.seq, priority: result.priority })
28
+ }
29
+
30
+ export function handleMsgEdit(ws, msg, ctx) {
31
+ const { messageService, publishChannel } = ctx
32
+ const { msg_id, channel_id, text } = msg.body ?? {}
33
+ const result = messageService.editMessage({ msgId: msg_id, channelId: channel_id, userId: ws.data.userId, newText: text })
34
+ const renderedText = renderMarkdown(result.text).html
35
+ publishChannel(channel_id, {
36
+ t: 'msg.edited', ok: true,
37
+ body: { msg_id: result.msgId, channel_id: result.channelId, text: result.text, edited_at: result.editedAt, rendered_text: renderedText },
38
+ })
39
+ }
40
+
41
+ export function handleMsgList(ws, msg, ctx) {
42
+ const { messageService, sendWs } = ctx
43
+ const { channel_id, after_seq, before_seq, limit } = msg.body || {}
44
+
45
+ if (before_seq != null) {
46
+ const result = messageService.listMessagesBefore({
47
+ channelId: channel_id,
48
+ userId: ws.data.userId,
49
+ beforeSeq: before_seq,
50
+ limit: limit ?? 50,
51
+ })
52
+ sendWs(ws, { t: 'msg.list_result', reply_to: msg.id, ok: true, body: { ...result, channel_id, direction: 'before' } })
53
+ return
54
+ }
55
+
56
+ const result = messageService.listMessages({ channelId: channel_id, userId: ws.data.userId, afterSeq: after_seq ?? 0, limit: limit ?? 50 })
57
+ sendWs(ws, { t: 'msg.list_result', reply_to: msg.id, ok: true, body: { ...result, channel_id, direction: 'after' } })
58
+ }
59
+
60
+ export function handleMsgDelete(ws, msg, ctx) {
61
+ const { messageService, publishChannel } = ctx
62
+ const { msg_id, channel_id } = msg.body ?? {}
63
+ const result = messageService.deleteMessage({ msgId: msg_id, channelId: channel_id, userId: ws.data.userId })
64
+ publishChannel(channel_id, {
65
+ t: 'msg.deleted', ok: true,
66
+ body: { msg_id: result.msgId, channel_id: result.channelId, seq: result.seq },
67
+ })
68
+ }
69
+
70
+ export function handleSearchQuery(ws, msg, ctx) {
71
+ const { auth, channelService, searchService, sendWs } = ctx
72
+ const { channel_id, q, limit } = msg.body || {}
73
+ const roles = auth.getUser(ws.data.userId)?.roles || []
74
+ if (!channelService.canAccessChannel(channel_id, ws.data.userId, roles)) {
75
+ return sendWs(ws, { t: 'error', reply_to: msg.id, ok: false, body: { code: 'FORBIDDEN', message: 'Access denied' } })
76
+ }
77
+ const hits = searchService.searchMessages({ channelId: channel_id, query: q, limit })
78
+ sendWs(ws, { t: 'search.result', reply_to: msg.id, ok: true, body: { hits } })
79
+ }
80
+
81
+ export function handlePresenceSubscribe(ws, msg, ctx) {
82
+ const { auth, channelService, presenceService, sendWs } = ctx
83
+ const roles = auth.getUser(ws.data.userId)?.roles || []
84
+ const accessibleChannels = channelService.listChannels(ws.data.userId, roles)
85
+ const channelIds = accessibleChannels.map(c => c.channel_id)
86
+ const users = presenceService.listOnlineUsersInChannels(channelIds)
87
+ sendWs(ws, { t: 'presence.snapshot', reply_to: msg.id, ok: true, body: { users } })
88
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Web Push subscription WS handlers.
3
+ */
4
+
5
+ export function handlePushSubscribe(ws, msg, ctx) {
6
+ const { pushRepo, sendWs } = ctx
7
+ const { subscription } = msg.body ?? {}
8
+ const endpoint = subscription?.endpoint
9
+ const p256dh = subscription?.keys?.p256dh
10
+ const auth = subscription?.keys?.auth
11
+
12
+ if (!endpoint || !p256dh || !auth) {
13
+ return sendWs(ws, { t: 'error', reply_to: msg.id, ok: false, body: { code: 'BAD_REQUEST', message: 'Invalid push subscription' } })
14
+ }
15
+
16
+ pushRepo.upsertSubscription({ userId: ws.data.userId, endpoint, p256dh, auth })
17
+ sendWs(ws, { t: 'push.subscribed', reply_to: msg.id, ok: true, body: {} })
18
+ }
19
+
20
+ export function handlePushUnsubscribe(ws, msg, ctx) {
21
+ const { pushRepo, sendWs } = ctx
22
+ const { endpoint } = msg.body ?? {}
23
+ if (endpoint) pushRepo.removeSubscription(endpoint)
24
+ sendWs(ws, { t: 'push.unsubscribed', reply_to: msg.id, ok: true, body: {} })
25
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Reaction WS handlers.
3
+ */
4
+
5
+ export function handleReactionAdd(ws, msg, ctx) {
6
+ const { reactionService, publishChannel } = ctx
7
+ const { msg_id, channel_id, emoji } = msg.body ?? {}
8
+ const reactions = reactionService.addReaction({
9
+ msgId: msg_id, channelId: channel_id, userId: ws.data.userId, emoji,
10
+ })
11
+ publishChannel(channel_id, {
12
+ t: 'reaction.event', ok: true,
13
+ body: { msg_id, channel_id, emoji, user_id: ws.data.userId, action: 'add', reactions },
14
+ })
15
+ }
16
+
17
+ export function handleReactionRemove(ws, msg, ctx) {
18
+ const { reactionService, publishChannel } = ctx
19
+ const { msg_id, channel_id, emoji } = msg.body ?? {}
20
+ const reactions = reactionService.removeReaction({
21
+ msgId: msg_id, channelId: channel_id, userId: ws.data.userId, emoji,
22
+ })
23
+ publishChannel(channel_id, {
24
+ t: 'reaction.event', ok: true,
25
+ body: { msg_id, channel_id, emoji, user_id: ws.data.userId, action: 'remove', reactions },
26
+ })
27
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * WebRTC signaling WS handlers.
3
+ */
4
+
5
+ export function handleRtcCallCreate(ws, msg, ctx) {
6
+ const { auth, channelService, signalingService, sendWs, getIceServers } = ctx
7
+ const { channel_id, kind = 'mesh' } = msg.body || {}
8
+ const roles = auth.getUser(ws.data.userId)?.roles || []
9
+ if (!channelService.canAccessChannel(channel_id, ws.data.userId, roles)) {
10
+ return sendWs(ws, { t: 'error', reply_to: msg.id, ok: false, body: { code: 'FORBIDDEN', message: 'Access denied' } })
11
+ }
12
+ const result = signalingService.createCall({ roomId: channel_id, createdByUserId: ws.data.userId, topology: kind })
13
+ sendWs(ws, { t: 'rtc.call', reply_to: msg.id, ok: true, body: { ...result, channel_id, ice_servers: getIceServers() } })
14
+ }
15
+
16
+ export function handleRtcJoin(ws, msg, ctx) {
17
+ const { auth, channelService, signalingService, sendWs, publishCall, publishChannel, publishCallState, getIceServers, peerConnections } = ctx
18
+ const { call_id } = msg.body || {}
19
+
20
+ // Guard: verify user can access the channel this call belongs to
21
+ const call = signalingService.getCall(call_id)
22
+ if (call) {
23
+ const roles = auth.getUser(ws.data.userId)?.roles || []
24
+ if (!channelService.canAccessChannel(call.room_id, ws.data.userId, roles)) {
25
+ return sendWs(ws, { t: 'error', reply_to: msg.id, ok: false, body: { code: 'FORBIDDEN', message: 'Access denied' } })
26
+ }
27
+ }
28
+
29
+ const result = signalingService.joinOrSwitch({
30
+ callId: call_id,
31
+ userId: ws.data.userId,
32
+ displayName: ws.data.displayName,
33
+ currentPeerId: ws.data.peerId,
34
+ currentCallId: ws.data.callId,
35
+ })
36
+
37
+ if (result.status === 'already_in_call') {
38
+ sendWs(ws, { t: 'rtc.joined', reply_to: msg.id, ok: true, body: { call_id, peer_id: result.peerId, peers: result.peers, ice_servers: getIceServers() } })
39
+ return
40
+ }
41
+
42
+ // Publish transport side effects for previous call departure (status === 'switched')
43
+ if (result.previousLeft) {
44
+ const { call_id: prevCallId, room_id, peerId: prevPeerId, removed, ended } = result.previousLeft
45
+ peerConnections.delete(prevPeerId)
46
+ ws.unsubscribe(`call:${prevCallId}`)
47
+ if (removed) {
48
+ publishCall(prevCallId, { t: 'rtc.peer_event', ok: true, body: { call_id: prevCallId, kind: 'leave', peer: { peer_id: prevPeerId, user_id: ws.data.userId } } })
49
+ }
50
+ if (ended) {
51
+ publishChannel(room_id, { t: 'rtc.call_end', ok: true, body: { call_id: prevCallId, channel_id: room_id } })
52
+ publishCallState(room_id, null, [])
53
+ }
54
+ }
55
+
56
+ ws.data.peerId = result.peerId
57
+ ws.data.callId = call_id
58
+ peerConnections.set(result.peerId, ws.data.connectionId)
59
+ ws.subscribe(`call:${call_id}`)
60
+ sendWs(ws, { t: 'rtc.joined', reply_to: msg.id, ok: true, body: { call_id, peer_id: result.peerId, peers: result.peers, ice_servers: getIceServers() } })
61
+ publishCall(call_id, { t: 'rtc.peer_event', ok: true, body: { call_id, kind: 'join', peer: { peer_id: result.peerId, user_id: ws.data.userId, display_name: ws.data.displayName } } })
62
+
63
+ const updatedCall = signalingService.getCall(call_id)
64
+ if (updatedCall) publishCallState(updatedCall.room_id, call_id, Array.from(updatedCall.peers.values()))
65
+ }
66
+
67
+ export function handleRtcOffer(ws, msg, ctx) {
68
+ const { signalingService } = ctx
69
+ const { call_id, to_peer_id, sdp } = msg.body || {}
70
+ if (!ws.data.peerId || ws.data.callId !== call_id) return
71
+ signalingService.routeOffer({ callId: call_id, fromPeerId: ws.data.peerId, toPeerId: to_peer_id, sdp })
72
+ }
73
+
74
+ export function handleRtcAnswer(ws, msg, ctx) {
75
+ const { signalingService } = ctx
76
+ const { call_id, to_peer_id, sdp } = msg.body || {}
77
+ if (!ws.data.peerId || ws.data.callId !== call_id) return
78
+ signalingService.routeAnswer({ callId: call_id, fromPeerId: ws.data.peerId, toPeerId: to_peer_id, sdp })
79
+ }
80
+
81
+ export function handleRtcIce(ws, msg, ctx) {
82
+ const { signalingService } = ctx
83
+ const { call_id, to_peer_id, candidate } = msg.body || {}
84
+ if (!ws.data.peerId || ws.data.callId !== call_id) return
85
+ signalingService.routeIce({ callId: call_id, fromPeerId: ws.data.peerId, toPeerId: to_peer_id, candidate })
86
+ }
87
+
88
+ export function handleRtcStreamPublish(ws, msg, ctx) {
89
+ const { publishCall } = ctx
90
+ const { call_id, stream } = msg.body || {}
91
+ publishCall(call_id, { t: 'rtc.stream_event', ok: true, body: { call_id, peer_id: ws.data.peerId, stream } })
92
+ }
93
+
94
+ export function handleRtcLeave(ws, msg, ctx) {
95
+ const { signalingService, sendWs, publishCall, publishChannel, publishCallState, peerConnections } = ctx
96
+ const { call_id } = msg.body || {}
97
+ if (!ws.data.peerId) return
98
+ const leavingPeerId = ws.data.peerId
99
+ const result = signalingService.leaveCall({ callId: call_id, peerId: leavingPeerId })
100
+ ws.unsubscribe(`call:${call_id}`)
101
+ peerConnections.delete(leavingPeerId)
102
+ ws.data.peerId = null
103
+ ws.data.callId = null
104
+ sendWs(ws, { t: 'rtc.left', reply_to: msg.id, ok: true, body: { call_id } })
105
+ if (result.removed) {
106
+ publishCall(call_id, { t: 'rtc.peer_event', ok: true, body: { call_id, kind: 'leave', peer: { peer_id: leavingPeerId, user_id: ws.data.userId } } })
107
+ }
108
+ if (result.ended && result.room_id) {
109
+ publishChannel(result.room_id, { t: 'rtc.call_end', ok: true, body: { call_id, channel_id: result.room_id } })
110
+ publishCallState(result.room_id, null, [])
111
+ } else if (result.removed && result.room_id) {
112
+ const call = signalingService.getCall(call_id)
113
+ publishCallState(result.room_id, call_id, call ? Array.from(call.peers.values()) : [])
114
+ }
115
+ }
116
+
117
+ export function handleRtcEndCall(ws, msg, ctx) {
118
+ const { signalingService, sendWs, publishCall, publishChannel, publishCallState } = ctx
119
+ const { call_id } = msg.body || {}
120
+ const result = signalingService.endCall({ callId: call_id })
121
+ if (!result) return
122
+ publishCall(call_id, { t: 'rtc.call_end', ok: true, body: { call_id, channel_id: result.room_id } })
123
+ publishChannel(result.room_id, { t: 'rtc.call_end', ok: true, body: { call_id, channel_id: result.room_id } })
124
+ publishCallState(result.room_id, null, [])
125
+ sendWs(ws, { t: 'rtc.call_ended', reply_to: msg.id, ok: true, body: { call_id } })
126
+ }