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.
Files changed (3) hide show
  1. package/package.json +3 -3
  2. package/product.js +42 -13
  3. package/telegram.js +139 -55
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tripstorekrabi-shared",
3
- "version": "1.25.9",
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.14",
22
- "nanoid": "^6.0.1",
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 "./price.js";
1
+ import { calculateProductSellingPrice, countMinPeople } from './price.js';
2
2
 
3
- const MATCH_HTML_REGEXP = /(<([^>]+)>)/ig;
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
- if (Array.isArray(product.destinations) && product.destinations.length > 0) {
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 (Array.isArray(product.tags)) {
75
- document.tags = product.tags;
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
- document.tags = [];
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
- if (product.from && product.to) {
81
- document.from = product.from;
82
- document.to = product.to;
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, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;');
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
- throwOnError = true
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 || ENV_BOT_TOKEN;
19
- const CHAT_ID = chatId || ENV_CHAT_ID;
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 the environment.";
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
- const note = "\n\n... [Message Truncated]";
32
- const usableLength = TELEGRAM_MAX_CHARS - note.length;
33
- finalMessage = finalMessage.substring(0, usableLength) + note;
34
- console.warn(`Message truncated from ${message.length} to ${TELEGRAM_MAX_CHARS} characters.`);
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 telegramApiUrl = `https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`;
38
- const fetchOptions = {
39
- method: 'POST',
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
- const retryAfterSeconds = json.parameters.retry_after;
62
- const waitTimeMs = (retryAfterSeconds + 1) * 1000;
63
-
64
- console.warn(`Telegram Rate Limit hit. Retrying after ${retryAfterSeconds}s...`);
65
- await new Promise((resolve) => setTimeout(resolve, waitTimeMs));
66
- continue;
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
- let errorMessage = 'Telegram API request failed';
70
- if (json?.description) {
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
- if (msg.includes('communicating with Telegram') && !msg.includes('429')) {
86
- if (throwOnError) throw safeError;
87
- console.error(safeError);
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
- if (attempt === maxRetries - 1) {
92
- if (throwOnError) throw safeError;
93
- console.error(safeError);
94
- return false;
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 timeoutError = new Error('Telegram request failed: Exceeded maximum retries.');
102
- if (throwOnError) throw timeoutError;
103
- console.error(timeoutError);
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
  }