Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions DevServer/telegram-format.js
Original file line number Diff line number Diff line change
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
})[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 ? `<b>${escaped}</b>` : 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,
},
}));
72 changes: 72 additions & 0 deletions DevServer/tests/telegram-format.test.cjs
Original file line number Diff line number Diff line change
@@ -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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');

test('SMS uses the compact bold header and event time in Moscow', () => {
assert.equal(format({})[0],
'Message487: <b>+79991234567</b>\n' +
'<b>SMS\npersonal-phone\n09.09.2026 12:30:00</b>\nYour message');
});

test('Notification preserves title and escapes every input field', () => {
const result = format({
message_type: 'notification', sender: undefined,
source_name: '<App & Co>', device_code: '<phone>',
title: '<b>Title</b>', text: 'A & B < C',
})[0];
assert.ok(result.startsWith('Message487: <b>&lt;App &amp; Co&gt;</b>\n'));
assert.ok(result.includes('Уведомление\n&lt;phone&gt;'));
assert.ok(result.endsWith('&lt;b&gt;Title&lt;/b&gt;\nA &amp; B &lt; 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: <b>life.andre.message487</b>\n' +
'<b>Тест\ninstallation-id\n—</b>\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 📨 & <world>\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(/<b>/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/);
}
});
62 changes: 22 additions & 40 deletions docs/en/n8n-telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
})[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).

Expand Down
62 changes: 22 additions & 40 deletions docs/ru/n8n-telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
})[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).

Expand Down
4 changes: 4 additions & 0 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading