tripstorekrabi-shared 1.25.9 → 1.26.1
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/package.json +3 -3
- package/product.js +42 -13
- package/telegram.js +139 -55
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tripstorekrabi-shared",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.1",
|
|
4
4
|
"description": "Shared library for Trip Store Krabi",
|
|
5
5
|
"author": "Stefan Sommer <tonprince@gmail.com>",
|
|
6
6
|
"repository": {
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"dayjs": "^1.11.23",
|
|
21
|
-
"libphonenumber-js": "^1.13.
|
|
22
|
-
"nanoid": "^6.0.
|
|
21
|
+
"libphonenumber-js": "^1.13.15",
|
|
22
|
+
"nanoid": "^6.0.2",
|
|
23
23
|
"firebase-admin": "^14.5.0"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
package/product.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { calculateProductSellingPrice, countMinPeople } from
|
|
1
|
+
import { calculateProductSellingPrice, countMinPeople } from './price.js';
|
|
2
2
|
|
|
3
|
-
const MATCH_HTML_REGEXP = /(<([^>]+)>)/
|
|
3
|
+
const MATCH_HTML_REGEXP = /(<([^>]+)>)/gi;
|
|
4
4
|
|
|
5
5
|
function toIsoDateString(val) {
|
|
6
6
|
if (!val) return null;
|
|
@@ -65,21 +65,50 @@ export function buildIndex(id, product) {
|
|
|
65
65
|
document.shortName = String(product.shortName).trim();
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
document.destinations = product.destinations;
|
|
70
|
-
} else if (product.destination) {
|
|
71
|
-
document.destinations = [product.destination];
|
|
72
|
-
}
|
|
68
|
+
const isTransfer = Boolean(product.from && product.to);
|
|
73
69
|
|
|
74
|
-
if (
|
|
75
|
-
|
|
70
|
+
if (isTransfer) {
|
|
71
|
+
// For transfers, always derive destinations directly from `from` and `to`
|
|
72
|
+
const transferDestinations = Array.from(
|
|
73
|
+
new Set([product.from, product.to].map((d) => String(d || '').trim()).filter(Boolean))
|
|
74
|
+
);
|
|
75
|
+
if (transferDestinations.length > 0) {
|
|
76
|
+
document.destinations = transferDestinations;
|
|
77
|
+
}
|
|
76
78
|
} else {
|
|
77
|
-
|
|
79
|
+
// For activities, clean and deduplicate `destinations` (or fallback to `destination`)
|
|
80
|
+
const rawDestinations = Array.isArray(product.destinations)
|
|
81
|
+
? product.destinations
|
|
82
|
+
: product.destination
|
|
83
|
+
? [product.destination]
|
|
84
|
+
: [];
|
|
85
|
+
|
|
86
|
+
const cleanedDestinations = Array.from(
|
|
87
|
+
new Set(rawDestinations.map((d) => String(d || '').trim()).filter(Boolean))
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (cleanedDestinations.length > 0) {
|
|
91
|
+
document.destinations = cleanedDestinations;
|
|
92
|
+
}
|
|
78
93
|
}
|
|
79
94
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
// Populate unified keywords array (falls back to legacy tags if keywords has no valid entries)
|
|
96
|
+
const validKeywords = Array.isArray(product.keywords)
|
|
97
|
+
? product.keywords.map((k) => String(k || '').trim()).filter(Boolean)
|
|
98
|
+
: [];
|
|
99
|
+
|
|
100
|
+
const validTags = Array.isArray(product.tags)
|
|
101
|
+
? product.tags.map((t) => String(t || '').trim()).filter(Boolean)
|
|
102
|
+
: [];
|
|
103
|
+
|
|
104
|
+
document.keywords = Array.from(new Set(validKeywords.length > 0 ? validKeywords : validTags));
|
|
105
|
+
|
|
106
|
+
// TODO stef Keep legacy tags synced for backward compatibility until tags is fully removed from frontend
|
|
107
|
+
document.tags = validTags.length > 0 ? Array.from(new Set(validTags)) : document.keywords;
|
|
108
|
+
|
|
109
|
+
if (isTransfer) {
|
|
110
|
+
document.from = String(product.from).trim();
|
|
111
|
+
document.to = String(product.to).trim();
|
|
83
112
|
|
|
84
113
|
if (Array.isArray(product.waypoints) && product.waypoints.length > 0) {
|
|
85
114
|
const startWp = product.waypoints[0]?.latLng;
|
package/telegram.js
CHANGED
|
@@ -1,54 +1,138 @@
|
|
|
1
|
-
const getEnv = (key) => typeof process !== 'undefined' && process.env ? process.env[key] : undefined;
|
|
2
|
-
|
|
3
|
-
const ENV_BOT_TOKEN = getEnv('TELEGRAM_API_TOKEN') || getEnv('PUBLIC_TELEGRAM_API_TOKEN');
|
|
4
|
-
const ENV_CHAT_ID = getEnv('TELEGRAM_CHAT_ID');
|
|
1
|
+
const getEnv = (key) => (typeof process !== 'undefined' && process.env ? process.env[key] : undefined);
|
|
5
2
|
const TELEGRAM_MAX_CHARS = 4096;
|
|
6
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Safely escapes HTML reserved characters for dynamic user-generated content.
|
|
6
|
+
* Use this BEFORE passing dynamic variables into your message string!
|
|
7
|
+
*/
|
|
8
|
+
export function escapeHtml(text) {
|
|
9
|
+
if (!text) return '';
|
|
10
|
+
return String(text)
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Safely truncates HTML strings without breaking active tags, attributes, or HTML entities.
|
|
18
|
+
*/
|
|
19
|
+
export function safeTruncateHtml(html, maxLength) {
|
|
20
|
+
if (html.length <= maxLength) return html;
|
|
21
|
+
|
|
22
|
+
const note = "\n\n... [Message Truncated]";
|
|
23
|
+
const targetLength = maxLength - note.length;
|
|
24
|
+
let truncated = html.substring(0, targetLength);
|
|
25
|
+
|
|
26
|
+
// 1. Strip trailing broken HTML entity if cut mid-way (e.g., "&am")
|
|
27
|
+
truncated = truncated.replace(/&[a-z0-9#]{0,7}$/i, '');
|
|
28
|
+
|
|
29
|
+
// 2. Cut back if sliced inside an unclosed HTML tag (e.g., "<a href=...")
|
|
30
|
+
const lastOpenAngle = truncated.lastIndexOf('<');
|
|
31
|
+
const lastCloseAngle = truncated.lastIndexOf('>');
|
|
32
|
+
if (lastOpenAngle > lastCloseAngle) {
|
|
33
|
+
if (/<[a-z\/]/i.test(truncated.substring(lastOpenAngle))) {
|
|
34
|
+
truncated = truncated.substring(0, lastOpenAngle);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 3. Close open HTML tags using a localized regex parser (ReDoS safe & supports tg-spoiler)
|
|
39
|
+
const openTags = [];
|
|
40
|
+
const tagMatcher = /<\/?([a-z0-9-]+)[^>]*>/gi; // <-- FIXED: Added hyphen for tg-spoiler / tg-emoji
|
|
41
|
+
let match;
|
|
42
|
+
|
|
43
|
+
while ((match = tagMatcher.exec(truncated)) !== null) {
|
|
44
|
+
const fullTag = match[0];
|
|
45
|
+
const tagName = match[1].toLowerCase();
|
|
46
|
+
|
|
47
|
+
// Skip self-closing void elements or explicit self-closing tags
|
|
48
|
+
if (fullTag.endsWith('/>') || ['br', 'img', 'hr', 'input'].includes(tagName)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (fullTag.startsWith('</')) {
|
|
53
|
+
const lastIndex = openTags.lastIndexOf(tagName);
|
|
54
|
+
if (lastIndex !== -1) openTags.splice(lastIndex, 1);
|
|
55
|
+
} else {
|
|
56
|
+
openTags.push(tagName);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Close remaining unclosed tags in reverse order
|
|
61
|
+
while (openTags.length > 0) {
|
|
62
|
+
truncated += `</${openTags.pop()}>`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return truncated + note;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sends a highly resilient message to Telegram.
|
|
70
|
+
*/
|
|
7
71
|
export async function sendTelegramMessage(message, options = {}) {
|
|
8
72
|
const {
|
|
9
73
|
botToken,
|
|
10
74
|
chatId,
|
|
75
|
+
messageThreadId,
|
|
11
76
|
parseMode = 'HTML',
|
|
12
77
|
prefix = '',
|
|
13
78
|
truncate = true,
|
|
14
79
|
maxRetries = 3,
|
|
15
|
-
|
|
80
|
+
maxRateLimitRetries = 3,
|
|
81
|
+
throwOnError = true,
|
|
82
|
+
timeoutMs = 15000 // <-- ADDED: Failsafe for hanging serverless connections
|
|
16
83
|
} = options;
|
|
17
84
|
|
|
18
|
-
const BOT_TOKEN = botToken ||
|
|
19
|
-
const CHAT_ID = chatId ||
|
|
85
|
+
const BOT_TOKEN = botToken || getEnv('TELEGRAM_API_TOKEN') || getEnv('PUBLIC_TELEGRAM_API_TOKEN');
|
|
86
|
+
const CHAT_ID = chatId || getEnv('TELEGRAM_CHAT_ID');
|
|
20
87
|
|
|
21
88
|
if (!BOT_TOKEN || !CHAT_ID) {
|
|
22
|
-
const errorMsg = "FATAL: Telegram secrets are not configured in options or
|
|
89
|
+
const errorMsg = "FATAL: Telegram secrets are not configured in options or environment.";
|
|
23
90
|
console.error(errorMsg);
|
|
24
91
|
if (throwOnError) throw new Error(errorMsg);
|
|
25
92
|
return false;
|
|
26
93
|
}
|
|
27
94
|
|
|
28
|
-
let finalMessage = `${prefix} ${message}`.trim();
|
|
95
|
+
let finalMessage = prefix ? `${prefix} ${message}`.trim() : String(message || '').trim();
|
|
29
96
|
|
|
97
|
+
// Safe truncation
|
|
30
98
|
if (truncate && finalMessage.length > TELEGRAM_MAX_CHARS) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
99
|
+
if (parseMode && parseMode.toUpperCase() === 'HTML') {
|
|
100
|
+
finalMessage = safeTruncateHtml(finalMessage, TELEGRAM_MAX_CHARS);
|
|
101
|
+
} else {
|
|
102
|
+
const note = "\n\n... [Message Truncated]";
|
|
103
|
+
finalMessage = finalMessage.substring(0, TELEGRAM_MAX_CHARS - note.length) + note;
|
|
104
|
+
}
|
|
105
|
+
console.warn(`Telegram message truncated to fit ${TELEGRAM_MAX_CHARS} limit.`);
|
|
35
106
|
}
|
|
36
107
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
headers: {
|
|
41
|
-
'Content-Type': 'application/json'
|
|
42
|
-
},
|
|
43
|
-
body: JSON.stringify({
|
|
44
|
-
chat_id: CHAT_ID,
|
|
45
|
-
text: finalMessage,
|
|
46
|
-
parse_mode: parseMode.toUpperCase(),
|
|
47
|
-
})
|
|
108
|
+
const bodyPayload = {
|
|
109
|
+
chat_id: String(CHAT_ID),
|
|
110
|
+
text: finalMessage,
|
|
48
111
|
};
|
|
49
112
|
|
|
113
|
+
if (parseMode) {
|
|
114
|
+
bodyPayload.parse_mode = parseMode.toUpperCase();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (messageThreadId !== undefined && messageThreadId !== null) {
|
|
118
|
+
bodyPayload.message_thread_id = Number(messageThreadId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const telegramApiUrl = `https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`;
|
|
122
|
+
|
|
123
|
+
let lastError = null;
|
|
124
|
+
let rateLimitRetries = 0;
|
|
125
|
+
|
|
50
126
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
51
127
|
try {
|
|
128
|
+
// <-- FIXED: Added AbortSignal to prevent Firebase Function 60s freeze
|
|
129
|
+
const fetchOptions = {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { 'Content-Type': 'application/json' },
|
|
132
|
+
body: JSON.stringify(bodyPayload),
|
|
133
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined
|
|
134
|
+
};
|
|
135
|
+
|
|
52
136
|
const res = await fetch(telegramApiUrl, fetchOptions);
|
|
53
137
|
|
|
54
138
|
if (res.ok) {
|
|
@@ -57,49 +141,49 @@ export async function sendTelegramMessage(message, options = {}) {
|
|
|
57
141
|
|
|
58
142
|
const json = await res.json().catch(() => null);
|
|
59
143
|
|
|
144
|
+
// 1. Rate Limits (429)
|
|
60
145
|
if (res.status === 429 && json?.parameters?.retry_after) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
146
|
+
if (rateLimitRetries < maxRateLimitRetries) {
|
|
147
|
+
rateLimitRetries++;
|
|
148
|
+
const waitTimeMs = (json.parameters.retry_after + 1) * 1000;
|
|
149
|
+
console.warn(`Telegram Rate Limit hit. Waiting ${json.parameters.retry_after}s...`);
|
|
150
|
+
await new Promise((resolve) => setTimeout(resolve, waitTimeMs));
|
|
151
|
+
attempt--; // Retry attempt
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
67
154
|
}
|
|
68
155
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
errorMessage = json.description;
|
|
72
|
-
} else if (typeof json?.error === 'string') {
|
|
73
|
-
errorMessage = json.error;
|
|
74
|
-
} else if (json?.error?.message) {
|
|
75
|
-
errorMessage = json.error.message;
|
|
76
|
-
} else {
|
|
77
|
-
errorMessage = `HTTP ${res.status}: ${res.statusText || 'Unknown Error'}`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
throw new Error(`An error occurred while communicating with Telegram:\n${errorMessage}`);
|
|
81
|
-
} catch (err) {
|
|
82
|
-
const safeError = err instanceof Error ? err : new Error(String(err));
|
|
83
|
-
const msg = safeError.message || '';
|
|
156
|
+
const description = json?.description || json?.error || `HTTP ${res.status}: ${res.statusText}`;
|
|
157
|
+
const apiError = new Error(`Telegram API Error (${res.status}): ${description}`);
|
|
84
158
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
159
|
+
// 2. Permanent Client Errors (4xx) - DO NOT RETRY
|
|
160
|
+
if (res.status >= 400 && res.status < 500) {
|
|
161
|
+
if (throwOnError) throw apiError;
|
|
162
|
+
console.error(apiError);
|
|
88
163
|
return false;
|
|
89
164
|
}
|
|
90
165
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
166
|
+
// 3. Server Errors (5xx) - Save & Retry
|
|
167
|
+
lastError = apiError;
|
|
168
|
+
|
|
169
|
+
} catch (err) {
|
|
170
|
+
// If we threw a 4xx error intentionally above, rethrow it immediately
|
|
171
|
+
if (err instanceof Error && err.message.startsWith('Telegram API Error (4')) {
|
|
172
|
+
throw err;
|
|
95
173
|
}
|
|
96
174
|
|
|
175
|
+
// Capture network errors (e.g., fetch failed, DNS, connection timeout)
|
|
176
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Delay before retrying network/5xx errors
|
|
180
|
+
if (attempt < maxRetries - 1) {
|
|
97
181
|
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
|
|
98
182
|
}
|
|
99
183
|
}
|
|
100
184
|
|
|
101
|
-
const
|
|
102
|
-
if (throwOnError) throw
|
|
103
|
-
console.error(
|
|
185
|
+
const finalError = lastError || new Error('Telegram request failed after retries.');
|
|
186
|
+
if (throwOnError) throw finalError;
|
|
187
|
+
console.error(finalError);
|
|
104
188
|
return false;
|
|
105
189
|
}
|