@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,21 @@
1
+ import { randomBytes, createHash } from 'node:crypto'
2
+ import { promisify } from 'node:util'
3
+ import { scrypt as _scrypt } from 'node:crypto'
4
+
5
+ const scrypt = promisify(_scrypt)
6
+
7
+ export const randomToken = (bytes = 24) => randomBytes(bytes).toString('base64url')
8
+
9
+ export const hashToken = (token) => createHash('sha256').update(token).digest('hex')
10
+
11
+ export const hashPassword = async (password) => {
12
+ const salt = randomBytes(16).toString('hex')
13
+ const buf = await scrypt(password, salt, 64)
14
+ return `${salt}:${buf.toString('hex')}`
15
+ }
16
+
17
+ export const verifyPassword = async (password, hash) => {
18
+ const [salt, key] = hash.split(':')
19
+ const buf = await scrypt(password, salt, 64)
20
+ return buf.toString('hex') === key
21
+ }
@@ -0,0 +1,14 @@
1
+ export class ServiceError extends Error {
2
+ constructor(code, message, details = null) {
3
+ super(message)
4
+ this.code = code
5
+ this.details = details
6
+ }
7
+ }
8
+
9
+ const HTTP_STATUS = { FORBIDDEN: 403, NOT_FOUND: 404, BAD_REQUEST: 400, CONFLICT: 409, UNAUTHORIZED: 401 }
10
+
11
+ /** Map a ServiceError code to an HTTP status code. Defaults to 500. */
12
+ export function httpStatus(err) {
13
+ return HTTP_STATUS[err?.code] ?? 500
14
+ }
@@ -0,0 +1,3 @@
1
+ import { randomUUIDv7 } from 'bun'
2
+
3
+ export const newId = (prefix) => `${prefix}_${randomUUIDv7()}`
@@ -0,0 +1,21 @@
1
+ const redact = (value) => {
2
+ if (!value || typeof value !== 'object') return value
3
+ const clone = Array.isArray(value) ? [...value] : { ...value }
4
+ for (const key of Object.keys(clone)) {
5
+ if (key.toLowerCase().includes('token') || key.toLowerCase().includes('password')) {
6
+ clone[key] = '[redacted]'
7
+ }
8
+ }
9
+ return clone
10
+ }
11
+
12
+ export const createLogger = () => {
13
+ const write = (level, event, data) => {
14
+ console.log(JSON.stringify({ level, event, ts: Date.now(), data: redact(data) }))
15
+ }
16
+ return {
17
+ info: (event, data) => write('info', event, data),
18
+ warn: (event, data) => write('warn', event, data),
19
+ error: (event, data) => write('error', event, data)
20
+ }
21
+ }
@@ -0,0 +1,478 @@
1
+ import { ServiceError } from '../util/errors.js'
2
+ import { newId } from '../util/ids.js'
3
+ import { randomToken } from '../util/crypto.js'
4
+ import { AuthService } from '../services/AuthService.js'
5
+ import { HubService } from '../services/HubService.js'
6
+ import { ChannelService } from '../services/ChannelService.js'
7
+ import { MessageService } from '../services/MessageService.js'
8
+ import { DeliveryService } from '../services/DeliveryService.js'
9
+ import { NotificationService } from '../services/NotificationService.js'
10
+ import { SearchService } from '../services/SearchService.js'
11
+ import { PresenceService } from '../services/PresenceService.js'
12
+ import { SignalingService } from '../services/SignalingService.js'
13
+ import { BotService } from '../services/BotService.js'
14
+ import { parseMentions } from '../core/mentions.js'
15
+ import { SqliteAuthRepository } from '../adapters/SqliteAuthRepository.js'
16
+ import { SqliteHubRepository } from '../adapters/SqliteHubRepository.js'
17
+ import { SqliteChannelRepository } from '../adapters/SqliteChannelRepository.js'
18
+ import { SqliteMessageRepository } from '../adapters/SqliteMessageRepository.js'
19
+ import { SqliteDeliveryRepository } from '../adapters/SqliteDeliveryRepository.js'
20
+ import { SqliteSearchRepository } from '../adapters/SqliteSearchRepository.js'
21
+ import { SqliteSignalingRepository } from '../adapters/SqliteSignalingRepository.js'
22
+ import { handleHello, handleInviteRedeem, handleSignIn, handleSignOut, handleAdminInviteCreate, handleAdminInviteList, handleAdminInviteRevoke, handleAdminUserList, handleAdminUserSetRoles, handleAdminUserSetPassword, handleAdminUserSetDisplayName, handleAdminBotCreate, handleAdminBotList, handleAdminBotTokenCreate, handleAdminBotTokenRevoke, handleAdminBotSetChannels } from './handlers/authHandlers.js'
23
+ import { handleHubList, handleHubCreate, handleHubUpdate, handleHubDelete, handleHubAddMember, handleHubRemoveMember, handleHubListMembers, handleHubReorder } from './handlers/hubHandlers.js'
24
+ import { handleChannelList, handleChannelCreate, handleChannelUpdate, handleChannelDelete, handleChannelJoin, handleChannelLeave, handleChannelReorder, handleChannelAddMember, handleChannelRemoveMember, handleChannelListMembers, handleUserList, handleBotList, handleDmOpen, handleDmList } from './handlers/channelHandlers.js'
25
+ import { handleMsgSend, handleMsgList, handleMsgEdit, handleMsgDelete, handleSearchQuery, handlePresenceSubscribe } from './handlers/messageHandlers.js'
26
+ import { handleRtcCallCreate, handleRtcJoin, handleRtcOffer, handleRtcAnswer, handleRtcIce, handleRtcStreamPublish, handleRtcLeave, handleRtcEndCall } from './handlers/rtcHandlers.js'
27
+ import { handlePushSubscribe, handlePushUnsubscribe } from './handlers/pushHandlers.js'
28
+ import { handleReactionAdd, handleReactionRemove } from './handlers/reactionHandlers.js'
29
+ import { WebPushService } from '../services/WebPushService.js'
30
+ import { SqlitePushRepository } from '../adapters/SqlitePushRepository.js'
31
+ import { SqliteReactionRepository } from '../adapters/SqliteReactionRepository.js'
32
+ import { ReactionService } from '../services/ReactionService.js'
33
+
34
+ /**
35
+ * ChatServer — Bun native WebSocket implementation.
36
+ *
37
+ * Responsibilities:
38
+ * 1. Composition root — instantiate repos and services
39
+ * 2. WebSocket lifecycle — open, message, close
40
+ * 3. Message routing — delegate to domain handler modules
41
+ * 4. Shared helpers — sendWs, publish*, broadcast*, subscribeUserToChannel,
42
+ * sendDigest, dispatchMentions, getIceServers, attachUser
43
+ *
44
+ * Connection state lives in ws.data (set during upgrade):
45
+ * { connectionId, userId, sessionId, displayName, peerId, callId }
46
+ *
47
+ * Broadcasting uses Bun's topic pub/sub:
48
+ * ws.subscribe('channel:<id>') — channel message delivery
49
+ * ws.subscribe('call:<id>') — RTC signaling delivery
50
+ * ws.subscribe('user:<id>') — direct user delivery
51
+ *
52
+ * The `websocket` property is passed directly to Bun.serve({ websocket }).
53
+ */
54
+ export class ChatServer {
55
+ constructor({ db, logger }) {
56
+ this.db = db
57
+ this.logger = logger
58
+
59
+ // ── Repositories ───────────────────────────────────────────────────────────
60
+ const authRepo = new SqliteAuthRepository({ db })
61
+ const hubRepo = new SqliteHubRepository({ db })
62
+ const channelRepo = new SqliteChannelRepository({ db })
63
+ const searchRepo = new SqliteSearchRepository({ db })
64
+ const messageRepo = new SqliteMessageRepository({ db })
65
+ const deliveryRepo = new SqliteDeliveryRepository({ db })
66
+ const pushRepo = new SqlitePushRepository({ db })
67
+ const reactionRepo = new SqliteReactionRepository({ db })
68
+
69
+ // ── Services ───────────────────────────────────────────────────────────────
70
+ this.auth = new AuthService({ authRepo, sessionTtlMs: Number(process.env.SESSION_TTL_MS ?? 30 * 24 * 60 * 60 * 1000) })
71
+ this.hubService = new HubService({ hubRepo })
72
+ this.channelService = new ChannelService({ channelRepo, hubService: this.hubService })
73
+ this.searchService = new SearchService({ searchRepo })
74
+ this.reactionService = new ReactionService({ reactionRepo, channelService: this.channelService })
75
+ this.messageService = new MessageService({ messageRepo, channelService: this.channelService, searchService: this.searchService, reactionService: this.reactionService })
76
+ this.deliveryService = new DeliveryService({ deliveryRepo })
77
+ this.notificationService = new NotificationService({ deliveryService: this.deliveryService, authService: this.auth })
78
+ this.presenceService = new PresenceService()
79
+ this.signalingService = new SignalingService({ signalingRepo: new SqliteSignalingRepository({ db }) })
80
+ this.botService = new BotService({ authService: this.auth, authRepo, channelRepo })
81
+ this.pushService = new WebPushService({
82
+ vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? null,
83
+ vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? null,
84
+ vapidSubject: process.env.VAPID_SUBJECT ?? null,
85
+ pushRepo,
86
+ })
87
+ this.pushRepo = pushRepo
88
+
89
+ // ── Connection state ───────────────────────────────────────────────────────
90
+ this.connections = new Map() // connectionId → ws
91
+ this.peerConnections = new Map() // peerId → connectionId
92
+
93
+ this.signalingService.onEvent(event => this.#handleSignalingEvent(event))
94
+
95
+ this.#ensureBootstrap()
96
+
97
+ // Expose as a plain object for Bun.serve({ websocket })
98
+ this.websocket = {
99
+ open: (ws) => this.#open(ws),
100
+ message: (ws, data) => this.#message(ws, data),
101
+ close: (ws) => this.#close(ws),
102
+ }
103
+ }
104
+
105
+ // ── Bun WebSocket lifecycle ────────────────────────────────────────────────
106
+
107
+ #open(ws) {
108
+ const connectionId = newId('conn')
109
+ const userId = ws.data?.userId ?? null
110
+ const sessionId = ws.data?.sessionId ?? null
111
+ const displayName = ws.data?.displayName ?? null
112
+ ws.data = { connectionId, userId, sessionId, displayName, peerId: null, callId: null }
113
+ if (userId) {
114
+ ws.subscribe(`user:${userId}`)
115
+ this.presenceService.addConnection(connectionId, userId)
116
+ }
117
+ this.connections.set(connectionId, ws)
118
+ }
119
+
120
+ async #message(ws, data) {
121
+ let msg
122
+ try {
123
+ msg = JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data))
124
+ } catch {
125
+ this.#sendWs(ws, { t: 'error', ok: false, body: { code: 'BAD_REQUEST', message: 'Invalid JSON' } })
126
+ return
127
+ }
128
+
129
+ if (!this.#isValidEnvelope(msg)) {
130
+ this.#sendWs(ws, { t: 'error', ok: false, reply_to: msg?.id, body: { code: 'BAD_REQUEST', message: 'Invalid message envelope' } })
131
+ return
132
+ }
133
+
134
+ const isAuthed = !!ws.data.userId
135
+ if (!isAuthed && !['hello', 'auth.invite_redeem', 'auth.signin'].includes(msg.t)) {
136
+ this.#sendWs(ws, { t: 'error', ok: false, reply_to: msg.id, body: { code: 'AUTH_REQUIRED', message: 'Authenticate first' } })
137
+ return
138
+ }
139
+
140
+ try {
141
+ await this.#route(ws, msg)
142
+ } catch (err) {
143
+ if (err instanceof ServiceError) {
144
+ this.#sendWs(ws, { t: 'error', ok: false, reply_to: msg.id, body: { code: err.code, message: err.message } })
145
+ } else {
146
+ this.logger.error('ws.handle_error', { error: err?.message })
147
+ this.#sendWs(ws, { t: 'error', ok: false, reply_to: msg.id, body: { code: 'INTERNAL', message: 'Internal error' } })
148
+ }
149
+ }
150
+ }
151
+
152
+ #close(ws) {
153
+ const { connectionId, userId, peerId, callId } = ws.data
154
+ if (peerId && callId) {
155
+ this.peerConnections.delete(peerId)
156
+ const result = this.signalingService.leaveCall({ callId, peerId })
157
+ if (result.removed) {
158
+ this.#publishCall(callId, { t: 'rtc.peer_event', ok: true, body: { call_id: callId, kind: 'leave', peer: { peer_id: peerId, user_id: userId } } })
159
+ }
160
+ if (result.ended && result.room_id) {
161
+ this.#publishChannel(result.room_id, { t: 'rtc.call_end', ok: true, body: { call_id: callId, channel_id: result.room_id } })
162
+ this.#publishCallState(result.room_id, null, [])
163
+ } else if (result.removed && result.room_id) {
164
+ const call = this.signalingService.getCall(callId)
165
+ this.#publishCallState(result.room_id, callId, call ? Array.from(call.peers.values()) : [])
166
+ }
167
+ }
168
+ if (userId) this.presenceService.removeConnection(connectionId, userId)
169
+ this.connections.delete(connectionId)
170
+ }
171
+
172
+ // ── Message router ─────────────────────────────────────────────────────────
173
+
174
+ async #route(ws, msg) {
175
+ const ctx = this.#ctx()
176
+ switch (msg.t) {
177
+ // Auth
178
+ case 'hello': return handleHello(ws, msg, ctx)
179
+ case 'auth.invite_redeem': return handleInviteRedeem(ws, msg, ctx)
180
+ case 'auth.signin': return handleSignIn(ws, msg, ctx)
181
+ case 'auth.signout': return handleSignOut(ws, msg, ctx)
182
+ // Admin — invites
183
+ case 'admin.invite_create': return handleAdminInviteCreate(ws, msg, ctx)
184
+ case 'admin.invite_list': return handleAdminInviteList(ws, msg, ctx)
185
+ case 'admin.invite_revoke': return handleAdminInviteRevoke(ws, msg, ctx)
186
+ // Admin — users
187
+ case 'admin.user_list': return handleAdminUserList(ws, msg, ctx)
188
+ case 'admin.user_set_roles': return handleAdminUserSetRoles(ws, msg, ctx)
189
+ case 'admin.user_set_password': return handleAdminUserSetPassword(ws, msg, ctx)
190
+ case 'admin.user_set_display_name': return handleAdminUserSetDisplayName(ws, msg, ctx)
191
+ // Admin — bots
192
+ case 'admin.bot_create': return handleAdminBotCreate(ws, msg, ctx)
193
+ case 'admin.bot_list': return handleAdminBotList(ws, msg, ctx)
194
+ case 'admin.bot_token_create': return handleAdminBotTokenCreate(ws, msg, ctx)
195
+ case 'admin.bot_token_revoke': return handleAdminBotTokenRevoke(ws, msg, ctx)
196
+ case 'admin.bot_set_channels': return handleAdminBotSetChannels(ws, msg, ctx)
197
+ // Hubs
198
+ case 'hub.list': return handleHubList(ws, msg, ctx)
199
+ case 'hub.create': return handleHubCreate(ws, msg, ctx)
200
+ case 'hub.update': return handleHubUpdate(ws, msg, ctx)
201
+ case 'hub.delete': return handleHubDelete(ws, msg, ctx)
202
+ case 'hub.add_member': return handleHubAddMember(ws, msg, ctx)
203
+ case 'hub.remove_member': return handleHubRemoveMember(ws, msg, ctx)
204
+ case 'hub.list_members': return handleHubListMembers(ws, msg, ctx)
205
+ case 'hub.reorder': return handleHubReorder(ws, msg, ctx)
206
+ // Channels
207
+ case 'channel.list': return handleChannelList(ws, msg, ctx)
208
+ case 'channel.create': return handleChannelCreate(ws, msg, ctx)
209
+ case 'channel.update': return handleChannelUpdate(ws, msg, ctx)
210
+ case 'channel.delete': return handleChannelDelete(ws, msg, ctx)
211
+ case 'channel.reorder': return handleChannelReorder(ws, msg, ctx)
212
+ case 'channel.join': return handleChannelJoin(ws, msg, ctx)
213
+ case 'channel.leave': return handleChannelLeave(ws, msg, ctx)
214
+ case 'channel.add_member': return handleChannelAddMember(ws, msg, ctx)
215
+ case 'channel.remove_member': return handleChannelRemoveMember(ws, msg, ctx)
216
+ case 'channel.list_members': return handleChannelListMembers(ws, msg, ctx)
217
+ // Users & DMs
218
+ case 'user.list': return handleUserList(ws, msg, ctx)
219
+ case 'bot.list': return handleBotList(ws, msg, ctx)
220
+ case 'dm.open': return handleDmOpen(ws, msg, ctx)
221
+ case 'dm.list': return handleDmList(ws, msg, ctx)
222
+ // Messages
223
+ case 'msg.send': return handleMsgSend(ws, msg, ctx)
224
+ case 'msg.edit': return handleMsgEdit(ws, msg, ctx)
225
+ case 'msg.delete': return handleMsgDelete(ws, msg, ctx)
226
+ case 'msg.list': return handleMsgList(ws, msg, ctx)
227
+ case 'search.query': return handleSearchQuery(ws, msg, ctx)
228
+ case 'presence.subscribe': return handlePresenceSubscribe(ws, msg, ctx)
229
+ // RTC
230
+ case 'rtc.call_create': return handleRtcCallCreate(ws, msg, ctx)
231
+ case 'rtc.join': return handleRtcJoin(ws, msg, ctx)
232
+ case 'rtc.offer': return handleRtcOffer(ws, msg, ctx)
233
+ case 'rtc.answer': return handleRtcAnswer(ws, msg, ctx)
234
+ case 'rtc.ice': return handleRtcIce(ws, msg, ctx)
235
+ case 'rtc.stream_publish': return handleRtcStreamPublish(ws, msg, ctx)
236
+ case 'rtc.leave': return handleRtcLeave(ws, msg, ctx)
237
+ case 'rtc.end_call': return handleRtcEndCall(ws, msg, ctx)
238
+ // Web Push
239
+ case 'push.subscribe': return handlePushSubscribe(ws, msg, ctx)
240
+ case 'push.unsubscribe': return handlePushUnsubscribe(ws, msg, ctx)
241
+ // Reactions
242
+ case 'reaction.add': return handleReactionAdd(ws, msg, ctx)
243
+ case 'reaction.remove': return handleReactionRemove(ws, msg, ctx)
244
+ default:
245
+ this.#sendWs(ws, { t: 'error', ok: false, reply_to: msg.id, body: { code: 'BAD_REQUEST', message: 'Unknown message type' } })
246
+ }
247
+ }
248
+
249
+ // ── Shared context passed to all handlers ──────────────────────────────────
250
+
251
+ #ctx() {
252
+ const self = this
253
+ return {
254
+ // Services
255
+ auth: this.auth,
256
+ hubService: this.hubService,
257
+ channelService: this.channelService,
258
+ messageService: this.messageService,
259
+ deliveryService: this.deliveryService,
260
+ searchService: this.searchService,
261
+ presenceService: this.presenceService,
262
+ signalingService: this.signalingService,
263
+ notificationService: this.notificationService,
264
+ botService: this.botService,
265
+ pushService: this.pushService,
266
+ pushRepo: this.pushRepo,
267
+ reactionService: this.reactionService,
268
+ // Connection state (mutable references)
269
+ connections: this.connections,
270
+ peerConnections: this.peerConnections,
271
+ get server() { return self.server },
272
+ // Bound helpers
273
+ sendWs: (ws, p) => this.#sendWs(ws, p),
274
+ publishChannel: (channelId, p) => this.#publishChannel(channelId, p),
275
+ publishCall: (callId, p) => this.#publishCall(callId, p),
276
+ publishCallState: (chId, callId, ps) => this.#publishCallState(chId, callId, ps),
277
+ broadcastToHubAudience: (hubId, p, ex) => this.#broadcastToHubAudience(hubId, p, ex),
278
+ broadcastToChannelAudience:(chId, p, ex) => this.#broadcastToChannelAudience(chId, p, ex),
279
+ collectHubAudience: (hubId, ex) => this.#collectHubAudience(hubId, ex),
280
+ collectChannelAudience: (chId, ex) => this.#collectChannelAudience(chId, ex),
281
+ subscribeUserToChannel: (userId, chId) => this.#subscribeUserToChannel(userId, chId),
282
+ sendDigest: (ws, uid, ts) => this.#sendDigest(ws, uid, ts),
283
+ dispatchMentions: (args) => this.#dispatchMentions(args),
284
+ getIceServers: () => this.#getIceServers(),
285
+ attachUser: (ws, user, sid) => this.#attachUser(ws, user, sid),
286
+ }
287
+ }
288
+
289
+ // ── Signaling event (SignalingService emitter → specific peer) ─────────────
290
+
291
+ #handleSignalingEvent(event) {
292
+ const toPeerId = event.body?.to_peer_id
293
+ if (!toPeerId) return
294
+ const connectionId = this.peerConnections.get(toPeerId)
295
+ if (!connectionId) return
296
+ const ws = this.connections.get(connectionId)
297
+ if (ws) this.#sendWs(ws, event)
298
+ }
299
+
300
+ // ── Notification helpers ───────────────────────────────────────────────────
301
+
302
+ #sendDigest(ws, userId, lastSeenAt) {
303
+ try {
304
+ const digest = this.notificationService.buildDigest(userId, lastSeenAt)
305
+ if (digest.channels.length > 0 || digest.dms.length > 0) {
306
+ this.#sendWs(ws, { t: 'notification.digest', ok: true, body: digest })
307
+ }
308
+ } catch { /* digest is best-effort */ }
309
+ }
310
+
311
+ #dispatchMentions({ channelId, senderId, text, seq, priority = 'normal' }) {
312
+ // For public channels any user on the instance is mentionable (they can see the channel).
313
+ // For private channels only explicit members can be mentioned.
314
+ const channel = this.channelService.getChannel(channelId)
315
+ let candidates
316
+ if (channel?.visibility === 'private' || channel?.kind === 'dm') {
317
+ const rawMembers = this.channelService.listChannelMembers(channelId)
318
+ candidates = rawMembers
319
+ .filter(m => m.user_id !== senderId)
320
+ .map(m => {
321
+ const u = this.auth.getUser(m.user_id)
322
+ return u ? { user_id: u.user_id, handle: u.handle } : null
323
+ })
324
+ .filter(Boolean)
325
+ } else {
326
+ candidates = this.auth.listUsersBasic()
327
+ .filter(u => u.user_id !== senderId && !u.roles.includes('bot'))
328
+ .map(u => ({ user_id: u.user_id, handle: u.handle }))
329
+ }
330
+
331
+ const mentioned = parseMentions(text, candidates)
332
+ for (const { user_id } of mentioned) {
333
+ this.deliveryService.advanceMention({ channelId, userId: user_id, mentionSeq: seq, priority })
334
+ this.server?.publish(`user:${user_id}`, JSON.stringify({
335
+ v: 1, server_ts: Date.now(), t: 'notification.mention', ok: true,
336
+ body: { channel_id: channelId, seq, from_user_id: senderId, priority }
337
+ }))
338
+ if (priority === 'now' && this.pushService.isConfigured()) {
339
+ const sender = this.auth.getUser(senderId)
340
+ const channel = this.channelService.getChannel(channelId)
341
+ this.pushService.sendToUser({
342
+ userId: user_id,
343
+ title: `@${sender?.handle ?? 'someone'} mentioned you`,
344
+ body: text.slice(0, 120),
345
+ url: `/channels/${channelId}`,
346
+ channelId,
347
+ }).catch(err => this.logger.error('push.send_error', { error: err?.message }))
348
+ }
349
+ }
350
+ }
351
+
352
+ // ── Broadcasting helpers ───────────────────────────────────────────────────
353
+
354
+ #publishChannel(channelId, payload) {
355
+ this.server?.publish(`channel:${channelId}`, JSON.stringify(payload))
356
+ }
357
+
358
+ #publishCall(callId, payload) {
359
+ this.server?.publish(`call:${callId}`, JSON.stringify(payload))
360
+ }
361
+
362
+ #publishCallState(channelId, callId, peers) {
363
+ this.#publishChannel(channelId, {
364
+ t: 'rtc.call_state', ok: true,
365
+ body: { channel_id: channelId, call_id: callId, count: peers.length, users: peers.map(p => ({ user_id: p.user_id })) }
366
+ })
367
+ }
368
+
369
+ #collectHubAudience(hubId, excludeWs = null) {
370
+ const audience = []
371
+ const hub = this.hubService.getHub(hubId)
372
+ if (!hub || hub.deleted_at) return audience
373
+ const rolesCache = new Map()
374
+ for (const [, ws] of this.connections) {
375
+ if (!ws.data.userId || ws === excludeWs) continue
376
+ if (!rolesCache.has(ws.data.userId)) {
377
+ rolesCache.set(ws.data.userId, this.auth.getUser(ws.data.userId)?.roles || [])
378
+ }
379
+ if (this.hubService.canAccessHub(hubId, ws.data.userId, rolesCache.get(ws.data.userId))) {
380
+ audience.push(ws)
381
+ }
382
+ }
383
+ return audience
384
+ }
385
+
386
+ #collectChannelAudience(channelId, excludeWs = null) {
387
+ const audience = []
388
+ const rolesCache = new Map()
389
+ for (const [, ws] of this.connections) {
390
+ if (!ws.data.userId || ws === excludeWs) continue
391
+ if (!rolesCache.has(ws.data.userId)) {
392
+ rolesCache.set(ws.data.userId, this.auth.getUser(ws.data.userId)?.roles || [])
393
+ }
394
+ if (this.channelService.canAccessChannel(channelId, ws.data.userId, rolesCache.get(ws.data.userId))) {
395
+ audience.push(ws)
396
+ }
397
+ }
398
+ return audience
399
+ }
400
+
401
+ #broadcastToHubAudience(hubId, payload, excludeWs = null) {
402
+ const hub = this.hubService.getHub(hubId)
403
+ if (!hub || hub.deleted_at) return
404
+ const rolesCache = new Map()
405
+ for (const [, ws] of this.connections) {
406
+ if (!ws.data.userId || ws === excludeWs) continue
407
+ if (!rolesCache.has(ws.data.userId)) {
408
+ rolesCache.set(ws.data.userId, this.auth.getUser(ws.data.userId)?.roles || [])
409
+ }
410
+ if (this.hubService.canAccessHub(hubId, ws.data.userId, rolesCache.get(ws.data.userId))) {
411
+ this.#sendWs(ws, payload)
412
+ }
413
+ }
414
+ }
415
+
416
+ #broadcastToChannelAudience(channelId, payload, excludeWs = null) {
417
+ const rolesCache = new Map()
418
+ for (const [, ws] of this.connections) {
419
+ if (!ws.data.userId || ws === excludeWs) continue
420
+ if (!rolesCache.has(ws.data.userId)) {
421
+ rolesCache.set(ws.data.userId, this.auth.getUser(ws.data.userId)?.roles || [])
422
+ }
423
+ if (this.channelService.canAccessChannel(channelId, ws.data.userId, rolesCache.get(ws.data.userId))) {
424
+ this.#sendWs(ws, payload)
425
+ }
426
+ }
427
+ }
428
+
429
+ #subscribeUserToChannel(userId, channelId) {
430
+ const topic = `channel:${channelId}`
431
+ for (const [, conn] of this.connections) {
432
+ if (conn.data.userId === userId) conn.subscribe(topic)
433
+ }
434
+ }
435
+
436
+ // ── Utilities ──────────────────────────────────────────────────────────────
437
+
438
+ #sendWs(ws, payload) {
439
+ try {
440
+ ws.send(JSON.stringify({ v: 1, server_ts: Date.now(), ...payload }))
441
+ } catch { /* ws may be closing */ }
442
+ }
443
+
444
+ #attachUser(ws, user, sessionId) {
445
+ const alreadyAttached = ws.data.userId === user.user_id
446
+ ws.data.userId = user.user_id
447
+ ws.data.sessionId = sessionId
448
+ ws.data.displayName = user.display_name
449
+ if (!alreadyAttached) {
450
+ ws.subscribe(`user:${user.user_id}`)
451
+ this.presenceService.addConnection(ws.data.connectionId, user.user_id)
452
+ }
453
+ }
454
+
455
+ #isValidEnvelope(msg) {
456
+ return msg && msg.v === 1 && typeof msg.t === 'string' && typeof msg.id === 'string' && typeof msg.ts === 'number' && typeof msg.body === 'object'
457
+ }
458
+
459
+ #getIceServers() {
460
+ const servers = [{ urls: process.env.STUN_URLS ?? 'stun:stun.l.google.com:19302' }]
461
+ if (process.env.TURN_URLS) {
462
+ servers.push({ urls: process.env.TURN_URLS, username: process.env.TURN_USERNAME, credential: process.env.TURN_CREDENTIAL })
463
+ }
464
+ return servers
465
+ }
466
+
467
+ #ensureBootstrap() {
468
+ if (this.auth.getUserCount() > 0) return
469
+ const token = process.env.BOOTSTRAP_TOKEN || randomToken(18)
470
+ this.auth.bootstrapToken = token
471
+ this.logger.info('auth.bootstrap_ready', { bootstrap_code: token })
472
+ }
473
+
474
+ /** Called by index.js after Bun.serve() returns, so publish works */
475
+ attachServer(server) {
476
+ this.server = server
477
+ }
478
+ }