@relaya-chat/core 1.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/LICENSE +21 -0
- package/dist/apiClient.d.ts +246 -0
- package/dist/apiClient.js +241 -0
- package/dist/chatConnection.d.ts +84 -0
- package/dist/chatConnection.js +182 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +9 -0
- package/dist/messageUtils.d.ts +106 -0
- package/dist/messageUtils.js +221 -0
- package/dist/types.d.ts +285 -0
- package/dist/types.js +17 -0
- package/package.json +37 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Copyright (c) 2026 JAB Ventures, Inc. MIT License.
|
|
2
|
+
// See LICENSE file at https://github.com/relaya-chat/sdk
|
|
3
|
+
/**
|
|
4
|
+
* WebSocket connection manager for the Relaya chat system.
|
|
5
|
+
*
|
|
6
|
+
* Handles:
|
|
7
|
+
* - Connection lifecycle (connect, disconnect, reconnect)
|
|
8
|
+
* - Exponential backoff for reconnection attempts
|
|
9
|
+
* - Application-level heartbeat pong responses
|
|
10
|
+
* - Status change callbacks for UI indicators
|
|
11
|
+
*
|
|
12
|
+
* Designed for use in both browser (native WebSocket) and React Native
|
|
13
|
+
* (via the same global WebSocket API surface in React Native).
|
|
14
|
+
*/
|
|
15
|
+
import { calculateBackoff } from './messageUtils.js';
|
|
16
|
+
/**
|
|
17
|
+
* Manages a single WebSocket connection to the Relaya chat server.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* const conn = new ChatConnection(
|
|
21
|
+
* () => `ws://localhost:9000/ws?token=${token}&station=balearic-fm`,
|
|
22
|
+
* (msg) => dispatch(msg),
|
|
23
|
+
* (status) => setConnectionStatus(status)
|
|
24
|
+
* );
|
|
25
|
+
* conn.connect();
|
|
26
|
+
* conn.send({ type: 'message:send', content: 'hello', clientId: '...' });
|
|
27
|
+
* conn.close(); // on component unmount
|
|
28
|
+
*/
|
|
29
|
+
export class ChatConnection {
|
|
30
|
+
buildWsUrl;
|
|
31
|
+
onMessage;
|
|
32
|
+
onStatusChange;
|
|
33
|
+
ws = null;
|
|
34
|
+
status = 'disconnected';
|
|
35
|
+
reconnectAttempt = 0;
|
|
36
|
+
reconnectTimer = null;
|
|
37
|
+
/** Set to true when `close()` is called; prevents any further reconnect. */
|
|
38
|
+
closed = false;
|
|
39
|
+
backoffBaseMs;
|
|
40
|
+
backoffMaxMs;
|
|
41
|
+
onAuthRevoked;
|
|
42
|
+
/**
|
|
43
|
+
* @param buildWsUrl - Called each time a new WebSocket is opened; allows
|
|
44
|
+
* the caller to inject a fresh JWT on reconnect.
|
|
45
|
+
* @param onMessage - Receives every parsed WsServerMessage except `ping`
|
|
46
|
+
* (pings are handled internally with an auto-pong) and
|
|
47
|
+
* `force_logout` (handled internally by stopping the
|
|
48
|
+
* connection and invoking `options.onAuthRevoked`).
|
|
49
|
+
* @param onStatusChange - Called whenever the connection status changes.
|
|
50
|
+
* @param options - Backoff tuning and auth-revocation callback (optional).
|
|
51
|
+
*/
|
|
52
|
+
constructor(buildWsUrl, onMessage, onStatusChange, options = {}) {
|
|
53
|
+
this.buildWsUrl = buildWsUrl;
|
|
54
|
+
this.onMessage = onMessage;
|
|
55
|
+
this.onStatusChange = onStatusChange;
|
|
56
|
+
this.backoffBaseMs = options.backoffBaseMs ?? 1000;
|
|
57
|
+
this.backoffMaxMs = options.backoffMaxMs ?? 30_000;
|
|
58
|
+
this.onAuthRevoked = options.onAuthRevoked;
|
|
59
|
+
}
|
|
60
|
+
getStatus() {
|
|
61
|
+
return this.status;
|
|
62
|
+
}
|
|
63
|
+
/** Open the WebSocket connection (or start the reconnect cycle). */
|
|
64
|
+
connect() {
|
|
65
|
+
if (this.closed)
|
|
66
|
+
return;
|
|
67
|
+
this.setStatus('connecting');
|
|
68
|
+
this.openSocket();
|
|
69
|
+
}
|
|
70
|
+
/** Send a message to the server. Silently dropped if not connected. */
|
|
71
|
+
send(msg) {
|
|
72
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
73
|
+
this.ws.send(JSON.stringify(msg));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Permanently close the connection and stop any pending reconnect.
|
|
78
|
+
* After calling this, the instance should be discarded.
|
|
79
|
+
*/
|
|
80
|
+
close() {
|
|
81
|
+
this.closed = true;
|
|
82
|
+
this.clearReconnectTimer();
|
|
83
|
+
if (this.ws) {
|
|
84
|
+
this.ws.onclose = null; // prevent scheduleReconnect from firing
|
|
85
|
+
this.ws.close();
|
|
86
|
+
this.ws = null;
|
|
87
|
+
}
|
|
88
|
+
this.setStatus('disconnected');
|
|
89
|
+
}
|
|
90
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
91
|
+
openSocket() {
|
|
92
|
+
if (this.closed)
|
|
93
|
+
return;
|
|
94
|
+
const url = this.buildWsUrl();
|
|
95
|
+
const ws = new WebSocket(url);
|
|
96
|
+
this.ws = ws;
|
|
97
|
+
ws.onopen = () => {
|
|
98
|
+
if (this.closed) {
|
|
99
|
+
ws.close();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.reconnectAttempt = 0;
|
|
103
|
+
this.setStatus('connected');
|
|
104
|
+
};
|
|
105
|
+
ws.onmessage = (event) => {
|
|
106
|
+
let msg;
|
|
107
|
+
try {
|
|
108
|
+
msg = JSON.parse(event.data);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return; // ignore malformed frames
|
|
112
|
+
}
|
|
113
|
+
// Reply to server heartbeat pings automatically
|
|
114
|
+
if (msg.type === 'ping') {
|
|
115
|
+
this.send({ type: 'pong' });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Server-initiated logout (e.g. demo space reset removes the user).
|
|
119
|
+
// Stop reconnecting and notify the caller to clear auth state.
|
|
120
|
+
if (msg.type === 'force_logout') {
|
|
121
|
+
this.handleAuthRevoked();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.onMessage(msg);
|
|
125
|
+
};
|
|
126
|
+
ws.onclose = (event) => {
|
|
127
|
+
if (this.closed)
|
|
128
|
+
return;
|
|
129
|
+
// Close code 4001 means the server explicitly revoked this session
|
|
130
|
+
// (e.g. a WS upgrade was rejected after the user was removed).
|
|
131
|
+
// Stop reconnecting and notify the caller to clear auth state.
|
|
132
|
+
if (event.code === 4001) {
|
|
133
|
+
this.handleAuthRevoked();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.scheduleReconnect();
|
|
137
|
+
};
|
|
138
|
+
ws.onerror = () => {
|
|
139
|
+
// onerror is always followed by onclose — no additional action needed here.
|
|
140
|
+
// The reconnect is scheduled in onclose.
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
scheduleReconnect() {
|
|
144
|
+
if (this.closed)
|
|
145
|
+
return;
|
|
146
|
+
this.setStatus('reconnecting');
|
|
147
|
+
const delay = calculateBackoff(this.reconnectAttempt, this.backoffBaseMs, this.backoffMaxMs);
|
|
148
|
+
this.reconnectAttempt++;
|
|
149
|
+
this.reconnectTimer = setTimeout(() => {
|
|
150
|
+
if (!this.closed)
|
|
151
|
+
this.openSocket();
|
|
152
|
+
}, delay);
|
|
153
|
+
}
|
|
154
|
+
clearReconnectTimer() {
|
|
155
|
+
if (this.reconnectTimer !== null) {
|
|
156
|
+
clearTimeout(this.reconnectTimer);
|
|
157
|
+
this.reconnectTimer = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
setStatus(status) {
|
|
161
|
+
if (this.status !== status) {
|
|
162
|
+
this.status = status;
|
|
163
|
+
this.onStatusChange(status);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Permanently stop this connection (no reconnect) and invoke the
|
|
168
|
+
* onAuthRevoked callback so the caller can clear auth state.
|
|
169
|
+
* Called when the server sends a force_logout message or closes with code 4001.
|
|
170
|
+
*/
|
|
171
|
+
handleAuthRevoked() {
|
|
172
|
+
this.closed = true;
|
|
173
|
+
this.clearReconnectTimer();
|
|
174
|
+
if (this.ws) {
|
|
175
|
+
this.ws.onclose = null; // suppress the onclose that will fire after ws.close()
|
|
176
|
+
this.ws.close();
|
|
177
|
+
this.ws = null;
|
|
178
|
+
}
|
|
179
|
+
this.setStatus('disconnected');
|
|
180
|
+
this.onAuthRevoked?.();
|
|
181
|
+
}
|
|
182
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Copyright (c) 2026 JAB Ventures, Inc. MIT License.
|
|
2
|
+
// See LICENSE file at https://github.com/relaya-chat/sdk
|
|
3
|
+
/**
|
|
4
|
+
* Shared package exports
|
|
5
|
+
*/
|
|
6
|
+
export * from './types.js';
|
|
7
|
+
export * from './messageUtils.js';
|
|
8
|
+
export * from './apiClient.js';
|
|
9
|
+
export * from './chatConnection.js';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions for the Relaya chat system.
|
|
3
|
+
* No I/O, no side effects — safe to import in any environment.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Generate a unique client-side message ID for optimistic rendering.
|
|
7
|
+
* Format: timestamp (base-36) + random suffix, both URL-safe.
|
|
8
|
+
*/
|
|
9
|
+
export declare function generateClientId(): string;
|
|
10
|
+
/**
|
|
11
|
+
* Calculate exponential backoff delay for reconnection attempts.
|
|
12
|
+
*
|
|
13
|
+
* @param attempt - Zero-indexed reconnect attempt number
|
|
14
|
+
* @param baseMs - Base delay in milliseconds (default 1000)
|
|
15
|
+
* @param maxMs - Maximum delay cap in milliseconds (default 30000)
|
|
16
|
+
* @returns Delay in milliseconds
|
|
17
|
+
*
|
|
18
|
+
* Examples (baseMs=1000, maxMs=30000):
|
|
19
|
+
* attempt 0 → 1000ms
|
|
20
|
+
* attempt 1 → 2000ms
|
|
21
|
+
* attempt 2 → 4000ms
|
|
22
|
+
* attempt 3 → 8000ms
|
|
23
|
+
* attempt 4 → 16000ms
|
|
24
|
+
* attempt 5+ → 30000ms (capped)
|
|
25
|
+
*/
|
|
26
|
+
export declare function calculateBackoff(attempt: number, baseMs?: number, maxMs?: number): number;
|
|
27
|
+
/**
|
|
28
|
+
* Build URLSearchParams for message cursor pagination.
|
|
29
|
+
* `before` and `after` are mutually exclusive; the server enforces this.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildCursorParams(params: {
|
|
32
|
+
before?: string;
|
|
33
|
+
after?: string;
|
|
34
|
+
limit?: number;
|
|
35
|
+
}): URLSearchParams;
|
|
36
|
+
/**
|
|
37
|
+
* A client-side pending message created before server confirmation.
|
|
38
|
+
* Rendered in the UI immediately; replaced by the server's authoritative
|
|
39
|
+
* copy once a matching `message:broadcast` arrives.
|
|
40
|
+
*/
|
|
41
|
+
export interface OptimisticMessage {
|
|
42
|
+
clientId: string;
|
|
43
|
+
content: string;
|
|
44
|
+
authorId: string;
|
|
45
|
+
authorDisplayName: string;
|
|
46
|
+
authorAvatarUrl: string | null;
|
|
47
|
+
createdAt: Date;
|
|
48
|
+
status: 'sending' | 'sent' | 'failed';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Remove the optimistic message that has been reconciled by a server broadcast.
|
|
52
|
+
* When the server echoes back a `message:broadcast` with a matching `clientId`,
|
|
53
|
+
* the optimistic copy is no longer needed — the server message is used instead.
|
|
54
|
+
*
|
|
55
|
+
* @param optimistic - Current list of optimistic messages
|
|
56
|
+
* @param serverClientId - The clientId echoed back by the server, if any
|
|
57
|
+
* @returns Updated optimistic list with the reconciled message removed
|
|
58
|
+
*/
|
|
59
|
+
export declare function removeReconciledOptimistic(optimistic: OptimisticMessage[], serverClientId: string | undefined): OptimisticMessage[];
|
|
60
|
+
/**
|
|
61
|
+
* Mark an optimistic message as failed.
|
|
62
|
+
* Called when a WS `error` response arrives that can be tied back to a clientId,
|
|
63
|
+
* or when the connection drops before the server echoes the message back.
|
|
64
|
+
*/
|
|
65
|
+
export declare function markOptimisticFailed(optimistic: OptimisticMessage[], clientId: string): OptimisticMessage[];
|
|
66
|
+
/**
|
|
67
|
+
* Remove duplicate messages from a list, keeping the first occurrence.
|
|
68
|
+
* Used when merging REST catch-up results with already-received WS messages
|
|
69
|
+
* after a reconnect.
|
|
70
|
+
*/
|
|
71
|
+
export declare function deduplicateMessages<T extends {
|
|
72
|
+
id: string;
|
|
73
|
+
}>(messages: T[]): T[];
|
|
74
|
+
export interface ImageSegment {
|
|
75
|
+
text: string;
|
|
76
|
+
isImage: boolean;
|
|
77
|
+
url?: string;
|
|
78
|
+
}
|
|
79
|
+
export interface StickerShortcodeEntry {
|
|
80
|
+
shortcode: string | null;
|
|
81
|
+
url: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Host/path allowlist for inline image rendering in chat messages.
|
|
85
|
+
*
|
|
86
|
+
* Notes:
|
|
87
|
+
* - The station sticker path is allowlisted by pathname prefix so both
|
|
88
|
+
* relative and absolute URLs can be rendered.
|
|
89
|
+
* - External hosts are intentionally constrained to known GIF/image providers.
|
|
90
|
+
*/
|
|
91
|
+
export declare const IMAGE_URL_ALLOWLIST: {
|
|
92
|
+
readonly pathPrefixes: readonly ["/files/stations/"];
|
|
93
|
+
readonly hosts: readonly ["tenor.com", "media.tenor.com", "giphy.com", "media.giphy.com", "i.giphy.com", "editablegifs.com", "imgur.com", "i.imgur.com"];
|
|
94
|
+
};
|
|
95
|
+
export declare function normalizeStickerShortcode(shortcode: string): string;
|
|
96
|
+
export declare function buildStickerShortcodeMap(stickers: StickerShortcodeEntry[]): Record<string, string>;
|
|
97
|
+
export declare function expandStickerShortcodes(content: string, stickers: StickerShortcodeEntry[]): string;
|
|
98
|
+
export declare function hasStickerShortcodeToken(content: string): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Segment message content into plain-text and allowlisted image URL spans.
|
|
101
|
+
*/
|
|
102
|
+
export declare function detectImageUrls(content: string): ImageSegment[];
|
|
103
|
+
/**
|
|
104
|
+
* True when content is a single image URL plus optional surrounding whitespace.
|
|
105
|
+
*/
|
|
106
|
+
export declare function isSingleImageMessage(content: string): boolean;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Copyright (c) 2026 JAB Ventures, Inc. MIT License.
|
|
2
|
+
// See LICENSE file at https://github.com/relaya-chat/sdk
|
|
3
|
+
/**
|
|
4
|
+
* Pure utility functions for the Relaya chat system.
|
|
5
|
+
* No I/O, no side effects — safe to import in any environment.
|
|
6
|
+
*/
|
|
7
|
+
// ==================== CLIENT ID ====================
|
|
8
|
+
/**
|
|
9
|
+
* Generate a unique client-side message ID for optimistic rendering.
|
|
10
|
+
* Format: timestamp (base-36) + random suffix, both URL-safe.
|
|
11
|
+
*/
|
|
12
|
+
export function generateClientId() {
|
|
13
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
|
14
|
+
}
|
|
15
|
+
// ==================== RECONNECT BACKOFF ====================
|
|
16
|
+
/**
|
|
17
|
+
* Calculate exponential backoff delay for reconnection attempts.
|
|
18
|
+
*
|
|
19
|
+
* @param attempt - Zero-indexed reconnect attempt number
|
|
20
|
+
* @param baseMs - Base delay in milliseconds (default 1000)
|
|
21
|
+
* @param maxMs - Maximum delay cap in milliseconds (default 30000)
|
|
22
|
+
* @returns Delay in milliseconds
|
|
23
|
+
*
|
|
24
|
+
* Examples (baseMs=1000, maxMs=30000):
|
|
25
|
+
* attempt 0 → 1000ms
|
|
26
|
+
* attempt 1 → 2000ms
|
|
27
|
+
* attempt 2 → 4000ms
|
|
28
|
+
* attempt 3 → 8000ms
|
|
29
|
+
* attempt 4 → 16000ms
|
|
30
|
+
* attempt 5+ → 30000ms (capped)
|
|
31
|
+
*/
|
|
32
|
+
export function calculateBackoff(attempt, baseMs = 1000, maxMs = 30_000) {
|
|
33
|
+
const delay = baseMs * Math.pow(2, attempt);
|
|
34
|
+
return Math.min(delay, maxMs);
|
|
35
|
+
}
|
|
36
|
+
// ==================== CURSOR HELPERS ====================
|
|
37
|
+
/**
|
|
38
|
+
* Build URLSearchParams for message cursor pagination.
|
|
39
|
+
* `before` and `after` are mutually exclusive; the server enforces this.
|
|
40
|
+
*/
|
|
41
|
+
export function buildCursorParams(params) {
|
|
42
|
+
const sp = new URLSearchParams();
|
|
43
|
+
if (params.before)
|
|
44
|
+
sp.set('before', params.before);
|
|
45
|
+
if (params.after)
|
|
46
|
+
sp.set('after', params.after);
|
|
47
|
+
if (params.limit !== undefined)
|
|
48
|
+
sp.set('limit', String(params.limit));
|
|
49
|
+
return sp;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Remove the optimistic message that has been reconciled by a server broadcast.
|
|
53
|
+
* When the server echoes back a `message:broadcast` with a matching `clientId`,
|
|
54
|
+
* the optimistic copy is no longer needed — the server message is used instead.
|
|
55
|
+
*
|
|
56
|
+
* @param optimistic - Current list of optimistic messages
|
|
57
|
+
* @param serverClientId - The clientId echoed back by the server, if any
|
|
58
|
+
* @returns Updated optimistic list with the reconciled message removed
|
|
59
|
+
*/
|
|
60
|
+
export function removeReconciledOptimistic(optimistic, serverClientId) {
|
|
61
|
+
if (!serverClientId)
|
|
62
|
+
return optimistic;
|
|
63
|
+
return optimistic.filter((m) => m.clientId !== serverClientId);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Mark an optimistic message as failed.
|
|
67
|
+
* Called when a WS `error` response arrives that can be tied back to a clientId,
|
|
68
|
+
* or when the connection drops before the server echoes the message back.
|
|
69
|
+
*/
|
|
70
|
+
export function markOptimisticFailed(optimistic, clientId) {
|
|
71
|
+
return optimistic.map((m) => m.clientId === clientId ? { ...m, status: 'failed' } : m);
|
|
72
|
+
}
|
|
73
|
+
// ==================== DEDUPLICATION ====================
|
|
74
|
+
/**
|
|
75
|
+
* Remove duplicate messages from a list, keeping the first occurrence.
|
|
76
|
+
* Used when merging REST catch-up results with already-received WS messages
|
|
77
|
+
* after a reconnect.
|
|
78
|
+
*/
|
|
79
|
+
export function deduplicateMessages(messages) {
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
return messages.filter((m) => {
|
|
82
|
+
if (seen.has(m.id))
|
|
83
|
+
return false;
|
|
84
|
+
seen.add(m.id);
|
|
85
|
+
return true;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const ALLOWED_IMAGE_EXTENSIONS = new Set(['.gif', '.png', '.jpg', '.jpeg', '.webp']);
|
|
89
|
+
/**
|
|
90
|
+
* Host/path allowlist for inline image rendering in chat messages.
|
|
91
|
+
*
|
|
92
|
+
* Notes:
|
|
93
|
+
* - The station sticker path is allowlisted by pathname prefix so both
|
|
94
|
+
* relative and absolute URLs can be rendered.
|
|
95
|
+
* - External hosts are intentionally constrained to known GIF/image providers.
|
|
96
|
+
*/
|
|
97
|
+
export const IMAGE_URL_ALLOWLIST = {
|
|
98
|
+
pathPrefixes: ['/files/stations/'],
|
|
99
|
+
hosts: [
|
|
100
|
+
'tenor.com',
|
|
101
|
+
'media.tenor.com',
|
|
102
|
+
'giphy.com',
|
|
103
|
+
'media.giphy.com',
|
|
104
|
+
'i.giphy.com',
|
|
105
|
+
'editablegifs.com',
|
|
106
|
+
'imgur.com',
|
|
107
|
+
'i.imgur.com',
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
const URL_TOKEN_REGEX = /(https?:\/\/[^\s]+|\/files\/stations\/[^\s]+)/gi;
|
|
111
|
+
const SHORTCODE_TOKEN_REGEX = /:([a-z0-9_-]{1,64}):/gi;
|
|
112
|
+
export function normalizeStickerShortcode(shortcode) {
|
|
113
|
+
return shortcode.trim().toLowerCase();
|
|
114
|
+
}
|
|
115
|
+
export function buildStickerShortcodeMap(stickers) {
|
|
116
|
+
return stickers.reduce((acc, sticker) => {
|
|
117
|
+
if (!sticker.shortcode)
|
|
118
|
+
return acc;
|
|
119
|
+
acc[normalizeStickerShortcode(sticker.shortcode)] = sticker.url;
|
|
120
|
+
return acc;
|
|
121
|
+
}, {});
|
|
122
|
+
}
|
|
123
|
+
export function expandStickerShortcodes(content, stickers) {
|
|
124
|
+
const shortcodeMap = buildStickerShortcodeMap(stickers);
|
|
125
|
+
return content.replace(SHORTCODE_TOKEN_REGEX, (fullMatch, rawShortcode) => {
|
|
126
|
+
const resolved = shortcodeMap[normalizeStickerShortcode(rawShortcode)];
|
|
127
|
+
return resolved ?? fullMatch;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export function hasStickerShortcodeToken(content) {
|
|
131
|
+
SHORTCODE_TOKEN_REGEX.lastIndex = 0;
|
|
132
|
+
return SHORTCODE_TOKEN_REGEX.test(content);
|
|
133
|
+
}
|
|
134
|
+
function hasAllowedImageExtension(pathname) {
|
|
135
|
+
const lowerPath = pathname.toLowerCase();
|
|
136
|
+
for (const ext of ALLOWED_IMAGE_EXTENSIONS) {
|
|
137
|
+
if (lowerPath.endsWith(ext))
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
function hostIsAllowlisted(hostname) {
|
|
143
|
+
const host = hostname.toLowerCase();
|
|
144
|
+
return IMAGE_URL_ALLOWLIST.hosts.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
|
|
145
|
+
}
|
|
146
|
+
function splitTrailingPunctuation(token) {
|
|
147
|
+
const match = token.match(/[\],.!?:;]+$/);
|
|
148
|
+
if (!match)
|
|
149
|
+
return { core: token, trailing: '' };
|
|
150
|
+
const trailing = match[0];
|
|
151
|
+
return { core: token.slice(0, -trailing.length), trailing };
|
|
152
|
+
}
|
|
153
|
+
function pushTextSegment(segments, text) {
|
|
154
|
+
if (!text)
|
|
155
|
+
return;
|
|
156
|
+
const last = segments[segments.length - 1];
|
|
157
|
+
if (last && !last.isImage) {
|
|
158
|
+
last.text += text;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
segments.push({ text, isImage: false });
|
|
162
|
+
}
|
|
163
|
+
function isAllowlistedImageUrl(url) {
|
|
164
|
+
try {
|
|
165
|
+
// Relative station sticker path
|
|
166
|
+
if (url.startsWith('/')) {
|
|
167
|
+
return IMAGE_URL_ALLOWLIST.pathPrefixes.some((prefix) => url.startsWith(prefix))
|
|
168
|
+
&& hasAllowedImageExtension(url.split('?')[0] ?? url);
|
|
169
|
+
}
|
|
170
|
+
const parsed = new URL(url);
|
|
171
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
172
|
+
return false;
|
|
173
|
+
const pathname = parsed.pathname;
|
|
174
|
+
const isStickerPath = IMAGE_URL_ALLOWLIST.pathPrefixes.some((prefix) => pathname.startsWith(prefix));
|
|
175
|
+
const isExternalHost = hostIsAllowlisted(parsed.hostname);
|
|
176
|
+
if (!isStickerPath && !isExternalHost)
|
|
177
|
+
return false;
|
|
178
|
+
return hasAllowedImageExtension(pathname);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Segment message content into plain-text and allowlisted image URL spans.
|
|
186
|
+
*/
|
|
187
|
+
export function detectImageUrls(content) {
|
|
188
|
+
const segments = [];
|
|
189
|
+
let cursor = 0;
|
|
190
|
+
for (const match of content.matchAll(URL_TOKEN_REGEX)) {
|
|
191
|
+
const raw = match[0];
|
|
192
|
+
const start = match.index ?? 0;
|
|
193
|
+
const end = start + raw.length;
|
|
194
|
+
if (start > cursor) {
|
|
195
|
+
pushTextSegment(segments, content.slice(cursor, start));
|
|
196
|
+
}
|
|
197
|
+
const { core, trailing } = splitTrailingPunctuation(raw);
|
|
198
|
+
if (isAllowlistedImageUrl(core)) {
|
|
199
|
+
segments.push({ text: core, isImage: true, url: core });
|
|
200
|
+
pushTextSegment(segments, trailing);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
pushTextSegment(segments, raw);
|
|
204
|
+
}
|
|
205
|
+
cursor = end;
|
|
206
|
+
}
|
|
207
|
+
if (cursor < content.length) {
|
|
208
|
+
pushTextSegment(segments, content.slice(cursor));
|
|
209
|
+
}
|
|
210
|
+
return segments;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* True when content is a single image URL plus optional surrounding whitespace.
|
|
214
|
+
*/
|
|
215
|
+
export function isSingleImageMessage(content) {
|
|
216
|
+
const segments = detectImageUrls(content);
|
|
217
|
+
const imageCount = segments.filter((s) => s.isImage).length;
|
|
218
|
+
if (imageCount !== 1)
|
|
219
|
+
return false;
|
|
220
|
+
return segments.every((segment) => segment.isImage || segment.text.trim() === '');
|
|
221
|
+
}
|