diff --git a/DevServer/telegram-format.js b/DevServer/telegram-format.js new file mode 100644 index 0000000..d38c5b3 --- /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).replace(/,/g, '') + : '—'; +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]}\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 }, +]; + +// 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..49a8d37 --- /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\npersonal-phone\n09.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('Уведомление\n<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' + + 'Тест\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', () => { + 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\npersonal-phone\n09.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..2a22d84 100644 --- a/docs/en/n8n-telegram.md +++ b/docs/en/n8n-telegram.md @@ -81,49 +81,31 @@ 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 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**\ +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..4db02b3 100644 --- a/docs/ru/n8n-telegram.md +++ b/docs/ru/n8n-telegram.md @@ -82,49 +82,31 @@ 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