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
77 changes: 77 additions & 0 deletions DevServer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ The named volume stores the database and n8n-generated encryption key. Successes
visible in **Executions**, including submitted event bodies. The `error` workflow deliberately
returns an HTTP error; its n8n execution itself may still be marked successful.

## Portable workflow examples

The [five example levels](../docs/en/n8n-examples.md) ([на русском](../docs/ru/n8n-examples.md))
cover text/image webhooks without Telegram, basic Telegram, built-in image forwarding and the
advanced Rich Message workflow. Import the JSON from [examples/n8n/workflows](../examples/n8n/workflows),
not a configured export from a personal server. Credentials/chat/table IDs are intentionally absent.
The four workflows in this DevServer folder remain local fault-testing fixtures, not production templates.

DevServer also publishes the first two portable examples at `/webhook/message487/01-webhook-text`
and `/webhook/message487/02-webhook-images`, using the same local test token as `receive`.
The image receiver exposes decoded binary files in its execution output without any Telegram dependency.
Telegram templates are not automatically configured or published by DevServer.

## Android connection

The standard Android Emulator reaches the host through `10.0.2.2`. The debug app starts with the
Expand Down Expand Up @@ -103,3 +116,67 @@ docker compose -f DevServer/compose.yaml down -v
- [n8n Server CLI](https://docs.n8n.io/deploy/host-n8n/configure-n8n/use-the-command-line)
- [Owner provisioning](https://docs.n8n.io/deploy/host-n8n/configure-n8n/manage-settings-using-environment-variables)
- [Emulator networking](https://developer.android.com/studio/run/emulator-networking-address)

## Notification images

The receive webhook accepts the optional `body.images` array without changing the ACK contract.
It stays in the JSON request and execution history; the webhook does not automatically convert
it to n8n binary data. Each entry contains `mime_type`, `width`, `height`, `data_base64`, and `kind`
(`attachment`, `avatar`, or `app_icon`). Avatars and app icons are opt-in on the phone.
Filter by `kind` to route them separately from message attachments.
`presentation` is `big_picture` for BigPictureStyle, `gallery` for MessagingStyle image
attachments, and `file` for other images. Older queued images default to `file`.
MessagingStyle has no album ID: gallery grouping covers the extracted attachments of one notification.
Old/text-only events omit the array. A text-only workflow may accept an image-bearing event
without processing its images, so add the image-processing steps before acknowledging if
image forwarding is required.

For image-bearing events, use **Split Out** on `body.images` (include the original event ID),
then [Convert to File](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.converttofile/)
with **Move Base64 String to File**. The Base64 Input Field is `body.images.data_base64` if
Split Out retains that field path; MIME Type is `{{ $json.body.images.mime_type }}`.
Set a `.jpg` or `.png` file name matching that MIME type. Use an If node before splitting
so events without images still reach their text/ACK path. Process every image successfully
before sending an ACK with the original event ID. The [importable image workflows](../docs/en/n8n-examples.md) already include this processing
and route text-only requests without losing the ACK path.

Use synthetic images for development: the complete Base64 body is retained in n8n execution
history, just like text. The app defaults to 3 attachments, 256 KiB each and 512 KiB combined
before Base64; these output limits are configurable and can be disabled. Base64 increases size
by approximately one third. Image-bearing requests use HTTP chunked transfer; reverse proxies
must accept chunked request bodies and have sufficient body-size and upload-time limits.


## Telegram image routing

Run [telegram-images.js](telegram-images.js) in a Code node after formatting the text and
before assigning retry keys. All images attach to the first text part as one Rich Message:

- Big Picture becomes a photo block.
- MessagingStyle images become one collage (or one photo if only one remains).
- Avatars and app icons form a separate collage inside that same message.
- Other/legacy attachments become document blocks inside the message.

Use **Telegram Album 487 v0.2.1+**, operation **Send Rich Message**, Rich Message
`{{ $json.rich_message }}`, Binary Fields `{{ Object.keys($binary).join(",") }}`.
Preserve `binary` when assigning part keys and restore the pending loop item after the
deduplication lookup. Text parts retain `event_id:part_number`. Remaining parts of long text
use standard Send Message without repeating attachments. Text-only events also use Send Message.
There are no separate photo, album or document delivery branches in this workflow.
Telegram enforces Rich Message and collage limits; the ten-photo `sendMediaGroup` limit does
not determine Rich Message splitting. Failed media delivery must not be acknowledged as accepted.

Only remember a part key after successful Telegram delivery. Send the event ACK only after
all text and media parts are complete. As with the existing text workflow, delivery and key
persistence are not atomic: a crash between them can still produce a duplicate.

The built-in Telegram node supports uploading individual binary photos/documents, but its
media-group operation accepts URLs/file IDs rather than binary uploads. Install the MIT-licensed
[N8nTelegramNode487](https://github.com/andre487/N8nTelegramNode487) private extension for binary Rich Message uploads.
It reuses the Telegram API credential; no separate bot-token environment variable is needed.
For a private installation, its workflow type is `CUSTOM.telegramAlbum487`. The extension
must be installed on n8n main/worker processes before publishing workflows that use it.

Image content deduplication happens in Android: the notification fingerprint includes the
order-independent collection hash (bytes, roles and presentation). n8n part keys instead prevent
re-delivery of the same `event_id`; they do not suppress different events with matching images.
1 change: 1 addition & 0 deletions DevServer/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ services:
volumes:
- n8n-data:/home/node/.n8n
- ./:/bootstrap:ro
- ../examples/n8n:/examples/n8n:ro
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5678/healthz/readiness').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
Expand Down
18 changes: 18 additions & 0 deletions DevServer/import.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,21 @@ n8n import:workflow --separate --input=/bootstrap/workflows
for workflow in receive error slow invalid-ack; do
n8n publish:workflow --id="message487-$workflow"
done

# Adapt the two portable webhook examples to this disposable local server only.
node <<'JS'
const fs = require('node:fs');
fs.mkdirSync('/tmp/message487-examples', { recursive: true });
for (const slug of ['01-webhook-text', '02-webhook-images']) {
const workflow = JSON.parse(fs.readFileSync(`/examples/n8n/workflows/${slug}.json`));
workflow.id = `message487-example-${slug}`;
workflow.nodes.find(node => node.name === 'Webhook').credentials = {
httpHeaderAuth: { id: 'message487-header-auth', name: 'Message487 local webhook' },
};
fs.writeFileSync(`/tmp/message487-examples/${slug}.json`, JSON.stringify(workflow));
}
JS
n8n import:workflow --separate --input=/tmp/message487-examples
for workflow in 01-webhook-text 02-webhook-images; do
n8n publish:workflow --id="message487-example-$workflow"
done
2 changes: 1 addition & 1 deletion DevServer/start.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu

marker=/home/node/.n8n/message487-bootstrap-v2-auth
marker=/home/node/.n8n/message487-bootstrap-v3-examples
if [ ! -f "$marker" ]; then
/bin/sh /bootstrap/import.sh
touch "$marker"
Expand Down
59 changes: 59 additions & 0 deletions DevServer/telegram-images.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Attach all media to the first text part as one Rich Message.
const event = $('Webhook').first().json.body;
const images = event.images ?? [];
if (!Array.isArray(images)) throw new Error('Invalid images collection');
const result = $input.all();
const gallery = [];
const icons = [];
const binaryImage = (image, index) => {
if (!image || !['image/jpeg', 'image/png'].includes(image.mime_type) ||
typeof image.data_base64 !== 'string' || !image.data_base64.length ||
image.data_base64.length % 4 !== 0 ||
!/^[A-Za-z0-9+/]*={0,2}$/.test(image.data_base64)) {
throw new Error(`Invalid image ${index + 1}`);
}
const extension = image.mime_type === 'image/png' ? 'png' : 'jpg';
const kind = ['attachment', 'avatar', 'app_icon'].includes(image.kind) ? image.kind : 'attachment';
return { data: image.data_base64, mimeType: image.mime_type, fileName: `${kind}-${index + 1}.${extension}` };
};
const blocks = [];
const binary = {};
for (const [index, image] of images.entries()) {
binary[`image${index}`] = binaryImage(image, index);
const photo = { type: 'photo', photo: { type: 'photo', media: `attach://file${index}` } };
if (['avatar', 'app_icon'].includes(image.kind)) {
photo.caption = { text: image.kind === 'avatar' ? 'Аватар' : 'Значок приложения' };
icons.push(photo);
} else if (image.presentation === 'gallery') {
gallery.push(photo);
} else if (image.presentation === 'big_picture') {
blocks.push(photo);
} else {
blocks.push({ type: 'document', document: { type: 'document', media: `attach://file${index}` } });
}
}
for (const photos of [gallery, icons]) {
if (photos.length) blocks.push(photos.length > 1 ? { type: 'collage', blocks: photos } : photos[0]);
}
// Preserve the formatter's bold/italic text without interpreting escaped user HTML.
if (images.length) {
const first = result[0];
if (!first || typeof first.json.telegram_text !== 'string') throw new Error('Missing notification text');
let bold = false;
let italic = false;
const text = [];
for (const token of first.json.telegram_text.split(/(<\/?[bi]>)/)) {
if (token === '<b>' || token === '</b>') { bold = token === '<b>'; continue; }
if (token === '<i>' || token === '</i>') { italic = token === '<i>'; continue; }
if (!token) continue;
let value = token.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
if (italic) value = { type: 'italic', text: value };
if (bold) value = { type: 'bold', text: value };
text.push(value);
}
blocks.unshift({ type: 'paragraph', text });
first.json.telegram_operation = 'sendRichMessage';
first.json.rich_message = { blocks };
first.binary = binary;
}
return result;
13 changes: 13 additions & 0 deletions DevServer/telegram-standard-images.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Standard Telegram nodes send each image separately; no custom node is needed.
const result = $input.all();
const images = $('Webhook').first().json.body.images ?? [];
images.forEach((image, index) => {
const kind = ['avatar', 'app_icon'].includes(image.kind) ? image.kind : 'attachment';
result.push({ json: {
telegram_operation: kind === 'attachment' && ['big_picture', 'gallery'].includes(image.presentation)
? 'sendPhoto' : 'sendDocument',
caption: kind === 'avatar' ? 'Avatar' : kind === 'app_icon' ? 'Application icon' : '',
}, binary: { data: { data: image.data_base64, mimeType: image.mime_type,
fileName: `${kind}-${index + 1}.${image.mime_type === 'image/png' ? 'png' : 'jpg'}` } } });
});
return result;
114 changes: 114 additions & 0 deletions DevServer/tests/n8n-examples.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
const assert = require('node:assert/strict');
const { readFileSync, readdirSync } = require('node:fs');
const { resolve } = require('node:path');
const { test } = require('node:test');
const { runInNewContext } = require('node:vm');
const root = resolve(__dirname, '../../examples/n8n');
const files = readdirSync(`${root}/workflows`).filter(file => file.endsWith('.json'));
const load = file => JSON.parse(readFileSync(`${root}/workflows/${file}`, 'utf8'));
const text = JSON.parse(readFileSync(`${root}/payloads/text.json`, 'utf8'));
const images = JSON.parse(readFileSync(`${root}/payloads/images.json`, 'utf8'));
const execute = (workflow, name, event, input = []) => {
const script = workflow.nodes.find(node => node.name === name).parameters.jsCode;
return runInNewContext(`(function () { ${script}\n})()`, {
$: node => { assert.equal(node, 'Webhook'); return { first: () => ({ json: { body: event } }) }; },
$input: { all: () => input },
});
};

test('portable imports have connected nodes, authentication, placeholders and no exported account data', () => {
assert.equal(files.length, 6);
for (const file of files) {
const workflow = load(file);
assert.equal(workflow.active, false);
assert.deepEqual(Object.keys(workflow).sort(), ['active', 'connections', 'name', 'nodes', 'settings']);
const names = new Set(workflow.nodes.map(node => node.name));
assert.equal(names.size, workflow.nodes.length);
for (const [name, outputs] of Object.entries(workflow.connections)) {
assert.ok(names.has(name));
for (const branch of outputs.main) for (const edge of branch) assert.ok(names.has(edge.node));
}
for (const node of workflow.nodes) {
assert.equal(node.credentials, undefined);
if (node.type.endsWith('.webhook')) assert.equal(node.parameters.authentication, 'headerAuth');
if (node.parameters.chatId) assert.equal(node.parameters.chatId, "={{ $('Configure').first().json.chat_id }}");
if (node.type.endsWith('.dataTable')) {
assert.deepEqual(node.parameters.dataTableId, { __rl: true, mode: 'id',
value: "={{ $('Configure').first().json.sent_parts_table_id }}" });
}
if (node.name === 'Configure') {
for (const field of node.parameters.assignments.assignments) assert.match(field.value, /^REPLACE_WITH_/);
}
}
}
});

test('all receivers validate events; text-only levels intentionally ignore image content', () => {
for (const file of files.filter(file => !file.includes('cleanup'))) {
const workflow = load(file);
assert.equal(execute(workflow, 'Validate event', text)[0].json.valid, true);
for (const bad of [null, { ...text, text: null }, { ...text, schema_version: 2 }, { ...text, images: {} }]) {
assert.equal(execute(workflow, 'Validate event', bad)[0].json.valid, false);
}
assert.equal(execute(workflow, 'Validate event', images)[0].json.valid, true);
const invalidImage = { ...images, images: [{ ...images.images[0], data_base64: 'bad!' }] };
assert.equal(execute(workflow, 'Validate event', invalidImage)[0].json.valid, ['01', '03'].includes(file.slice(0, 2)));
assert.equal(workflow.connections['Valid event'].main[1][0].node, 'Reject');
assert.equal(workflow.nodes.find(node => node.name === 'Reject').parameters.options.responseCode, 400);
}
});

test('webhook images become binary files without requiring Telegram or a custom node', () => {
const workflow = load('02-webhook-images.json');
const result = execute(workflow, 'Read images', images)[0];
assert.equal(result.json.image_count, 5);
assert.equal(result.json.images[0].data_base64, undefined);
assert.ok(workflow.nodes.every(node => !/telegram/i.test(node.type)));
for (let index = 0; index < 5; index++) {
const file = result.binary[`image${index}`];
const bytes = Buffer.from(file.data, 'base64');
assert.equal(bytes.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
assert.equal(bytes.readUInt32BE(16), images.images[index].width);
assert.equal(bytes.readUInt32BE(20), images.images[index].height);
}
assert.equal(execute(workflow, 'Read images', text)[0].json.image_count, 0);
});

test('standard Telegram example preserves every image with only built-in nodes', () => {
const workflow = load('04-telegram-images-standard.json');
const formatted = execute(workflow, 'Prepare Telegram text', images);
const parts = execute(workflow, 'Prepare image parts', images, formatted);
assert.equal(parts.length, 6);
assert.equal(parts.slice(1).map(part => part.json.telegram_operation).join(','),
'sendPhoto,sendPhoto,sendPhoto,sendDocument,sendDocument');
assert.ok(workflow.nodes.every(node => node.type.startsWith('n8n-nodes-base.')));
parts.slice(1).forEach((part, index) => assert.equal(part.binary.data.data, images.images[index].data_base64));
assert.equal(workflow.connections['Loop Over Parts'].main[0][0].node, 'Respond');
});

test('advanced Telegram example keeps all media in the text message and remembers only successful parts', () => {
const workflow = load('05-telegram-images-advanced.json');
const formatted = execute(workflow, 'Prepare Telegram text', images);
const parts = execute(workflow, 'Prepare image parts', images, formatted);
assert.equal(parts.length, 1);
assert.equal(parts[0].json.telegram_operation, 'sendRichMessage');
assert.equal(parts[0].json.rich_message.blocks.map(block => block.type).join(','), 'paragraph,photo,collage,collage');
assert.equal(Object.keys(parts[0].binary).length, 5);
const keys = execute(workflow, 'Create Part Keys', images, parts);
assert.equal(keys[0].json.part_key, `${images.event_id}:1`);
assert.equal(Object.keys(keys[0].binary).length, 5);
for (const name of ['Send text', 'Send rich message']) {
assert.equal(workflow.connections[name].main[0][0].node, 'Remember part key');
assert.notEqual(workflow.nodes.find(node => node.name === name).onError, 'continueRegularOutput');
}
assert.equal(workflow.connections['Already sent'].main[0][0].node, 'Loop Over Parts');
assert.equal(workflow.connections['Loop Over Parts'].main[0][0].node, 'Respond');
assert.ok(!workflow.nodes.some(node => ['Send photo', 'Send file', 'Send album'].includes(node.name)));
});

test('optional cleanup starts as a dry run with an age filter', () => {
const node = load('05-telegram-cleanup.json').nodes.find(node => node.name === 'Delete old keys');
assert.equal(node.parameters.operation, 'deleteRows');
assert.equal(node.parameters.options.dryRun, true);
assert.equal(node.parameters.filters.conditions[0].keyName, 'createdAt');
});
20 changes: 20 additions & 0 deletions DevServer/tests/smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,26 @@ def main():
}, (code, body)
code, body = post('receive', {})
assert code == 400 and body['status'] == 'rejected', (code, body)
examples = Path(__file__).resolve().parents[2] / 'examples/n8n/payloads'
# Docker mounts the portable examples separately from /bootstrap.
if not examples.exists():
examples = Path('/examples/n8n/payloads')
for scenario in ('01-webhook-text', '02-webhook-images'):
code, body = post(scenario, event)
assert code == 200 and body['event_id'] == event['event_id'], (
scenario,
code,
body,
)
code, _ = post(scenario, event, token='wrong-token')
assert code in (401, 403), (scenario, code)
code, body = post(scenario, {})
assert code == 400, (scenario, code, body)
graphic_event = json.loads((examples / 'images.json').read_text())
code, body = post('01-webhook-text', graphic_event)
assert code == 200, (code, body)
code, body = post('02-webhook-images', graphic_event)
assert code == 200 and body['event_id'] == graphic_event['event_id'], (code, body)
code, body = post('error', event)
assert code == 500, (code, body)
code, body = post('invalid-ack', event)
Expand Down
Loading
Loading