@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.
- package/README.md +313 -0
- package/index.js +148 -0
- package/migrate/001-drop-channel-invites.js +3 -0
- package/migrate/002-invite-initial-roles.js +5 -0
- package/migrate/003-dm-channels.js +35 -0
- package/migrate/004-notifications.js +15 -0
- package/migrate/005-uploads.js +21 -0
- package/migrate/006-mention-priority.js +3 -0
- package/migrate/007-push-subscriptions.js +14 -0
- package/migrate/008-messages-channel-seq-index.js +3 -0
- package/migrate/009-message-reactions.js +15 -0
- package/migrate/010-edit-messages.js +7 -0
- package/package.json +51 -0
- package/pages/_error.html +12 -0
- package/pages/_layout.html +31 -0
- package/pages/_layout.js +13 -0
- package/pages/admin/_layout.html +52 -0
- package/pages/admin/_layout.js +8 -0
- package/pages/admin/bots/[userId].js +88 -0
- package/pages/admin/bots/[userId].phtml +89 -0
- package/pages/admin/bots/index.js +41 -0
- package/pages/admin/bots/index.phtml +58 -0
- package/pages/admin/index.js +8 -0
- package/pages/admin/invites/index.js +72 -0
- package/pages/admin/invites/index.phtml +88 -0
- package/pages/admin/users/[userId].js +60 -0
- package/pages/admin/users/[userId].phtml +57 -0
- package/pages/admin/users/index.js +20 -0
- package/pages/admin/users/index.phtml +37 -0
- package/pages/api/uploads/index.js +66 -0
- package/pages/api/user/settings.js +26 -0
- package/pages/auth/signout.js +14 -0
- package/pages/channels/[channelId].js +99 -0
- package/pages/channels/[channelId].phtml +173 -0
- package/pages/index.js +33 -0
- package/pages/invite/[token].js +10 -0
- package/pages/login/index.js +57 -0
- package/pages/login/index.phtml +29 -0
- package/pages/public/client/action-sheet.js +77 -0
- package/pages/public/client/app.js +38 -0
- package/pages/public/client/auth-tabs.js +13 -0
- package/pages/public/client/emoji-data.js +197 -0
- package/pages/public/client/islands/call.js +1770 -0
- package/pages/public/client/islands/sidebar.js +1197 -0
- package/pages/public/client/long-press.js +59 -0
- package/pages/public/client/modal.js +50 -0
- package/pages/public/client/router.js +87 -0
- package/pages/public/client/rtc-peer-manager.js +344 -0
- package/pages/public/client/settings-sync.js +76 -0
- package/pages/public/client/shared/messages.js +147 -0
- package/pages/public/client/swipe-nav.js +98 -0
- package/pages/public/client/theme.js +27 -0
- package/pages/public/client/ws.js +71 -0
- package/pages/public/favicon.ico +0 -0
- package/pages/public/favicon.png +0 -0
- package/pages/public/icon.png +0 -0
- package/pages/public/manifest.json +11 -0
- package/pages/public/sw.js +38 -0
- package/pages/public/themes/base.css +1786 -0
- package/pages/public/themes/dark.css +22 -0
- package/pages/public/themes/forest.css +22 -0
- package/pages/public/themes/light.css +23 -0
- package/pages/public/themes/ocean.css +22 -0
- package/pages/public/themes/rose.css +22 -0
- package/pages/registration/index.js +35 -0
- package/pages/registration/index.phtml +38 -0
- package/pages/uploads/[uploadId]/[filename].js +45 -0
- package/src/adapters/InMemoryAuthRepository.js +74 -0
- package/src/adapters/InMemoryChannelRepository.js +138 -0
- package/src/adapters/InMemoryDeliveryRepository.js +52 -0
- package/src/adapters/InMemoryFileStore.js +53 -0
- package/src/adapters/InMemoryHubRepository.js +85 -0
- package/src/adapters/InMemoryMessageRepository.js +35 -0
- package/src/adapters/InMemoryReactionRepository.js +45 -0
- package/src/adapters/InMemorySearchRepository.js +37 -0
- package/src/adapters/InMemorySignalingRepository.js +35 -0
- package/src/adapters/InMemoryUploadRepository.js +36 -0
- package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
- package/src/adapters/LocalFileStore.js +40 -0
- package/src/adapters/SqliteAuthRepository.js +184 -0
- package/src/adapters/SqliteChannelRepository.js +149 -0
- package/src/adapters/SqliteDeliveryRepository.js +53 -0
- package/src/adapters/SqliteHubRepository.js +99 -0
- package/src/adapters/SqliteMessageRepository.js +90 -0
- package/src/adapters/SqlitePushRepository.js +39 -0
- package/src/adapters/SqliteReactionRepository.js +50 -0
- package/src/adapters/SqliteSearchRepository.js +42 -0
- package/src/adapters/SqliteSignalingRepository.js +50 -0
- package/src/adapters/SqliteUploadRepository.js +34 -0
- package/src/adapters/SqliteUserSettingsRepository.js +23 -0
- package/src/adminAuth.js +25 -0
- package/src/config.js +11 -0
- package/src/context.js +77 -0
- package/src/core/dm.js +10 -0
- package/src/core/mentions.js +27 -0
- package/src/core/messages.js +21 -0
- package/src/core/reactions.js +6 -0
- package/src/core/roles.js +5 -0
- package/src/core/uploads.js +107 -0
- package/src/db/initDb.js +225 -0
- package/src/db/openDb.js +18 -0
- package/src/db/runMigrations.js +45 -0
- package/src/db/transaction.js +11 -0
- package/src/ports/IFileStore.js +34 -0
- package/src/services/AuthService.js +208 -0
- package/src/services/BotService.js +148 -0
- package/src/services/ChannelService.js +176 -0
- package/src/services/DeliveryService.js +28 -0
- package/src/services/HubService.js +133 -0
- package/src/services/MessageService.js +122 -0
- package/src/services/NotificationService.js +45 -0
- package/src/services/PresenceService.js +55 -0
- package/src/services/ReactionService.js +57 -0
- package/src/services/SearchService.js +21 -0
- package/src/services/SignalingService.js +177 -0
- package/src/services/UploadService.js +111 -0
- package/src/services/UserSettingsService.js +30 -0
- package/src/services/WebPushService.js +217 -0
- package/src/util/crypto.js +21 -0
- package/src/util/errors.js +14 -0
- package/src/util/ids.js +3 -0
- package/src/util/logger.js +21 -0
- package/src/ws/ChatServer.js +478 -0
- package/src/ws/handlers/authHandlers.js +152 -0
- package/src/ws/handlers/channelHandlers.js +166 -0
- package/src/ws/handlers/hubHandlers.js +82 -0
- package/src/ws/handlers/messageHandlers.js +88 -0
- package/src/ws/handlers/pushHandlers.js +25 -0
- package/src/ws/handlers/reactionHandlers.js +27 -0
- package/src/ws/handlers/rtcHandlers.js +126 -0
- 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
|
+
}
|
package/src/util/ids.js
ADDED
|
@@ -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
|
+
}
|