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