-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathchat.ts
466 lines (414 loc) · 17 KB
/
chat.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
/** @file A WebSocket server that sends messages to and from the desktop IDE and cloud. */
import * as http from 'node:http'
import * as ws from 'ws'
import isEmail from 'validator/es/lib/isEmail'
import * as newtype from './newtype'
import * as reactionModule from './reaction'
import * as schema from './schema'
// =================
// === Constants ===
// =================
/** The endpoint from which user data is retrieved. */
const USERS_ME_PATH = 'https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com/users/me'
// ==================
// === Re-exports ===
// ==================
/** All possible emojis that can be used as a reaction on a chat message. */
export type ReactionSymbol = reactionModule.ReactionSymbol
// =======================
// === AWS Cognito API ===
// =======================
/** An email address. */
export type EmailAddress = newtype.Newtype<string, 'EmailAddress'>
/** A user of the application. */
export interface User {
id: string
name: string
email: EmailAddress
}
// =====================
// === Message Types ===
// =====================
// Intentionally the same as in `database.ts`; this one is intended to be copied to the frontend.
export type ThreadId = newtype.Newtype<string, 'ThreadId'>
export type MessageId = newtype.Newtype<string, 'MessageId'>
export enum ChatMessageDataType {
// Messages internal to the server.
/** Like the `authenticate` message, but with user details. */
internalAuthenticate = 'internal-authenticate',
/** Like the `authenticateAnonymously` message, but with user details. */
internalAuthenticateAnonymously = 'internal-authenticate-anonymously',
// Messages from the server to the client.
/** Metadata for all threads associated with a user. */
serverThreads = 'server-threads',
/** Metadata for the currently open thread. */
serverThread = 'server-thread',
/** A message from the server to the client. */
serverMessage = 'server-message',
/** An edited message from the server to the client. */
serverEditedMessage = 'server-edited-message',
/** A message from the client to the server, sent from the server to the client as part of
* the message history. */
serverReplayedMessage = 'server-replayed-message',
// Messages from the client to the server.
/** The authentication token. */
authenticate = 'authenticate',
/** Sent by a user that is not logged in. This is currently only used on the website. */
authenticateAnonymously = 'authenticate-anonymously',
/** Sent when the user is requesting scrollback history. */
historyBefore = 'history-before',
/** Create a new thread with an initial message. */
newThread = 'new-thread',
/** Rename an existing thread. */
renameThread = 'rename-thread',
/** Change the currently active thread. */
switchThread = 'switch-thread',
/** A message from the client to the server. */
message = 'message',
/** A reaction from the client. */
reaction = 'reaction',
/** Removal of a reaction from the client. */
removeReaction = 'remove-reaction',
/** Mark a message as read. Used to determine whether to show the notification dot
* next to a thread. */
markAsRead = 'mark-as-read',
}
/** Properties common to all WebSocket messages. */
interface ChatBaseMessageData<Type extends ChatMessageDataType> {
type: Type
}
// =========================
// === Internal messages ===
// =========================
/** Sent to the main file with user information. */
export interface ChatInternalAuthenticateMessageData
extends ChatBaseMessageData<ChatMessageDataType.internalAuthenticate> {
userId: schema.UserId
userName: string
}
/** Sent to the main file with user IP. */
export interface ChatInternalAuthenticateAnonymouslyMessageData
extends ChatBaseMessageData<ChatMessageDataType.internalAuthenticateAnonymously> {
userId: schema.UserId
email: schema.EmailAddress
}
export type ChatInternalMessageData =
| ChatInternalAuthenticateAnonymouslyMessageData
| ChatInternalAuthenticateMessageData
// ======================================
// === Messages from server to client ===
// ======================================
/** Basic metadata for a single thread. */
export interface ThreadData {
title: string
id: ThreadId
hasUnreadMessages: boolean
}
/** Basic metadata for a all of a user's threads. */
export interface ChatServerThreadsMessageData
extends ChatBaseMessageData<ChatMessageDataType.serverThreads> {
threads: ThreadData[]
}
/** All possible message types that may trigger a {@link ChatServerThreadMessageData} response. */
export type ChatServerThreadRequestType =
| ChatMessageDataType.authenticate
| ChatMessageDataType.historyBefore
| ChatMessageDataType.newThread
| ChatMessageDataType.switchThread
/** Thread details and recent messages.
* This message is sent every time the user switches threads. */
export interface ChatServerThreadMessageData
extends ChatBaseMessageData<ChatMessageDataType.serverThread> {
/** The type of the message that triggered this response. */
requestType: ChatServerThreadRequestType
title: string
id: ThreadId
/** `true` if there is no more message history before these messages. */
isAtBeginning: boolean
messages: (ChatServerMessageMessageData | ChatServerReplayedMessageMessageData)[]
}
/** A regular chat message from the server to the client. */
export interface ChatServerMessageMessageData
extends ChatBaseMessageData<ChatMessageDataType.serverMessage> {
id: MessageId
// This should not be `null` for staff, as registration is required.
// However, it will be `null` for users that have not yet set an avatar.
authorAvatar: string | null
authorName: string
content: string
reactions: reactionModule.ReactionSymbol[]
/** Milliseconds since the Unix epoch. */
timestamp: number
/** Milliseconds since the Unix epoch.
* Should only be present when receiving message history, because new messages cannot have been
* edited. */
editedTimestamp: number | null
}
/** A regular edited chat message from the server to the client. */
export interface ChatServerEditedMessageMessageData
extends ChatBaseMessageData<ChatMessageDataType.serverEditedMessage> {
id: MessageId
content: string
/** Milliseconds since the Unix epoch. */
timestamp: number
}
/** A replayed message from the client to the server. Includes the timestamp of the message. */
export interface ChatServerReplayedMessageMessageData
extends ChatBaseMessageData<ChatMessageDataType.serverReplayedMessage> {
id: MessageId
content: string
/** Milliseconds since the Unix epoch. */
timestamp: number
}
/** A message from the server to the client. */
export type ChatServerMessageData =
| ChatServerEditedMessageMessageData
| ChatServerMessageMessageData
| ChatServerReplayedMessageMessageData
| ChatServerThreadMessageData
| ChatServerThreadsMessageData
// ======================================
// === Messages from client to server ===
// ======================================
/** Sent whenever the user opens the chat sidebar. */
export interface ChatAuthenticateMessageData
extends ChatBaseMessageData<ChatMessageDataType.authenticate> {
accessToken: string
}
/** Sent whenever the user opens the chat sidebar. */
export interface ChatAuthenticateAnonymouslyMessageData
extends ChatBaseMessageData<ChatMessageDataType.authenticateAnonymously> {
email: schema.EmailAddress
}
/** Sent when the user is requesting scrollback history. */
export interface ChatHistoryBeforeMessageData
extends ChatBaseMessageData<ChatMessageDataType.historyBefore> {
messageId: MessageId
}
/** Sent when the user sends a message in a new thread. */
export interface ChatNewThreadMessageData
extends ChatBaseMessageData<ChatMessageDataType.newThread> {
title: string
/** Content of the first message, to reduce the number of round trips. */
content: string
}
/** Sent when the user finishes editing the thread name in the chat title bar. */
export interface ChatRenameThreadMessageData
extends ChatBaseMessageData<ChatMessageDataType.renameThread> {
title: string
threadId: ThreadId
}
/** Sent when the user picks a thread from the dropdown. */
export interface ChatSwitchThreadMessageData
extends ChatBaseMessageData<ChatMessageDataType.switchThread> {
threadId: ThreadId
}
/** A regular message from the client to the server. */
export interface ChatMessageMessageData extends ChatBaseMessageData<ChatMessageDataType.message> {
threadId: ThreadId
content: string
}
/** A reaction to a message sent by staff. */
export interface ChatReactionMessageData extends ChatBaseMessageData<ChatMessageDataType.reaction> {
messageId: MessageId
reaction: reactionModule.ReactionSymbol
}
/** Removal of a reaction from the client. */
export interface ChatRemoveReactionMessageData
extends ChatBaseMessageData<ChatMessageDataType.removeReaction> {
messageId: MessageId
reaction: reactionModule.ReactionSymbol
}
/** Sent when the user scrolls to the bottom of a chat thread. */
export interface ChatMarkAsReadMessageData
extends ChatBaseMessageData<ChatMessageDataType.markAsRead> {
threadId: ThreadId
messageId: MessageId
}
/** A message from the client to the server. */
export type ChatClientMessageData =
| ChatAuthenticateAnonymouslyMessageData
| ChatAuthenticateMessageData
| ChatHistoryBeforeMessageData
| ChatMarkAsReadMessageData
| ChatMessageMessageData
| ChatNewThreadMessageData
| ChatReactionMessageData
| ChatRemoveReactionMessageData
| ChatRenameThreadMessageData
| ChatSwitchThreadMessageData
// ====================
// === CustomerChat ===
// ====================
function mustBeOverridden(name: string) {
return () => {
throw new Error(`${name} MUST be set.`)
}
}
export class Chat {
private static instance: Chat
server: ws.WebSocketServer
ipToUser = new Map<string /* Client IP */, schema.UserId>()
/** Required only to find the correct `ipToUser` entry to clean up. */
userToIp = new Map<schema.UserId, string /* Client IP */>()
userToWebsocket = new Map<schema.UserId, ws.WebSocket>()
messageCallback: (
userId: schema.UserId,
message: ChatClientMessageData | ChatInternalMessageData
) => Promise<void> | void = mustBeOverridden('Chat.messageCallback')
constructor(port: number) {
this.server = new ws.WebSocketServer({ port })
this.server.on('connection', (websocket, req) => {
websocket.on('error', error => {
this.onWebSocketError(websocket, req, error)
})
websocket.on('close', (code, reason) => {
this.onWebSocketClose(websocket, req, code, reason)
})
websocket.on('message', (data, isBinary) => {
void this.onWebSocketMessage(websocket, req, data, isBinary)
})
})
}
static default(port: number) {
// This will be `undefined` on the first run.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return (Chat.instance ??= new Chat(port))
}
onMessage(callback: NonNullable<typeof this.messageCallback>) {
this.messageCallback = callback
}
async send(userId: schema.UserId, message: ChatServerMessageData) {
const websocket = this.userToWebsocket.get(userId)
if (websocket == null) {
// The user is not online. This is not an error.
return
} else if (websocket.readyState !== websocket.OPEN) {
this.userToWebsocket.delete(userId)
const ip = this.userToIp.get(userId)
this.userToIp.delete(userId)
if (ip != null) {
this.ipToUser.delete(ip)
}
return
} else {
return new Promise<void>((resolve, reject) => {
websocket.send(JSON.stringify(message), error => {
if (error == null) {
resolve()
} else {
reject(error)
}
})
})
}
}
protected getClientAddress(request: http.IncomingMessage) {
const rawForwardedFor = request.headers['x-forwarded-for']
const forwardedFor =
typeof rawForwardedFor === 'string' ? rawForwardedFor : rawForwardedFor?.[0]
return forwardedFor?.split(',')[0]?.trim() ?? request.socket.remoteAddress
}
protected removeClient(request: http.IncomingMessage) {
const clientAddress = this.getClientAddress(request)
if (clientAddress != null) {
const userId = this.ipToUser.get(clientAddress)
this.ipToUser.delete(clientAddress)
if (userId != null) {
this.userToIp.delete(userId)
this.userToWebsocket.delete(userId)
}
}
}
protected onWebSocketError(
_websocket: ws.WebSocket,
request: http.IncomingMessage,
error: Error
) {
console.error(`WebSocket error: ${error.toString()}`)
this.removeClient(request)
}
protected onWebSocketClose(
_websocket: ws.WebSocket,
request: http.IncomingMessage,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_code: number,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_reason: Buffer
) {
this.removeClient(request)
}
protected async onWebSocketMessage(
websocket: ws.WebSocket,
request: http.IncomingMessage,
data: ws.RawData,
isBinary: boolean
) {
if (isBinary) {
console.error('Binary messages are not supported.')
// This is fine, as binary messages cannot be handled by this application.
// eslint-disable-next-line no-restricted-syntax
return
}
const clientAddress = this.getClientAddress(request)
if (clientAddress != null) {
// This acts as the server so it must not assume the message is valid.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-base-to-string
const message: ChatClientMessageData = JSON.parse(data.toString())
let userId = this.ipToUser.get(clientAddress)
if (message.type === ChatMessageDataType.authenticate) {
const userInfoRequest = await fetch(USERS_ME_PATH, {
headers: {
// The names come from a third-party API and cannot be changed.
// eslint-disable-next-line @typescript-eslint/naming-convention
Authorization: `Bearer ${message.accessToken}`,
},
})
if (!userInfoRequest.ok) {
console.error(
`The client at ${clientAddress} sent an invalid authorization token.`
)
// This is an unrecoverable error.
// eslint-disable-next-line no-restricted-syntax
return
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const userInfo: User = await userInfoRequest.json()
userId = newtype.asNewtype<schema.UserId>(`${userInfo.id} ${userInfo.email}`)
this.ipToUser.set(clientAddress, userId)
this.userToIp.set(userId, clientAddress)
this.userToWebsocket.set(userId, websocket)
await this.messageCallback(userId, {
type: ChatMessageDataType.internalAuthenticate,
userId,
userName: userInfo.name,
})
} else if (message.type === ChatMessageDataType.authenticateAnonymously) {
userId = newtype.asNewtype<schema.UserId>(clientAddress)
this.ipToUser.set(clientAddress, userId)
this.userToIp.set(userId, clientAddress)
this.userToWebsocket.set(userId, websocket)
if (typeof message.email !== 'string' || !isEmail(message.email)) {
websocket.close()
// This is an unrecoverable error.
// eslint-disable-next-line no-restricted-syntax
return
}
await this.messageCallback(userId, {
type: ChatMessageDataType.internalAuthenticateAnonymously,
userId,
email: message.email,
})
} else {
// TODO[sb]: Is it dangerous to log client IPs?
if (userId == null) {
console.error(`The client at ${clientAddress} is not authenticated.`)
// This is fine, as this is an unrecoverable error.
// eslint-disable-next-line no-restricted-syntax
return
}
}
await this.messageCallback(userId, message)
}
}
}