chatly-sdk 0.0.5 → 0.0.6
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/CONTRIBUTING.md +658 -0
- package/IMPROVEMENTS.md +402 -0
- package/README.md +1538 -164
- package/dist/index.d.ts +430 -9
- package/dist/index.js +1420 -63
- package/examples/01-basic-chat/README.md +61 -0
- package/examples/01-basic-chat/index.js +58 -0
- package/examples/01-basic-chat/package.json +13 -0
- package/examples/02-group-chat/README.md +78 -0
- package/examples/02-group-chat/index.js +76 -0
- package/examples/02-group-chat/package.json +13 -0
- package/examples/03-offline-messaging/README.md +73 -0
- package/examples/03-offline-messaging/index.js +80 -0
- package/examples/03-offline-messaging/package.json +13 -0
- package/examples/04-live-chat/README.md +80 -0
- package/examples/04-live-chat/index.js +114 -0
- package/examples/04-live-chat/package.json +13 -0
- package/examples/05-hybrid-messaging/README.md +71 -0
- package/examples/05-hybrid-messaging/index.js +106 -0
- package/examples/05-hybrid-messaging/package.json +13 -0
- package/examples/06-postgresql-integration/README.md +101 -0
- package/examples/06-postgresql-integration/adapters/groupStore.js +73 -0
- package/examples/06-postgresql-integration/adapters/messageStore.js +47 -0
- package/examples/06-postgresql-integration/adapters/userStore.js +40 -0
- package/examples/06-postgresql-integration/index.js +92 -0
- package/examples/06-postgresql-integration/package.json +14 -0
- package/examples/06-postgresql-integration/schema.sql +58 -0
- package/examples/08-customer-support/README.md +70 -0
- package/examples/08-customer-support/index.js +104 -0
- package/examples/08-customer-support/package.json +13 -0
- package/examples/README.md +105 -0
- package/jest.config.cjs +28 -0
- package/package.json +12 -8
- package/src/chat/ChatSession.ts +81 -0
- package/src/chat/GroupSession.ts +79 -0
- package/src/constants.ts +61 -0
- package/src/crypto/e2e.ts +0 -20
- package/src/index.ts +525 -63
- package/src/models/mediaTypes.ts +58 -0
- package/src/models/message.ts +4 -1
- package/src/transport/adapters.ts +51 -1
- package/src/transport/memoryTransport.ts +75 -13
- package/src/transport/websocketClient.ts +269 -21
- package/src/transport/websocketServer.ts +26 -26
- package/src/utils/errors.ts +97 -0
- package/src/utils/logger.ts +96 -0
- package/src/utils/mediaUtils.ts +235 -0
- package/src/utils/messageQueue.ts +162 -0
- package/src/utils/validation.ts +99 -0
- package/test/crypto.test.ts +122 -35
- package/test/sdk.test.ts +276 -0
- package/test/validation.test.ts +64 -0
- package/tsconfig.json +11 -10
- package/tsconfig.test.json +11 -0
- package/src/ChatManager.ts +0 -103
- package/src/crypto/keyManager.ts +0 -28
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log levels
|
|
3
|
+
*/
|
|
4
|
+
export enum LogLevel {
|
|
5
|
+
DEBUG = 0,
|
|
6
|
+
INFO = 1,
|
|
7
|
+
WARN = 2,
|
|
8
|
+
ERROR = 3,
|
|
9
|
+
NONE = 4,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Logger configuration
|
|
14
|
+
*/
|
|
15
|
+
export interface LoggerConfig {
|
|
16
|
+
level: LogLevel;
|
|
17
|
+
prefix?: string;
|
|
18
|
+
timestamp?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Simple structured logger
|
|
23
|
+
*/
|
|
24
|
+
export class Logger {
|
|
25
|
+
private config: LoggerConfig;
|
|
26
|
+
|
|
27
|
+
constructor(config: Partial<LoggerConfig> = {}) {
|
|
28
|
+
this.config = {
|
|
29
|
+
level: config.level ?? LogLevel.INFO,
|
|
30
|
+
prefix: config.prefix ?? '[ChatSDK]',
|
|
31
|
+
timestamp: config.timestamp ?? true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private shouldLog(level: LogLevel): boolean {
|
|
36
|
+
return level >= this.config.level;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private formatMessage(level: string, message: string, data?: unknown): string {
|
|
40
|
+
const parts: string[] = [];
|
|
41
|
+
|
|
42
|
+
if (this.config.timestamp) {
|
|
43
|
+
parts.push(new Date().toISOString());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
parts.push(this.config.prefix!);
|
|
47
|
+
parts.push(`[${level}]`);
|
|
48
|
+
parts.push(message);
|
|
49
|
+
|
|
50
|
+
let formatted = parts.join(' ');
|
|
51
|
+
|
|
52
|
+
if (data !== undefined) {
|
|
53
|
+
formatted += ' ' + JSON.stringify(data, null, 2);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return formatted;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
debug(message: string, data?: unknown): void {
|
|
60
|
+
if (this.shouldLog(LogLevel.DEBUG)) {
|
|
61
|
+
console.debug(this.formatMessage('DEBUG', message, data));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
info(message: string, data?: unknown): void {
|
|
66
|
+
if (this.shouldLog(LogLevel.INFO)) {
|
|
67
|
+
console.info(this.formatMessage('INFO', message, data));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
warn(message: string, data?: unknown): void {
|
|
72
|
+
if (this.shouldLog(LogLevel.WARN)) {
|
|
73
|
+
console.warn(this.formatMessage('WARN', message, data));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
error(message: string, error?: Error | unknown): void {
|
|
78
|
+
if (this.shouldLog(LogLevel.ERROR)) {
|
|
79
|
+
const errorData = error instanceof Error
|
|
80
|
+
? { message: error.message, stack: error.stack }
|
|
81
|
+
: error;
|
|
82
|
+
console.error(this.formatMessage('ERROR', message, errorData));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setLevel(level: LogLevel): void {
|
|
87
|
+
this.config.level = level;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getLevel(): LogLevel {
|
|
91
|
+
return this.config.level;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Default logger instance
|
|
96
|
+
export const logger = new Logger();
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { MediaType, MediaMetadata, MediaAttachment, SUPPORTED_MIME_TYPES, FILE_SIZE_LIMITS } from '../models/mediaTypes.js';
|
|
2
|
+
import { ValidationError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert File or Blob to base64 string
|
|
6
|
+
*/
|
|
7
|
+
export async function encodeFileToBase64(file: File | Blob): Promise<string> {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const reader = new FileReader();
|
|
10
|
+
reader.onload = () => {
|
|
11
|
+
const result = reader.result as string;
|
|
12
|
+
if (!result) {
|
|
13
|
+
reject(new Error('Failed to read file: result is null'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const base64 = result.split(',')[1];
|
|
17
|
+
if (!base64) {
|
|
18
|
+
reject(new Error('Failed to extract base64 data'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
resolve(base64);
|
|
22
|
+
};
|
|
23
|
+
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
24
|
+
reader.readAsDataURL(file);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert base64 string to Blob
|
|
30
|
+
*/
|
|
31
|
+
export function decodeBase64ToBlob(base64: string, mimeType: string): Blob {
|
|
32
|
+
const byteCharacters = atob(base64);
|
|
33
|
+
const byteNumbers = new Array(byteCharacters.length);
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < byteCharacters.length; i++) {
|
|
36
|
+
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const byteArray = new Uint8Array(byteNumbers);
|
|
40
|
+
return new Blob([byteArray], { type: mimeType });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detect media type from MIME type
|
|
45
|
+
*/
|
|
46
|
+
export function getMediaType(mimeType: string): MediaType {
|
|
47
|
+
if (SUPPORTED_MIME_TYPES.image.includes(mimeType)) {
|
|
48
|
+
return MediaType.IMAGE;
|
|
49
|
+
}
|
|
50
|
+
if (SUPPORTED_MIME_TYPES.audio.includes(mimeType)) {
|
|
51
|
+
return MediaType.AUDIO;
|
|
52
|
+
}
|
|
53
|
+
if (SUPPORTED_MIME_TYPES.video.includes(mimeType)) {
|
|
54
|
+
return MediaType.VIDEO;
|
|
55
|
+
}
|
|
56
|
+
if (SUPPORTED_MIME_TYPES.document.includes(mimeType)) {
|
|
57
|
+
return MediaType.DOCUMENT;
|
|
58
|
+
}
|
|
59
|
+
throw new ValidationError(`Unsupported MIME type: ${mimeType}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate media file
|
|
64
|
+
*/
|
|
65
|
+
export function validateMediaFile(file: File | Blob, filename?: string): void {
|
|
66
|
+
const mimeType = file.type;
|
|
67
|
+
|
|
68
|
+
// Check if MIME type is supported
|
|
69
|
+
const mediaType = getMediaType(mimeType);
|
|
70
|
+
|
|
71
|
+
// Check file size
|
|
72
|
+
const maxSize = FILE_SIZE_LIMITS[mediaType];
|
|
73
|
+
if (file.size > maxSize) {
|
|
74
|
+
throw new ValidationError(
|
|
75
|
+
`File size exceeds limit. Max size for ${mediaType}: ${maxSize / 1024 / 1024}MB`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate filename if provided
|
|
80
|
+
if (filename && filename.length > 255) {
|
|
81
|
+
throw new ValidationError('Filename too long (max 255 characters)');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create media metadata from file
|
|
87
|
+
*/
|
|
88
|
+
export async function createMediaMetadata(
|
|
89
|
+
file: File | Blob,
|
|
90
|
+
filename?: string
|
|
91
|
+
): Promise<MediaMetadata> {
|
|
92
|
+
const actualFilename = filename || (file instanceof File ? file.name : 'file');
|
|
93
|
+
|
|
94
|
+
const metadata: MediaMetadata = {
|
|
95
|
+
filename: actualFilename,
|
|
96
|
+
mimeType: file.type,
|
|
97
|
+
size: file.size,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// For images, try to get dimensions
|
|
101
|
+
if (file.type.startsWith('image/')) {
|
|
102
|
+
try {
|
|
103
|
+
const dimensions = await getImageDimensions(file);
|
|
104
|
+
metadata.width = dimensions.width;
|
|
105
|
+
metadata.height = dimensions.height;
|
|
106
|
+
|
|
107
|
+
// Generate thumbnail for images
|
|
108
|
+
metadata.thumbnail = await generateThumbnail(file);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// Dimensions optional, continue without them
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// For audio/video, duration would need to be provided by the caller
|
|
115
|
+
// as we can't reliably get it in Node.js without external libraries
|
|
116
|
+
|
|
117
|
+
return metadata;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get image dimensions
|
|
122
|
+
*/
|
|
123
|
+
function getImageDimensions(file: Blob): Promise<{ width: number; height: number }> {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const img = new Image();
|
|
126
|
+
const url = URL.createObjectURL(file);
|
|
127
|
+
|
|
128
|
+
img.onload = () => {
|
|
129
|
+
URL.revokeObjectURL(url);
|
|
130
|
+
resolve({ width: img.width, height: img.height });
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
img.onerror = () => {
|
|
134
|
+
URL.revokeObjectURL(url);
|
|
135
|
+
reject(new Error('Failed to load image'));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
img.src = url;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generate thumbnail for image (max 200x200)
|
|
144
|
+
*/
|
|
145
|
+
async function generateThumbnail(file: Blob): Promise<string> {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const img = new Image();
|
|
148
|
+
const url = URL.createObjectURL(file);
|
|
149
|
+
|
|
150
|
+
img.onload = () => {
|
|
151
|
+
URL.revokeObjectURL(url);
|
|
152
|
+
|
|
153
|
+
// Calculate thumbnail size (max 200x200, maintain aspect ratio)
|
|
154
|
+
const maxSize = 200;
|
|
155
|
+
let width = img.width;
|
|
156
|
+
let height = img.height;
|
|
157
|
+
|
|
158
|
+
if (width > height) {
|
|
159
|
+
if (width > maxSize) {
|
|
160
|
+
height = (height * maxSize) / width;
|
|
161
|
+
width = maxSize;
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
if (height > maxSize) {
|
|
165
|
+
width = (width * maxSize) / height;
|
|
166
|
+
height = maxSize;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Create canvas and draw thumbnail
|
|
171
|
+
const canvas = document.createElement('canvas');
|
|
172
|
+
canvas.width = width;
|
|
173
|
+
canvas.height = height;
|
|
174
|
+
const ctx = canvas.getContext('2d');
|
|
175
|
+
|
|
176
|
+
if (!ctx) {
|
|
177
|
+
reject(new Error('Failed to get canvas context'));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
182
|
+
|
|
183
|
+
// Convert to base64
|
|
184
|
+
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
|
|
185
|
+
const thumbnail = dataUrl.split(',')[1];
|
|
186
|
+
if (!thumbnail) {
|
|
187
|
+
reject(new Error('Failed to generate thumbnail'));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
resolve(thumbnail);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
img.onerror = () => {
|
|
194
|
+
URL.revokeObjectURL(url);
|
|
195
|
+
reject(new Error('Failed to load image for thumbnail'));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
img.src = url;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Create media attachment from file
|
|
204
|
+
*/
|
|
205
|
+
export async function createMediaAttachment(
|
|
206
|
+
file: File | Blob,
|
|
207
|
+
filename?: string
|
|
208
|
+
): Promise<MediaAttachment> {
|
|
209
|
+
// Validate file
|
|
210
|
+
validateMediaFile(file, filename);
|
|
211
|
+
|
|
212
|
+
// Get media type
|
|
213
|
+
const mediaType = getMediaType(file.type);
|
|
214
|
+
|
|
215
|
+
// Encode file to base64
|
|
216
|
+
const data = await encodeFileToBase64(file);
|
|
217
|
+
|
|
218
|
+
// Create metadata
|
|
219
|
+
const metadata = await createMediaMetadata(file, filename);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
type: mediaType,
|
|
223
|
+
data,
|
|
224
|
+
metadata,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Format file size for display
|
|
230
|
+
*/
|
|
231
|
+
export function formatFileSize(bytes: number): string {
|
|
232
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
233
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
234
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
235
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Message } from '../models/message.js';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
import { MAX_QUEUE_SIZE, MESSAGE_RETRY_ATTEMPTS, MESSAGE_RETRY_DELAY, MessageStatus } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Queued message with retry metadata
|
|
7
|
+
*/
|
|
8
|
+
export interface QueuedMessage {
|
|
9
|
+
message: Message;
|
|
10
|
+
status: MessageStatus;
|
|
11
|
+
attempts: number;
|
|
12
|
+
lastAttempt?: number;
|
|
13
|
+
error?: Error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Message queue for offline support and retry logic
|
|
18
|
+
*/
|
|
19
|
+
export class MessageQueue {
|
|
20
|
+
private queue: Map<string, QueuedMessage> = new Map();
|
|
21
|
+
private maxSize: number;
|
|
22
|
+
private maxRetries: number;
|
|
23
|
+
private retryDelay: number;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
maxSize: number = MAX_QUEUE_SIZE,
|
|
27
|
+
maxRetries: number = MESSAGE_RETRY_ATTEMPTS,
|
|
28
|
+
retryDelay: number = MESSAGE_RETRY_DELAY
|
|
29
|
+
) {
|
|
30
|
+
this.maxSize = maxSize;
|
|
31
|
+
this.maxRetries = maxRetries;
|
|
32
|
+
this.retryDelay = retryDelay;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Add a message to the queue
|
|
37
|
+
*/
|
|
38
|
+
enqueue(message: Message): void {
|
|
39
|
+
if (this.queue.size >= this.maxSize) {
|
|
40
|
+
logger.warn('Message queue is full, removing oldest message');
|
|
41
|
+
const firstKey = this.queue.keys().next().value;
|
|
42
|
+
if (firstKey) {
|
|
43
|
+
this.queue.delete(firstKey);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.queue.set(message.id, {
|
|
48
|
+
message,
|
|
49
|
+
status: MessageStatus.PENDING,
|
|
50
|
+
attempts: 0,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
logger.debug('Message enqueued', { messageId: message.id });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Mark a message as sent
|
|
58
|
+
*/
|
|
59
|
+
markSent(messageId: string): void {
|
|
60
|
+
const queued = this.queue.get(messageId);
|
|
61
|
+
if (queued) {
|
|
62
|
+
queued.status = MessageStatus.SENT;
|
|
63
|
+
logger.debug('Message marked as sent', { messageId });
|
|
64
|
+
// Remove from queue after successful send
|
|
65
|
+
this.queue.delete(messageId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mark a message as failed
|
|
71
|
+
*/
|
|
72
|
+
markFailed(messageId: string, error: Error): void {
|
|
73
|
+
const queued = this.queue.get(messageId);
|
|
74
|
+
if (queued) {
|
|
75
|
+
queued.status = MessageStatus.FAILED;
|
|
76
|
+
queued.error = error;
|
|
77
|
+
queued.attempts++;
|
|
78
|
+
queued.lastAttempt = Date.now();
|
|
79
|
+
|
|
80
|
+
logger.warn('Message failed', {
|
|
81
|
+
messageId,
|
|
82
|
+
attempts: queued.attempts,
|
|
83
|
+
error: error.message,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Remove if max retries exceeded
|
|
87
|
+
if (queued.attempts >= this.maxRetries) {
|
|
88
|
+
logger.error('Message exceeded max retries, removing from queue', {
|
|
89
|
+
messageId,
|
|
90
|
+
attempts: queued.attempts,
|
|
91
|
+
});
|
|
92
|
+
this.queue.delete(messageId);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get messages that need to be retried
|
|
99
|
+
*/
|
|
100
|
+
getRetryableMessages(): QueuedMessage[] {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const retryable: QueuedMessage[] = [];
|
|
103
|
+
|
|
104
|
+
for (const queued of this.queue.values()) {
|
|
105
|
+
if (
|
|
106
|
+
queued.status === MessageStatus.FAILED &&
|
|
107
|
+
queued.attempts < this.maxRetries &&
|
|
108
|
+
(!queued.lastAttempt || now - queued.lastAttempt >= this.retryDelay)
|
|
109
|
+
) {
|
|
110
|
+
retryable.push(queued);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return retryable;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get all pending messages
|
|
119
|
+
*/
|
|
120
|
+
getPendingMessages(): QueuedMessage[] {
|
|
121
|
+
return Array.from(this.queue.values()).filter(
|
|
122
|
+
(q) => q.status === MessageStatus.PENDING
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get queue size
|
|
128
|
+
*/
|
|
129
|
+
size(): number {
|
|
130
|
+
return this.queue.size;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Clear the queue
|
|
135
|
+
*/
|
|
136
|
+
clear(): void {
|
|
137
|
+
this.queue.clear();
|
|
138
|
+
logger.debug('Message queue cleared');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get message by ID
|
|
143
|
+
*/
|
|
144
|
+
get(messageId: string): QueuedMessage | undefined {
|
|
145
|
+
return this.queue.get(messageId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Remove message from queue
|
|
150
|
+
*/
|
|
151
|
+
remove(messageId: string): void {
|
|
152
|
+
this.queue.delete(messageId);
|
|
153
|
+
logger.debug('Message removed from queue', { messageId });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get all messages in queue
|
|
158
|
+
*/
|
|
159
|
+
getAll(): QueuedMessage[] {
|
|
160
|
+
return Array.from(this.queue.values());
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ValidationError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validation utilities
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const USERNAME_REGEX = /^[a-zA-Z0-9_-]{3,20}$/;
|
|
8
|
+
const MAX_MESSAGE_LENGTH = 10000;
|
|
9
|
+
const MIN_GROUP_MEMBERS = 2;
|
|
10
|
+
const MAX_GROUP_MEMBERS = 256;
|
|
11
|
+
const MAX_GROUP_NAME_LENGTH = 100;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validate username format
|
|
15
|
+
*/
|
|
16
|
+
export function validateUsername(username: string): void {
|
|
17
|
+
if (!username || typeof username !== 'string') {
|
|
18
|
+
throw new ValidationError('Username is required', { username });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!USERNAME_REGEX.test(username)) {
|
|
22
|
+
throw new ValidationError(
|
|
23
|
+
'Username must be 3-20 characters and contain only letters, numbers, underscores, and hyphens',
|
|
24
|
+
{ username }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validate message content
|
|
31
|
+
*/
|
|
32
|
+
export function validateMessage(message: string): void {
|
|
33
|
+
if (!message || typeof message !== 'string') {
|
|
34
|
+
throw new ValidationError('Message content is required');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (message.length === 0) {
|
|
38
|
+
throw new ValidationError('Message cannot be empty');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (message.length > MAX_MESSAGE_LENGTH) {
|
|
42
|
+
throw new ValidationError(
|
|
43
|
+
`Message exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters`,
|
|
44
|
+
{ length: message.length, max: MAX_MESSAGE_LENGTH }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate group name
|
|
51
|
+
*/
|
|
52
|
+
export function validateGroupName(name: string): void {
|
|
53
|
+
if (!name || typeof name !== 'string') {
|
|
54
|
+
throw new ValidationError('Group name is required');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (name.trim().length === 0) {
|
|
58
|
+
throw new ValidationError('Group name cannot be empty');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (name.length > MAX_GROUP_NAME_LENGTH) {
|
|
62
|
+
throw new ValidationError(
|
|
63
|
+
`Group name exceeds maximum length of ${MAX_GROUP_NAME_LENGTH} characters`,
|
|
64
|
+
{ length: name.length, max: MAX_GROUP_NAME_LENGTH }
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate group members count
|
|
71
|
+
*/
|
|
72
|
+
export function validateGroupMembers(memberCount: number): void {
|
|
73
|
+
if (memberCount < MIN_GROUP_MEMBERS) {
|
|
74
|
+
throw new ValidationError(
|
|
75
|
+
`Group must have at least ${MIN_GROUP_MEMBERS} members`,
|
|
76
|
+
{ count: memberCount, min: MIN_GROUP_MEMBERS }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (memberCount > MAX_GROUP_MEMBERS) {
|
|
81
|
+
throw new ValidationError(
|
|
82
|
+
`Group cannot have more than ${MAX_GROUP_MEMBERS} members`,
|
|
83
|
+
{ count: memberCount, max: MAX_GROUP_MEMBERS }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate user ID format
|
|
90
|
+
*/
|
|
91
|
+
export function validateUserId(userId: string): void {
|
|
92
|
+
if (!userId || typeof userId !== 'string') {
|
|
93
|
+
throw new ValidationError('User ID is required');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (userId.trim().length === 0) {
|
|
97
|
+
throw new ValidationError('User ID cannot be empty');
|
|
98
|
+
}
|
|
99
|
+
}
|