From e963619847b75a75cf875b7c2bfb1948767c07a5 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 9 Sep 2026 12:42:03 +0300 Subject: [PATCH 1/2] Match Telegram formatting to Citadel bot --- DevServer/telegram-format.js | 67 ++++++++++++++++++++++ DevServer/tests/telegram-format.test.cjs | 72 ++++++++++++++++++++++++ docs/en/n8n-telegram.md | 59 +++++++------------ docs/ru/n8n-telegram.md | 59 +++++++------------ fastlane/Fastfile | 4 ++ 5 files changed, 181 insertions(+), 80 deletions(-) create mode 100644 DevServer/telegram-format.js create mode 100644 DevServer/tests/telegram-format.test.cjs diff --git a/DevServer/telegram-format.js b/DevServer/telegram-format.js new file mode 100644 index 0000000..84de987 --- /dev/null +++ b/DevServer/telegram-format.js @@ -0,0 +1,67 @@ +const timeZone = 'Europe/Moscow'; +const locale = 'ru-RU'; +const messageTypes = { sms: 'SMS', notification: 'Уведомление', test: 'Тест' }; +const event = $('Webhook').first().json.body; + +if (!event || event.schema_version !== 1 || + typeof event.event_id !== 'string' || !event.event_id || + !Object.hasOwn(messageTypes, event.message_type) || + typeof event.text !== 'string') { + throw new Error('Invalid Message487 event'); +} + +const escapeHtml = value => value.replace(/[&<>]/g, character => ({ + '&': '&', '<': '<', '>': '>', +})[character]); +const firstText = (...values) => values.find(value => typeof value === 'string' && value) || '—'; +const date = new Date(event.occurred_at); +const timestamp = typeof event.occurred_at === 'string' && !Number.isNaN(date.getTime()) + ? new Intl.DateTimeFormat(locale, { + timeZone, day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', + }).format(date) + : '—'; +const source = firstText(event.sender, event.source_name, event.source); +const device = firstText(event.device_code, event.device_id); +const segments = [ + { text: 'Message487: ', bold: false }, + { text: source, bold: true }, + { text: '\n', bold: false }, + { text: `${messageTypes[event.message_type]} ${device} ${timestamp}`, bold: true }, + { text: '\n', bold: false }, + { text: typeof event.title === 'string' && event.title ? `${event.title}\n` : '', bold: false }, + { text: event.text, bold: false }, +]; + +// Split unescaped text and close formatting in each part to keep HTML valid. +const chunks = []; +let html = ''; +let length = 0; +for (const segment of segments) { + let run = ''; + const flush = () => { + if (run) { + const escaped = escapeHtml(run); + html += segment.bold ? `${escaped}` : escaped; + run = ''; + } + }; + for (const character of segment.text) { + if (length + character.length > 3500) { + flush(); + chunks.push(html); + html = ''; + length = 0; + } + run += character; + length += character.length; + } + flush(); +} +if (html) chunks.push(html); + +return chunks.map((part, index) => ({ + json: { + telegram_text: chunks.length > 1 ? `[${index + 1}/${chunks.length}]\n${part}` : part, + }, +})); diff --git a/DevServer/tests/telegram-format.test.cjs b/DevServer/tests/telegram-format.test.cjs new file mode 100644 index 0000000..ba340c4 --- /dev/null +++ b/DevServer/tests/telegram-format.test.cjs @@ -0,0 +1,72 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { test } = require('node:test'); +const { runInNewContext } = require('node:vm'); + +const script = readFileSync(join(__dirname, '../telegram-format.js'), 'utf8'); +const base = { + schema_version: 1, event_id: 'synthetic-event', message_type: 'sms', + device_code: 'personal-phone', device_id: 'installation-id', + sender: '+79991234567', source: 'sms', source_name: 'SMS', + occurred_at: '2026-09-09T09:30:00Z', text: 'Your message', +}; +const format = changes => runInNewContext(`(function () { ${script}\n})()`, { + $: name => { + assert.equal(name, 'Webhook'); + return { first: () => ({ json: { body: { ...base, ...changes } } }) }; + }, +}).map(item => item.json.telegram_text); +const plain = html => html.replace(/<\/?b>/g, '') + .replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + +test('SMS uses the compact bold header and event time in Moscow', () => { + assert.equal(format({})[0], + 'Message487: +79991234567\n' + + 'SMS personal-phone 09.09.2026, 12:30:00\nYour message'); +}); + +test('Notification preserves title and escapes every input field', () => { + const result = format({ + message_type: 'notification', sender: undefined, + source_name: '', device_code: '', + title: 'Title', text: 'A & B < C', + })[0]; + assert.ok(result.startsWith('Message487: <App & Co>\n')); + assert.ok(result.includes('Уведомление <phone>')); + assert.ok(result.endsWith('<b>Title</b>\nA & B < C')); +}); + +test('Missing display fields fall back without inventing an event timestamp', () => { + const result = format({ + message_type: 'test', sender: undefined, source_name: undefined, + source: 'life.andre.message487', device_code: undefined, occurred_at: null, + })[0]; + assert.equal(result, 'Message487: life.andre.message487\n' + + 'Тест installation-id —\nYour message'); + assert.ok(format({ occurred_at: 'invalid' })[0].includes('personal-phone —')); +}); + +test('Long headers and body preserve text, Unicode and balanced HTML in each part', () => { + const sender = '😀<&>'.repeat(1500); + const text = 'Hello 📨 & \n'.repeat(1000); + const parts = format({ sender, text }); + assert.ok(parts.length > 2); + const restored = parts.map((part, index) => { + assert.ok(part.startsWith(`[${index + 1}/${parts.length}]\n`)); + assert.ok(plain(part).length <= 4096); + assert.equal((part.match(//g) || []).length, (part.match(/<\/b>/g) || []).length); + assert.ok(plain(part).isWellFormed()); + return plain(part.replace(/^\[\d+\/\d+\]\n/, '')); + }).join(''); + assert.equal(restored, `Message487: ${sender}\nSMS personal-phone 09.09.2026, 12:30:00\n${text}`); +}); + +test('Invalid events fail before producing Telegram messages', () => { + for (const changes of [ + { schema_version: 2 }, { event_id: '' }, { message_type: 'unknown' }, + { message_type: 'toString' }, { text: null }, + ]) { + assert.throws(() => format(changes), /Invalid Message487 event/); + } +}); diff --git a/docs/en/n8n-telegram.md b/docs/en/n8n-telegram.md index 981a60a..1167cde 100644 --- a/docs/en/n8n-telegram.md +++ b/docs/en/n8n-telegram.md @@ -81,49 +81,28 @@ Keep **Webhook → Respond → Using 'Respond to Webhook' Node**. If you importe and Telegram before Respond. Do not leave another branch acknowledging early. Add a **Code** node named `Prepare Telegram text`, select **JavaScript** and -**Run Once for All Items**, and paste: +**Run Once for All Items**, and paste the contents of: -```javascript -const event = $('Webhook').first().json.body; -if (!event || event.schema_version !== 1 || - typeof event.event_id !== 'string' || !event.event_id || - !['test', 'notification', 'sms'].includes(event.message_type) || - typeof event.text !== 'string') { - throw new Error('Invalid Message487 event'); -} - -const text = [ - `Device: ${event.device_code || event.device_id || '—'}`, - `Source: ${event.source_name || event.source || '—'}`, - `Type: ${event.message_type}`, - event.title ? `Title: ${event.title}` : '', - event.sender ? `Sender: ${event.sender}` : '', - event.text, -].filter(line => line !== '').join('\n'); - -const chunks = []; -let chunk = ''; -for (const character of text) { - if (chunk.length + character.length > 3500) { - chunks.push(chunk); - chunk = ''; - } - chunk += character; -} -if (chunk) chunks.push(chunk); -const escapeHtml = value => value.replace(/[&<>]/g, character => ({ - '&': '&', '<': '<', '>': '>', -})[character]); -return chunks.map((part, index) => ({ - json: { - telegram_text: escapeHtml(chunks.length > 1 - ? `[${index + 1}/${chunks.length}]\n${part}` : part), - }, -})); -``` +[telegram-format.js](../../DevServer/telegram-format.js). + +The format follows [citadel487-bot](https://github.com/andre487/citadel487-bot/blob/main/sms.go): +the source is bold on the first line; the type, device and time are bold on the second. +The app name is updated to Message487, and notification titles precede the body. +Example SMS: + +Message487: **+79991234567**\ +**SMS personal-phone 09.09.2026, 12:30:00**\ +Your message + +Adjust `timeZone`, `locale` and the labels in `messageTypes` at the top of the script. +The shared script defaults to Russian labels and Moscow time. It uses `occurred_at`, +not the workflow execution time; missing or invalid dates display `—`. +SMS uses the sender as its source; notifications use the app name with the package +identifier as a fallback. This preserves the complete text by splitting long events into several messages. -It does not split emoji UTF-16 pairs and escapes `<`, `>` and `&` for HTML. +It does not split emoji UTF-16 pairs and escapes incoming `<`, `>` and `&` for HTML. +Each part closes its bold tags independently. The chunk size leaves room for part numbers. Telegram accepts up to 4096 characters after entity parsing; see [sendMessage](https://core.telegram.org/bots/api#sendmessage). diff --git a/docs/ru/n8n-telegram.md b/docs/ru/n8n-telegram.md index a172376..9bc3f5f 100644 --- a/docs/ru/n8n-telegram.md +++ b/docs/ru/n8n-telegram.md @@ -82,49 +82,28 @@ flowchart LR подтвердит событие раньше отправки. Добавьте узел **Code**, имя `Prepare Telegram text`, язык **JavaScript**, режим -**Run Once for All Items**, и вставьте: +**Run Once for All Items**, и вставьте +содержимое файла ниже: -```javascript -const event = $('Webhook').first().json.body; -if (!event || event.schema_version !== 1 || - typeof event.event_id !== 'string' || !event.event_id || - !['test', 'notification', 'sms'].includes(event.message_type) || - typeof event.text !== 'string') { - throw new Error('Invalid Message487 event'); -} - -const text = [ - `Устройство: ${event.device_code || event.device_id || '—'}`, - `Источник: ${event.source_name || event.source || '—'}`, - `Тип: ${event.message_type}`, - event.title ? `Заголовок: ${event.title}` : '', - event.sender ? `Отправитель: ${event.sender}` : '', - event.text, -].filter(line => line !== '').join('\n'); - -const chunks = []; -let chunk = ''; -for (const character of text) { - if (chunk.length + character.length > 3500) { - chunks.push(chunk); - chunk = ''; - } - chunk += character; -} -if (chunk) chunks.push(chunk); -const escapeHtml = value => value.replace(/[&<>]/g, character => ({ - '&': '&', '<': '<', '>': '>', -})[character]); -return chunks.map((part, index) => ({ - json: { - telegram_text: escapeHtml(chunks.length > 1 - ? `[${index + 1}/${chunks.length}]\n${part}` : part), - }, -})); -``` +[telegram-format.js](../../DevServer/telegram-format.js). + +Формат повторяет структуру [citadel487-bot](https://github.com/andre487/citadel487-bot/blob/main/sms.go): +источник выделен жирным в первой строке, тип, устройство и время — во второй. +Название приложения заменено на Message487; заголовок уведомления идёт перед текстом. +Пример SMS: + +Message487: **+79991234567**\ +**SMS personal-phone 09.09.2026, 12:30:00**\ +Ваше сообщение + +В начале скрипта можно изменить `timeZone`, `locale` и названия типов в `messageTypes`. +Время берётся из `occurred_at`, а не из времени выполнения workflow; при отсутствии +или некорректной дате показывается `—`. Для SMS источником служит отправитель, +для уведомлений — имя приложения с запасным вариантом идентификатора пакета. Код сохраняет полный текст, разделяя длинное событие на несколько сообщений. -Эмодзи не разрезаются внутри UTF-16-пары; `<`, `>` и `&` экранируются для HTML. +Эмодзи не разрезаются внутри UTF-16-пары; `<`, `>` и `&` из входных данных +экранируются для HTML. В каждой части теги жирного текста закрыты. Запас по длине оставлен под номер части. Telegram допускает до 4096 символов после разбора entities; см. [sendMessage](https://core.telegram.org/bots/api#sendmessage). diff --git a/fastlane/Fastfile b/fastlane/Fastfile index d246bbd..54c77ec 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -45,6 +45,10 @@ platform :android do desc "Exercise the running development n8n server" lane :server_tests do + Dir.chdir(project_root) do + sh("docker", "compose", "-f", "DevServer/compose.yaml", "exec", "-T", "n8n", + "node", "--test", "/bootstrap/tests/telegram-format.test.cjs") + end sh(ENV.fetch("PYTHON", "python3"), File.join(project_root, "DevServer/tests/smoke.py")) end From 9a6275bd973c86fad2c7e14fc20c3b6a00c06131 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 9 Sep 2026 12:45:44 +0300 Subject: [PATCH 2/2] Put Telegram message details on separate lines --- DevServer/telegram-format.js | 4 ++-- DevServer/tests/telegram-format.test.cjs | 10 +++++----- docs/en/n8n-telegram.md | 7 +++++-- docs/ru/n8n-telegram.md | 7 +++++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/DevServer/telegram-format.js b/DevServer/telegram-format.js index 84de987..d38c5b3 100644 --- a/DevServer/telegram-format.js +++ b/DevServer/telegram-format.js @@ -19,7 +19,7 @@ const timestamp = typeof event.occurred_at === 'string' && !Number.isNaN(date.ge ? new Intl.DateTimeFormat(locale, { timeZone, day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', - }).format(date) + }).format(date).replace(/,/g, '') : '—'; const source = firstText(event.sender, event.source_name, event.source); const device = firstText(event.device_code, event.device_id); @@ -27,7 +27,7 @@ const segments = [ { text: 'Message487: ', bold: false }, { text: source, bold: true }, { text: '\n', bold: false }, - { text: `${messageTypes[event.message_type]} ${device} ${timestamp}`, bold: true }, + { text: `${messageTypes[event.message_type]}\n${device}\n${timestamp}`, bold: true }, { text: '\n', bold: false }, { text: typeof event.title === 'string' && event.title ? `${event.title}\n` : '', bold: false }, { text: event.text, bold: false }, diff --git a/DevServer/tests/telegram-format.test.cjs b/DevServer/tests/telegram-format.test.cjs index ba340c4..49a8d37 100644 --- a/DevServer/tests/telegram-format.test.cjs +++ b/DevServer/tests/telegram-format.test.cjs @@ -23,7 +23,7 @@ const plain = html => html.replace(/<\/?b>/g, '') test('SMS uses the compact bold header and event time in Moscow', () => { assert.equal(format({})[0], 'Message487: +79991234567\n' + - 'SMS personal-phone 09.09.2026, 12:30:00\nYour message'); + 'SMS\npersonal-phone\n09.09.2026 12:30:00\nYour message'); }); test('Notification preserves title and escapes every input field', () => { @@ -33,7 +33,7 @@ test('Notification preserves title and escapes every input field', () => { title: 'Title', text: 'A & B < C', })[0]; assert.ok(result.startsWith('Message487: <App & Co>\n')); - assert.ok(result.includes('Уведомление <phone>')); + assert.ok(result.includes('Уведомление\n<phone>')); assert.ok(result.endsWith('<b>Title</b>\nA & B < C')); }); @@ -43,8 +43,8 @@ test('Missing display fields fall back without inventing an event timestamp', () source: 'life.andre.message487', device_code: undefined, occurred_at: null, })[0]; assert.equal(result, 'Message487: life.andre.message487\n' + - 'Тест installation-id —\nYour message'); - assert.ok(format({ occurred_at: 'invalid' })[0].includes('personal-phone —')); + 'Тест\ninstallation-id\n—\nYour message'); + assert.ok(format({ occurred_at: 'invalid' })[0].includes('personal-phone\n—')); }); test('Long headers and body preserve text, Unicode and balanced HTML in each part', () => { @@ -59,7 +59,7 @@ test('Long headers and body preserve text, Unicode and balanced HTML in each par assert.ok(plain(part).isWellFormed()); return plain(part.replace(/^\[\d+\/\d+\]\n/, '')); }).join(''); - assert.equal(restored, `Message487: ${sender}\nSMS personal-phone 09.09.2026, 12:30:00\n${text}`); + assert.equal(restored, `Message487: ${sender}\nSMS\npersonal-phone\n09.09.2026 12:30:00\n${text}`); }); test('Invalid events fail before producing Telegram messages', () => { diff --git a/docs/en/n8n-telegram.md b/docs/en/n8n-telegram.md index 1167cde..2a22d84 100644 --- a/docs/en/n8n-telegram.md +++ b/docs/en/n8n-telegram.md @@ -86,12 +86,15 @@ Add a **Code** node named `Prepare Telegram text`, select **JavaScript** and [telegram-format.js](../../DevServer/telegram-format.js). The format follows [citadel487-bot](https://github.com/andre487/citadel487-bot/blob/main/sms.go): -the source is bold on the first line; the type, device and time are bold on the second. +the source is bold on the first line. The type, device and date/time are also bold, +each on its own line. The app name is updated to Message487, and notification titles precede the body. Example SMS: Message487: **+79991234567**\ -**SMS personal-phone 09.09.2026, 12:30:00**\ +**SMS**\ +**personal-phone**\ +**09.09.2026 12:30:00**\ Your message Adjust `timeZone`, `locale` and the labels in `messageTypes` at the top of the script. diff --git a/docs/ru/n8n-telegram.md b/docs/ru/n8n-telegram.md index 9bc3f5f..4db02b3 100644 --- a/docs/ru/n8n-telegram.md +++ b/docs/ru/n8n-telegram.md @@ -88,12 +88,15 @@ flowchart LR [telegram-format.js](../../DevServer/telegram-format.js). Формат повторяет структуру [citadel487-bot](https://github.com/andre487/citadel487-bot/blob/main/sms.go): -источник выделен жирным в первой строке, тип, устройство и время — во второй. +источник выделен жирным в первой строке. Тип, устройство и дата со временем +также выделены жирным и расположены каждый на отдельной строке. Название приложения заменено на Message487; заголовок уведомления идёт перед текстом. Пример SMS: Message487: **+79991234567**\ -**SMS personal-phone 09.09.2026, 12:30:00**\ +**SMS**\ +**personal-phone**\ +**09.09.2026 12:30:00**\ Ваше сообщение В начале скрипта можно изменить `timeZone`, `locale` и названия типов в `messageTypes`.