Skip to content

Commit c3bef3c

Browse files
author
Cohen, Yohay
committed
Refactor Vite configuration to remove backend port definition, simplifying API root URL handling in development. Update Telegram bot service to support chunked message sending for long payloads, enhancing message delivery reliability. Replace custom message splitting logic in Telegram notifier with utility function for improved maintainability.
1 parent 1a7ae7c commit c3bef3c

7 files changed

Lines changed: 320 additions & 119 deletions

File tree

client/dev-dist/sw.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ define(['./workbox-46f6dd99'], (function (workbox) { 'use strict';
8282
"revision": "3ca0b8505b4bec776b69afdba2768812"
8383
}, {
8484
"url": "index.html",
85-
"revision": "0.hupt0fcl2eo"
85+
"revision": "0.30t30nqs6k"
8686
}], {});
8787
workbox.cleanupOutdatedCaches();
8888
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

client/src/lib/api.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import axios from 'axios';
22

3-
declare const __BACKEND_PORT__: string;
4-
53
/**
64
* Root URL for the REST API (no trailing slash). In production, respects Vite `BASE_URL` (GitHub Pages project sites).
5+
* In dev, use same-origin `/api` so requests go through the Vite proxy (matches `vite.config.ts` backend port).
76
*/
87
export function getApiRoot(): string {
98
if (import.meta.env.DEV) {
10-
const port = typeof __BACKEND_PORT__ !== 'undefined' ? __BACKEND_PORT__ : '3000';
11-
return `http://${window.location.hostname}:${port}/api`;
9+
return '/api';
1210
}
1311
const base = import.meta.env.BASE_URL;
1412
return base.endsWith('/') ? `${base}api` : `${base}/api`;

client/vite.config.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,6 @@ export default defineConfig(() => {
8282
optimizeDeps: {
8383
include: ['@app/shared'],
8484
},
85-
define: {
86-
'__BACKEND_PORT__': JSON.stringify(targetPort)
87-
},
8885
server: {
8986
port: 5173,
9087
host: '127.0.0.1',

server/src/routes/telegramRoutes.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,15 @@ router.get('/status', async (req: Request, res: Response) => {
174174
*/
175175
router.post('/send-test-message', async (req: Request, res: Response) => {
176176
try {
177-
const { chatId } = req.body || {};
178-
const result = await telegramBotService.sendTestMessage(chatId);
177+
const { chatId, testCharCount, testMode } = req.body || {};
178+
const parsedCount =
179+
testCharCount != null && testCharCount !== ''
180+
? Math.floor(Number(testCharCount))
181+
: undefined;
182+
const result = await telegramBotService.sendTestMessage(chatId, {
183+
testCharCount: parsedCount,
184+
mode: testMode === 'html' ? 'html' : 'plain',
185+
});
179186
if (result.errors.length > 0 && result.sent === 0) {
180187
return res.status(500).json({ success: false, error: result.errors[0] });
181188
}

server/src/services/notifications/telegramNotifier.ts

Lines changed: 2 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { BaseNotifier } from './baseNotifier.js';
77
import { NotificationPayload, NotifierConfig } from './types.js';
88
import axios from 'axios';
99
import { serverLogger } from '../../utils/logger.js';
10+
import { splitTelegramPlainText } from '../../utils/telegramTextSplit.js';
1011

1112
export interface TelegramNotifierConfig extends Omit<NotifierConfig, 'enabled'> {
1213
enabled?: boolean;
@@ -103,7 +104,7 @@ export class TelegramNotifier extends BaseNotifier {
103104
}
104105

105106
const message = this.formatMessage(payload);
106-
const chunks = this.splitMessageForTelegram(message, 3800);
107+
const chunks = splitTelegramPlainText(message, 3800);
107108

108109
for (const chatId of this.chatIds) {
109110
let sent = 0;
@@ -169,87 +170,6 @@ export class TelegramNotifier extends BaseNotifier {
169170
return this.L('sourceManual');
170171
}
171172

172-
/**
173-
* Split long messages into chunks within Telegram-safe size.
174-
* Prefer split points at newline, sentence boundary, then whitespace.
175-
* Avoids splitting in the middle of MarkdownV2 escapes (\) or UTF-16 surrogate pairs.
176-
*/
177-
private splitMessageForTelegram(message: string, maxLen: number): string[] {
178-
const text = String(message || '');
179-
if (text.length <= maxLen) return [text];
180-
181-
const chunks: string[] = [];
182-
let remaining = text;
183-
184-
while (remaining.length > maxLen) {
185-
let splitAt = this.findBestSplitPoint(remaining, maxLen);
186-
if (splitAt <= 0 || splitAt > remaining.length) {
187-
splitAt = Math.min(maxLen, remaining.length);
188-
}
189-
splitAt = this.adjustSplitForSafeBoundary(remaining, splitAt);
190-
191-
const chunk = remaining.slice(0, splitAt).trim();
192-
if (chunk.length > 0) {
193-
chunks.push(chunk);
194-
}
195-
remaining = remaining.slice(splitAt).trim();
196-
}
197-
198-
if (remaining.length > 0) {
199-
chunks.push(remaining);
200-
}
201-
202-
return chunks;
203-
}
204-
205-
/**
206-
* Ensure we don't split in the middle of a MarkdownV2 escape (backslash + char)
207-
* or in the middle of a UTF-16 surrogate pair (emoji).
208-
*/
209-
private adjustSplitForSafeBoundary(text: string, splitAt: number): number {
210-
if (splitAt <= 0 || splitAt >= text.length) return splitAt;
211-
let pos = splitAt;
212-
// Don't end chunk with a single backslash (would break MarkdownV2 escape in Telegram).
213-
while (pos > 0 && text[pos - 1] === '\\') {
214-
pos--;
215-
}
216-
// Don't split in the middle of a surrogate pair (low surrogate is 0xD800-0xDBFF, high is 0xDC00-0xDFFF).
217-
const atPos = text.charCodeAt(pos);
218-
if (pos < text.length && atPos >= 0xDC00 && atPos <= 0xDFFF && pos > 0) {
219-
pos--;
220-
}
221-
return Math.max(1, pos);
222-
}
223-
224-
private findBestSplitPoint(text: string, maxLen: number): number {
225-
const safeMax = Math.min(maxLen, text.length);
226-
const candidate = text.slice(0, safeMax + 1);
227-
228-
// Prefer newline boundaries.
229-
const newline = candidate.lastIndexOf('\n');
230-
if (newline >= Math.floor(safeMax * 0.6)) {
231-
return newline + 1;
232-
}
233-
234-
// Prefer sentence boundaries.
235-
for (let i = safeMax; i >= Math.floor(safeMax * 0.6); i--) {
236-
const c = candidate[i];
237-
if (!c) continue;
238-
if (c === '.' || c === '!' || c === '?' || c === '…' || c === ';') {
239-
return i + 1;
240-
}
241-
}
242-
243-
// Fallback to whitespace boundary.
244-
const ws = Math.max(candidate.lastIndexOf(' '), candidate.lastIndexOf('\t'));
245-
if (ws >= Math.floor(safeMax * 0.6)) {
246-
return ws + 1;
247-
}
248-
249-
// Last resort: hard split (adjustSplitForSafeBoundary will fix escape/surrogate).
250-
return safeMax;
251-
}
252-
253173
/**
254174
* Format payload as Telegram MarkdownV2 message
255175
*/

server/src/services/telegramBotService.ts

Lines changed: 85 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Profile, ScrapeRequest, ScrapeResult, type Transaction, type Transactio
1818
import { transactionsToCsv, transactionsToJson } from '@app/shared';
1919
import { postScrapeService } from './postScrapeService.js';
2020
import { buildUnifiedChatQueryWithMemory, mergeAndPersistAiMemory } from './unifiedAiChatMemory.js';
21+
import { getTelegramMaxMessageChars, splitTelegramHtmlChunks, splitTelegramPlainText } from '../utils/telegramTextSplit.js';
2122

2223
const __filename = fileURLToPath(import.meta.url);
2324
const __dirname = path.dirname(__filename);
@@ -1091,8 +1092,12 @@ export class TelegramBotService {
10911092

10921093
if (statusMsg && chatIdNum) {
10931094
const body = summaryLines.length > 0 ? summaryLines.join('\n') : this.t('errorScraper');
1094-
const finalText = `${this.t('scraperSuccess')}\n\n${body}`.slice(0, 4096);
1095-
await this.editTelegramStatusMessage(ctx, chatIdNum, statusMsg.message_id, finalText);
1095+
const finalText = `${this.t('scraperSuccess')}\n\n${body}`;
1096+
const summaryChunks = splitTelegramPlainText(finalText, getTelegramMaxMessageChars());
1097+
await this.editTelegramStatusMessage(ctx, chatIdNum, statusMsg.message_id, summaryChunks[0]);
1098+
for (let i = 1; i < summaryChunks.length; i++) {
1099+
await ctx.reply(summaryChunks[i]);
1100+
}
10961101
}
10971102

10981103
if (results.length > 0) {
@@ -1306,30 +1311,26 @@ export class TelegramBotService {
13061311
this.chatStates.set(chatIdNum.toString(), updatedState);
13071312
}
13081313

1309-
// If response already contains HTML tags, use as-is; otherwise convert Markdown to HTML
1314+
// If response already contains HTML tags, use as-is; otherwise convert Markdown to HTML per chunk
13101315
const looksLikeHtml = /<\/?\w+[^>]*>/.test(response);
1311-
const htmlResponse = looksLikeHtml ? response : this.convertMarkdownToHtml(response);
1316+
const htmlChunks: string[] = looksLikeHtml
1317+
? splitTelegramHtmlChunks(response, 4080)
1318+
: splitTelegramPlainText(response, 3400).flatMap((ch) => {
1319+
const h = this.convertMarkdownToHtml(ch);
1320+
return h.length > 4080 ? splitTelegramHtmlChunks(h, 4080) : [h];
1321+
});
13121322

1313-
if (htmlResponse.length > 4096) {
1314-
const chunks = htmlResponse.match(/[\s\S]{1,4096}/g) || [];
1315-
// Edit first chunk into the thinking message
1316-
try {
1317-
// @ts-ignore
1318-
await ctx.telegram.editMessageText(chatIdNum, thinkingMsg.message_id, undefined, chunks[0], { parse_mode: 'HTML' });
1319-
} catch (e) {
1320-
// If edit fails, send as a new message
1321-
await ctx.reply(String(chunks[0]), { parse_mode: 'HTML' });
1322-
}
1323-
for (let i = 1; i < chunks.length; i++) {
1324-
await ctx.reply(String(chunks[i]), { parse_mode: 'HTML' });
1325-
}
1326-
} else {
1327-
try {
1328-
// @ts-ignore
1329-
await ctx.telegram.editMessageText(chatIdNum, thinkingMsg.message_id, undefined, htmlResponse, { parse_mode: 'HTML' });
1330-
} catch (e) {
1331-
await ctx.reply(String(htmlResponse), { parse_mode: 'HTML' });
1332-
}
1323+
const chunks = htmlChunks.length > 0 ? htmlChunks : [this.convertMarkdownToHtml(response || this.t('noAiResponse'))];
1324+
const firstChunk = chunks[0];
1325+
const restChunks = chunks.slice(1);
1326+
1327+
try {
1328+
await ctx.telegram.editMessageText(chatIdNum, thinkingMsg.message_id, undefined, firstChunk, { parse_mode: 'HTML' });
1329+
} catch (e) {
1330+
await ctx.reply(String(firstChunk), { parse_mode: 'HTML' });
1331+
}
1332+
for (const part of restChunks) {
1333+
await ctx.reply(String(part), { parse_mode: 'HTML' });
13331334
}
13341335
} catch (error) {
13351336
serverLogger.error('Error in AI chat', { error });
@@ -2095,24 +2096,81 @@ export class TelegramBotService {
20952096
return this.isRunning;
20962097
}
20972098

2099+
/**
2100+
* Deterministic long payload for exercising Telegram chunking (plain or HTML).
2101+
*/
2102+
private buildLargeTestTelegramPayload(targetLen: number, mode: 'plain' | 'html'): string {
2103+
const cap = Math.min(50000, Math.max(1, Math.floor(targetLen)));
2104+
if (cap < 64) {
2105+
return 'x'.repeat(cap);
2106+
}
2107+
let s = mode === 'html' ? '<b>LARGE-TEST</b>\n' : 'LARGE-TEST\n';
2108+
let n = 0;
2109+
while (s.length < cap) {
2110+
const line = mode === 'html' ? `<i>${n}</i> ${'x'.repeat(32)}\n` : `LINE ${n} ${'x'.repeat(48)}\n`;
2111+
const room = cap - s.length;
2112+
if (line.length > room) {
2113+
s += mode === 'html' ? 'x'.repeat(room) : line.slice(0, room);
2114+
break;
2115+
}
2116+
s += line;
2117+
n++;
2118+
}
2119+
return s.slice(0, cap);
2120+
}
2121+
20982122
/**
20992123
* Send a test message to the given chat(s). If chatId is provided, send only to that chat;
21002124
* otherwise send to all notification chat IDs.
2125+
* Optional `testCharCount` (1–50000) sends a large payload split the same way as production long messages.
21012126
*/
2102-
async sendTestMessage(chatId?: string): Promise<{ sent: number; errors: string[] }> {
2127+
async sendTestMessage(
2128+
chatId?: string,
2129+
options?: { testCharCount?: number; mode?: 'plain' | 'html' }
2130+
): Promise<{ sent: number; errors: string[] }> {
21032131
if (!this.bot || !this.isRunning) {
21042132
throw new Error('Telegram bot is not running. Start the bot first.');
21052133
}
21062134
const targetIds = chatId ? [chatId] : this.config.notificationChatIds;
21072135
if (targetIds.length === 0) {
21082136
throw new Error('No notification chats configured. Add at least one user to the Notification column.');
21092137
}
2110-
const text = '✅ Test message from Israeli Bank Scraper. If you see this, notifications are working.';
2138+
const rawCount = options?.testCharCount;
2139+
const count =
2140+
rawCount != null && Number.isFinite(Number(rawCount)) ? Math.floor(Number(rawCount)) : 0;
2141+
const mode = options?.mode === 'html' ? 'html' : 'plain';
2142+
21112143
const errors: string[] = [];
21122144
let sent = 0;
2145+
2146+
if (count <= 0) {
2147+
const text = '✅ Test message from Israeli Bank Scraper. If you see this, notifications are working.';
2148+
for (const id of targetIds) {
2149+
try {
2150+
await this.bot.telegram.sendMessage(id, text);
2151+
sent++;
2152+
} catch (err: any) {
2153+
errors.push(`${id}: ${err?.message || err}`);
2154+
}
2155+
}
2156+
return { sent, errors };
2157+
}
2158+
2159+
const payload = this.buildLargeTestTelegramPayload(count, mode);
2160+
const chunks =
2161+
mode === 'html'
2162+
? splitTelegramHtmlChunks(payload, 4080)
2163+
: splitTelegramPlainText(payload, getTelegramMaxMessageChars());
2164+
21132165
for (const id of targetIds) {
21142166
try {
2115-
await this.bot.telegram.sendMessage(id, text);
2167+
for (const chunk of chunks) {
2168+
await this.bot.telegram.sendMessage(
2169+
id,
2170+
chunk,
2171+
mode === 'html' ? { parse_mode: 'HTML' } : {}
2172+
);
2173+
}
21162174
sent++;
21172175
} catch (err: any) {
21182176
errors.push(`${id}: ${err?.message || err}`);

0 commit comments

Comments
 (0)