diff --git a/DevServer/README.md b/DevServer/README.md
index 9bd18a0..07746a0 100644
--- a/DevServer/README.md
+++ b/DevServer/README.md
@@ -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
@@ -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.
diff --git a/DevServer/compose.yaml b/DevServer/compose.yaml
index 7324436..cf6d80a 100644
--- a/DevServer/compose.yaml
+++ b/DevServer/compose.yaml
@@ -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
diff --git a/DevServer/import.sh b/DevServer/import.sh
index 53f8d54..d1e78e3 100644
--- a/DevServer/import.sh
+++ b/DevServer/import.sh
@@ -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
diff --git a/DevServer/start.sh b/DevServer/start.sh
index 8c9d139..9a5741c 100644
--- a/DevServer/start.sh
+++ b/DevServer/start.sh
@@ -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"
diff --git a/DevServer/telegram-images.js b/DevServer/telegram-images.js
new file mode 100644
index 0000000..51e1581
--- /dev/null
+++ b/DevServer/telegram-images.js
@@ -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 === '' || token === '') { bold = token === ''; continue; }
+ if (token === '' || token === '') { italic = token === ''; continue; }
+ if (!token) continue;
+ let value = token.replace(/</g, '<').replace(/>/g, '>').replace(/&/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;
diff --git a/DevServer/telegram-standard-images.js b/DevServer/telegram-standard-images.js
new file mode 100644
index 0000000..178727b
--- /dev/null
+++ b/DevServer/telegram-standard-images.js
@@ -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;
diff --git a/DevServer/tests/n8n-examples.test.cjs b/DevServer/tests/n8n-examples.test.cjs
new file mode 100644
index 0000000..b69622a
--- /dev/null
+++ b/DevServer/tests/n8n-examples.test.cjs
@@ -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');
+});
diff --git a/DevServer/tests/smoke.py b/DevServer/tests/smoke.py
index 7171501..d40d61d 100644
--- a/DevServer/tests/smoke.py
+++ b/DevServer/tests/smoke.py
@@ -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)
diff --git a/DevServer/tests/telegram-images.test.cjs b/DevServer/tests/telegram-images.test.cjs
new file mode 100644
index 0000000..83c1376
--- /dev/null
+++ b/DevServer/tests/telegram-images.test.cjs
@@ -0,0 +1,84 @@
+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-images.js'), 'utf8');
+const run = images => runInNewContext(`(function () { ${script}\n})()`, {
+ $: () => ({ first: () => ({ json: { body: { images } } }) }),
+ $input: { all: () => [{ json: { telegram_text: 'Existing formatted text' } }] },
+});
+const image = (presentation, kind = 'attachment') => ({
+ mime_type: 'image/png', kind, presentation, data_base64: 'AAAA',
+});
+test('text-only requests and unknown legacy attachments retain their meaning', () => {
+ assert.equal(run(undefined).length, 1);
+ const encoded = Buffer.alloc(3 * 1024 * 1024, 47).toString('base64');
+ const result = run([{ mime_type: 'image/jpeg', kind: 'attachment', data_base64: encoded }]);
+ assert.equal(result[0].json.telegram_text, 'Existing formatted text');
+ assert.equal(result.length, 1);
+ assert.equal(result[0].json.telegram_operation, 'sendRichMessage');
+ assert.equal(result[0].json.rich_message.blocks[1].type, 'document');
+ assert.equal(result[0].binary.image0.data, encoded);
+});
+test('BigPicture stays a photo while both icons form a collage inside the text message', () => {
+ const result = run([image('big_picture'), image('big_picture', 'avatar'), image('gallery', 'app_icon')]);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].json.telegram_operation, 'sendRichMessage');
+ const blocks = result[0].json.rich_message.blocks;
+ assert.equal(blocks.length, 3);
+ assert.equal(blocks[1].type, 'photo');
+ assert.equal(blocks[1].photo.media, 'attach://file0');
+ assert.equal(blocks[2].type, 'collage');
+ const photos = blocks[2].blocks;
+ assert.equal(photos.length, 2);
+ assert.equal(photos[0].type, 'photo');
+ assert.equal(photos[0].photo.media, 'attach://file1');
+ assert.equal(photos[1].photo.media, 'attach://file2');
+ assert.equal(photos[1].caption.text, 'Значок приложения');
+ assert.equal(Object.keys(result[0].binary).join(','), 'image0,image1,image2');
+ assert.equal(result[0].json.image_key, undefined);
+});
+test('gallery stays a single collage attached to the text even above ten images', () => {
+ const result = run(Array.from({ length: 21 }, () => image('gallery')));
+ assert.equal(result.length, 1);
+ const collage = result[0].json.rich_message.blocks[1];
+ assert.equal(collage.type, 'collage');
+ assert.equal(collage.blocks.length, 21);
+ collage.blocks.forEach((photo, index) => {
+ assert.equal(photo.photo.media, `attach://file${index}`);
+ assert.ok(result[0].binary[`image${index}`]);
+ });
+});
+test('all image types are in the main message with unique attachment references', () => {
+ const result = run([image('big_picture'), image('gallery'), image('gallery'),
+ image('file'), image('file', 'avatar'), image('file', 'app_icon')]);
+ assert.equal(result.length, 1);
+ const blocks = result[0].json.rich_message.blocks;
+ assert.equal(blocks.map(block => block.type).join(','), 'paragraph,photo,document,collage,collage');
+ const references = blocks.slice(1).flatMap(block => block.blocks ?? [block])
+ .map(block => (block.photo ?? block.document).media);
+ assert.equal(new Set(references).size, 6);
+ assert.equal(Object.keys(result[0].binary).length, 6);
+});
+test('invalid image collection fails before any part can be sent', () => {
+ for (const images of [{}, [null], [{ mime_type: 'image/gif', data_base64: 'AAAA' }],
+ [{ mime_type: 'image/png', data_base64: 'invalid!' }],
+ [{ mime_type: 'image/png', data_base64: '' }]]) assert.throws(() => run(images), /Invalid image/);
+});
+
+test('icon formatting preserves literal user HTML and only attaches to the first text part', () => {
+ const parts = [{ json: { telegram_text: 'App & Co\n<b>user</b>' } },
+ { json: { telegram_text: 'Remaining text' } }];
+ const result = runInNewContext(`(function () { ${script}\n})()`, {
+ $: () => ({ first: () => ({ json: { body: { images: [image('file', 'avatar')] } } }) }),
+ $input: { all: () => parts },
+ });
+ const text = result[0].json.rich_message.blocks[0].text;
+ assert.equal(text[0].type, 'bold');
+ assert.equal(text[0].text, 'App & Co');
+ assert.equal(text[1], '\nuser');
+ assert.equal(result[0].json.rich_message.blocks[1].type, 'photo');
+ assert.equal(result[1].json.telegram_text, 'Remaining text');
+ assert.equal(result[1].binary, undefined);
+});
diff --git a/DevServer/workflow-images.js b/DevServer/workflow-images.js
new file mode 100644
index 0000000..48f7465
--- /dev/null
+++ b/DevServer/workflow-images.js
@@ -0,0 +1,10 @@
+// The previous validation node checks image metadata and Base64 before this step.
+const event = $('Webhook').first().json.body;
+const binary = {};
+const images = (event.images ?? []).map((image, index) => {
+ const { data_base64, ...metadata } = image;
+ binary[`image${index}`] = { data: data_base64, mimeType: image.mime_type,
+ fileName: `${['avatar', 'app_icon'].includes(image.kind) ? image.kind : 'attachment'}-${index + 1}.${image.mime_type === 'image/png' ? 'png' : 'jpg'}` };
+ return metadata;
+});
+return [{ json: { ...event, images, image_count: images.length }, binary }];
diff --git a/DevServer/workflow-validation.js b/DevServer/workflow-validation.js
new file mode 100644
index 0000000..7fbfac0
--- /dev/null
+++ b/DevServer/workflow-validation.js
@@ -0,0 +1,14 @@
+const event = $('Webhook').first().json.body;
+let reason = '';
+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') reason = 'Invalid Message487 event';
+const images = event?.images ?? [];
+if (!Array.isArray(images)) reason = 'images must be an array';
+else if (processImages && images.some(image => !image || !['image/jpeg', 'image/png'].includes(image.mime_type) ||
+ !Number.isInteger(image.width) || image.width <= 0 || !Number.isInteger(image.height) || image.height <= 0 ||
+ 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))) {
+ reason = 'Invalid JPEG/PNG image';
+}
+return [{ json: { valid: !reason, reason, event_id: event?.event_id ?? null } }];
diff --git a/PRIVACY.md b/PRIVACY.md
index 8b8e98f..b258f2e 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -15,28 +15,32 @@ The app does not read existing SMS history, send SMS, or reply to notifications.
The app stores your webhook URL, editable device code, confirmation preference, enabled sources,
selected and previously detected package names, the automatic-selection preference, duplicate-filter
-preference, pause state and a random installation ID in private preferences. These
+preference, pause state, notification image format/quality and limits, avatar/app-icon forwarding preferences and a random installation ID in private preferences. These
preferences do not have additional application-level encryption. A webhook URL may itself contain
a secret, so treat it as sensitive. Android cloud backup and device transfer are disabled for app data.
The webhook Bearer token is encrypted with AES-GCM and an Android Keystore key before
being saved in preferences. It is sent in the Authorization header to your configured endpoint.
-Captured event bodies, including message text, notification titles and SMS senders, are stored in
-a private SQLite outbox encrypted with AES-GCM and a key held in Android Keystore. Each queued
-request includes its original destination, encrypted authentication token and confirmation mode. Delivery metadata (event ID,
-source display name, type, timestamps, state, attempt count and HTTP result) is stored without
-additional application-level encryption. Message duplicate detection stores SHA-256 hashes of source package and text together with original event timestamps
+Message text, notification titles, SMS senders and the queued request description (including
+its original destination, authentication token and confirmation mode) are stored in private files
+encrypted with AES-GCM and an Android Keystore key. Notification images are stored separately
+as JPEG/PNG files in private internal storage without additional application-level encryption;
+Android storage isolation and device encryption apply. These files are under `noBackupFilesDir`
+and are not exposed by the app’s FileProvider or added to the media gallery. Temporary image
+files have the same storage protection. Older encrypted database payloads migrate to this layout
+on their next delivery attempt. SQLite stores delivery metadata (event ID, source display name,
+type, timestamps, state, attempt count and HTTP result) and a private file reference without
+additional application-level encryption. Message duplicate detection stores SHA-256 hashes of source package, text and an order-independent SHA-256 checksum of encoded image content, roles and presentation, together with original event timestamps
(and retains hashes from the older exact-timestamp filter),
including when filtering is disabled. These hashes remain after delivery or journal deletion until
app data is cleared or the app is uninstalled. They are not a substitute for encryption against
guesses of known content.
-The journal shows delivery metadata and does not display message contents. Confirmed events have
-their encrypted payload removed from the active database record; only recent delivery metadata is
-retained. Undelivered payloads are not automatically deleted by age. Database deletion is logical
-and does not guarantee forensic erasure of previously allocated storage.
-Message bodies and server response bodies are not written to diagnostic logs by the app.
+The journal shows delivery metadata and does not display message contents or image previews. Confirmed events have
+their request and image files removed; only recent delivery metadata is retained. Undelivered payloads are not automatically deleted by age. Abandoned temporary files are cleaned during recovery after a grace period. File and database
+deletion do not guarantee forensic erasure of previously allocated storage.
+Message bodies, image data and server response bodies are not written to diagnostic logs by the app.
## Diagnostics and voluntary email reports
@@ -65,8 +69,15 @@ copies held by your email provider remain subject to that provider's retention p
Your selected endpoint receives event text, an event ID, installation ID, device code, timestamp,
schema version, event type, source package identifier and source display name. Notifications add
-a title; SMS add the sender address. Manual connection tests send synthetic content through the
-same queue. Android may redact notification content before giving it to the app.
+a title and available image attachments, encoded as JPEG or PNG in Base64; SMS add the sender address.
+Image format and JPEG quality are configurable in Sources (default JPEG, quality 80%).
+Avatars and source app icons are sent only when their separate switches in Sources are enabled
+(both off by default). The avatar is the latest available sender avatar or notification large icon.
+Output size, dimensions and attachment count are configurable in Sources → Graphics;
+limits are enabled by default and can be disabled. Input decoding safeguards remain active.
+Images may be resized to fit enabled limits. Unreadable images
+are skipped with a local capture error. Manual connection tests send synthetic content and, when avatar/app-icon forwarding is enabled,
+the Message487 logo through the same queue. Android may redact notification content before giving it to the app.
The endpoint operator can also see connection metadata such as your IP address. The endpoint and
any downstream services process data under their own policies. An n8n instance may retain complete
diff --git a/README.md b/README.md
index 1e1644f..fbcbd20 100644
--- a/README.md
+++ b/README.md
@@ -13,9 +13,26 @@ Message487 connects selected Android notifications and incoming SMS to your n8n
A custom webhook is also supported. Telegram forwarding is one possible workflow; the Android
app does not depend on Telegram.
-**Status:** development preview with notification/SMS capture, a persistent encrypted outbox,
+**Status:** development preview with notification/SMS capture, a persistent private outbox,
background delivery, automatic retries and a delivery journal. Webhook requests require a Bearer token, stored encrypted on the device. Signed APK release automation is configured; see [Releases](docs/en/releases.md) and [release automation](docs/en/release-automation.md), and [release testing](docs/en/release-testing.md).
+## n8n workflow examples
+
+Choose an [English guide](docs/en/n8n-examples.md) or [инструкцию на русском](docs/ru/n8n-examples.md).
+Every workflow is self-contained, inactive on import and contains placeholders rather than personal data.
+
+| Level | Importable workflow |
+| --- | --- |
+| Text webhook, no Telegram | [01 · Webhook text](examples/n8n/workflows/01-webhook-text.json) |
+| Text + images webhook, no Telegram | [02 · Webhook images](examples/n8n/workflows/02-webhook-images.json) |
+| Telegram text | [03 · Telegram text](examples/n8n/workflows/03-telegram-text.json) |
+| Telegram images, built-in nodes | [04 · Telegram images standard](examples/n8n/workflows/04-telegram-images-standard.json) |
+| Telegram Rich Messages, all media + retry progress | [05 · Telegram advanced](examples/n8n/workflows/05-telegram-images-advanced.json) |
+
+Level 5 uses the MIT-licensed [N8nTelegramNode487](https://github.com/andre487/N8nTelegramNode487)
+and a Data Table; it includes an optional [sent-key cleanup workflow](examples/n8n/workflows/05-telegram-cleanup.json).
+Levels 1–4 require only built-in n8n nodes. Import instructions include synthetic payloads and setup steps.
+
## Getting started
**[Download the latest signed APK](https://github.com/andre487/AndroidMessage487/releases/latest/download/message487.apk)**
@@ -60,7 +77,8 @@ forwards only the content the system exposes.
**Skip duplicate SMS** and **Skip duplicate notifications** in **Sources** are independently
enabled by default and share the time window. SMS and notifications are
-compared by source package and exact text (including whitespace), with original timestamps
+compared by source package, exact text (including whitespace) and an order-independent SHA-256
+checksum of the image collection (including image roles and repeated entries), with original timestamps
inside the configured ± time window. Set the window in seconds in Sources; 0 requires exact timestamps.
Titles and SMS senders are not part of this key. SMS uses the system source `android`.
Messages outside the inclusive window count as new. Identical genuine messages inside it are also skipped. Turn the corresponding switch off
@@ -93,6 +111,46 @@ The JSON event carries `schema_version`, `event_id`, `device_id`, `device_code`,
`source_name` is its PackageManager display label, falling back to the package when unavailable.
Labels can change with language or app version; use `source` for matching rules.
+Notification image attachments are forwarded as an optional `images` array in the same JSON
+(`schema_version: 1`). Each image has `mime_type` (`image/jpeg` or `image/png`), `width`, `height`,
+`kind` (`attachment`, `avatar`, or `app_icon`), and `data_base64` (standard Base64 without a data-URL prefix). `presentation` (`big_picture`, `gallery`, or `file`) preserves the notification image presentation. Text-only events omit `images`.
+Choose JPEG or PNG in **Sources → Graphics**; defaults are JPEG and quality 80%.
+JPEG quality is adjustable from 0 to 100; PNG ignores that setting and preserves transparency.
+Transparent areas become white in JPEG. Settings changes apply only to new captures; queued
+images retain their original bytes across retries. The overview test button sends text only. **Connection → Test message** independently selects
+Big Picture, a two-image gallery, avatar and app icon. These test-only selections do not change
+notification capture settings; image format, quality and limits still apply. Avatar and app-icon
+test images use the Message487 logo.
+
+The app reads BigPicture content and image attachments in MessagingStyle. Avatars and app
+icons have independent switches in Sources, both off by default. When enabled, one latest
+available sender avatar (falling back to the notification large icon) and one source app icon
+are included, using the same format and quality. Image-only notifications can be forwarded.
+In **Sources → Graphics**, configure the attachment count, maximum side length, per-image size,
+and combined size before Base64. Defaults are 3 attachments, 1024 pixels, 256 KiB per image,
+and 512 KiB combined. The count excludes the optional avatar and app icon; all images share
+the size budget. Attachments prioritize the big picture and then the latest messaging images,
+with a stable content-hash tie-breaker. Duplicates within a role are removed before applying limits.
+Turn off **Limit images** to send all available attachments without output size limits or
+resizing. Saved limits are retained for re-enabling. Format conversion and JPEG quality still apply.
+Input decoding safeguards remain in both modes: content URI input is at most 8 MiB and decoded
+bitmaps at most 16 megapixels, with a lower working-memory ceiling on smaller heaps.
+EXIF rotations and mirrors are applied before encoding. URI inputs must be readable `content://` URIs;
+remote URLs and private file paths are not fetched. Unreadable/invalid images are skipped and
+reported as capture errors; available text and other readable images still proceed.
+Images are private files under `noBackupFilesDir`, without additional app-level encryption.
+The request description, text and token are AES-GCM encrypted in a separate file; SQLite holds
+queue metadata and a file reference. Base64 JSON is streamed over HTTP using chunked transfer
+for image-bearing events, so proxies must accept chunked request bodies. Retries keep the same
+files and bytes. Successful delivery or deletion removes the files; recovery cleans abandoned
+temporary files. Older database payloads migrate on their next attempt.
+Image capture has its own bounded queue and a 10-second deadline; queued tasks retain only
+notification metadata and fetch the current notification when processing starts. Superseded,
+expired or overflowed image jobs still forward available text. SMS uses a separate executor.
+The existing JSON webhook accepts the added field; downstream workflows must explicitly
+convert/process `images` to display or forward photos. Use the [importable example levels](docs/en/n8n-examples.md)
+for webhook binary files, standard Telegram images or Rich Messages with all attachments.
+
The manifest declares launcher-app visibility rather than `QUERY_ALL_PACKAGES`. The app requests
notification listener access and `RECEIVE_SMS` only for the features you enable. It does not request
SMS history access or become the default SMS app.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 955b446..6944aed 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -92,6 +92,7 @@ dependencies {
implementation(platform("androidx.compose:compose-bom:2025.01.01"))
implementation("androidx.activity:activity-compose:1.10.0")
implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..f21d359
--- /dev/null
+++ b/app/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/androidTest/java/life/andre/message487/BlockingImageTestProvider.java b/app/src/androidTest/java/life/andre/message487/BlockingImageTestProvider.java
new file mode 100644
index 0000000..6e3bd2b
--- /dev/null
+++ b/app/src/androidTest/java/life/andre/message487/BlockingImageTestProvider.java
@@ -0,0 +1,36 @@
+package life.andre.message487;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.ParcelFileDescriptor;
+import java.io.IOException;
+import java.util.ArrayList;
+
+/** Lives only in the test APK. Java keeps the separate provider process independent of the target APK's Kotlin runtime. */
+public class BlockingImageTestProvider extends ContentProvider {
+ private final ArrayList writers = new ArrayList<>();
+ private int opened;
+ @Override public boolean onCreate() { return true; }
+ @Override public String getType(Uri uri) { return "image/png"; }
+ @Override public Cursor query(Uri uri, String[] projection, String selection, String[] args, String sort) { return null; }
+ @Override public Uri insert(Uri uri, ContentValues values) { return null; }
+ @Override public int delete(Uri uri, String selection, String[] args) { return 0; }
+ @Override public int update(Uri uri, ContentValues values, String selection, String[] args) { return 0; }
+ @Override public synchronized ParcelFileDescriptor openFile(Uri uri, String mode) throws java.io.FileNotFoundException {
+ try {
+ ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
+ writers.add(pipe[1]); opened++;
+ return pipe[0];
+ } catch (IOException error) { throw new java.io.FileNotFoundException(error.toString()); }
+ }
+ @Override public synchronized Bundle call(String method, String arg, Bundle extras) {
+ if ("release".equals(method)) {
+ for (ParcelFileDescriptor writer : writers) { try { writer.close(); } catch (IOException ignored) { } }
+ writers.clear(); opened = 0;
+ }
+ Bundle result = new Bundle(); result.putInt("count", opened); return result;
+ }
+}
diff --git a/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt b/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt
index 5196584..eb713e0 100644
--- a/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt
+++ b/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt
@@ -23,6 +23,7 @@ class StorageDeliveryDeviceTest {
@Before fun clearStorage() {
context.deleteDatabase("outbox.db")
+ File(context.noBackupFilesDir, "outbox").deleteRecursively()
context.getSharedPreferences("connection", Context.MODE_PRIVATE).edit().clear().commit()
}
@@ -49,9 +50,10 @@ class StorageDeliveryDeviceTest {
val outbox = Outbox(context, KeystorePayloadCipher())
try {
assertTrue(outbox.enqueue(event, settings))
- outbox.readableDatabase.rawQuery("SELECT payload FROM events", null).use { rows ->
+ outbox.readableDatabase.rawQuery("SELECT payload,payload_file FROM events", null).use { rows ->
assertTrue(rows.moveToFirst())
- val raw = String(rows.getBlob(0), Charsets.ISO_8859_1)
+ assertTrue(rows.isNull(0))
+ val raw = File(outbox.files, rows.getString(1) + "/request.enc").readBytes().toString(Charsets.ISO_8859_1)
assertFalse(raw.contains(token))
assertFalse(raw.contains("Synthetic SMS"))
}
@@ -98,6 +100,79 @@ class StorageDeliveryDeviceTest {
}
}
+ @Test fun largeImageFilesStreamWithoutCursorWindowAndAreRemovedOnlyAfterAck() {
+ val file = File(context.cacheDir, "large-image-fixture").apply { outputStream().use { output ->
+ repeat(384) { output.write(ByteArray(8192) { (it % 251).toByte() }) }
+ } }
+ val image = NotificationImage("image/png", 1024, 1024, file)
+ val event = MessageEvent("device", "emulator", source, messageType = "notification", images = listOf(image))
+ val outbox = Outbox(context, KeystorePayloadCipher())
+ try {
+ MockWebServer().use { server ->
+ server.start()
+ val settings = ForwardingSettings(url = server.url("/receive").toString(), authToken = token)
+ assertTrue(outbox.enqueue(event, settings))
+ assertEquals(1, outbox.entries().size)
+ outbox.readableDatabase.rawQuery("SELECT payload FROM events", null).use {
+ assertTrue(it.moveToFirst()); assertTrue(it.isNull(0))
+ }
+ val attempt = outbox.beginAttempt(event.eventId)!!
+ val saved = attempt.request.images.single().file
+ assertArrayEquals(file.readBytes(), saved.readBytes())
+ server.enqueue(MockResponse().setBody("{\"status\":\"accepted\",\"event_id\":\"${event.eventId}\"}"))
+ val result = WebhookClient().sendRequest(attempt.request, event.eventId)
+ assertEquals(DeliveryStatus.ACCEPTED, result.status)
+ val request = server.takeRequest(2, TimeUnit.SECONDS)!!
+ assertEquals("chunked", request.getHeader("Transfer-Encoding"))
+ val encoded = JSONObject(request.body.readUtf8()).getJSONArray("images").getJSONObject(0).getString("data_base64")
+ assertArrayEquals(file.readBytes(), java.util.Base64.getDecoder().decode(encoded))
+ outbox.finish(event.eventId, attempt.token, result)
+ assertFalse(saved.exists())
+ }
+ } finally { outbox.close(); file.delete() }
+ }
+
+ @Test fun contentUriExifOrientationIsAppliedBeforeForwarding() {
+ val input = File(context.cacheDir, "updates/exif-fixture.jpg").apply { parentFile!!.mkdirs() }
+ val bitmap = android.graphics.Bitmap.createBitmap(64, 48, android.graphics.Bitmap.Config.ARGB_8888).apply {
+ eraseColor(android.graphics.Color.RED)
+ }
+ input.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 90, it) }
+ bitmap.recycle()
+ // Inject a standard EXIF APP1 segment; API 26's writer rejects some otherwise readable encoder output.
+ val jpeg = input.readBytes()
+ val exif = "4578696600004d4d002a00000008000101120003000000010006000000000000"
+ .chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+ input.writeBytes(jpeg.copyOfRange(0, 2) + byteArrayOf(0xff.toByte(), 0xe1.toByte(), 0, (exif.size + 2).toByte()) + exif + jpeg.copyOfRange(2, jpeg.size))
+ assertEquals(6, androidx.exifinterface.media.ExifInterface(input.path).getAttributeInt(androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION, 1))
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", input)
+ val style = android.app.Notification.MessagingStyle("self").addMessage(
+ android.app.Notification.MessagingStyle.Message("", 1L, "sender").setData("image/jpeg", uri))
+ val notification = android.app.Notification.Builder(context, "test").setSmallIcon(android.R.drawable.ic_dialog_info).setStyle(style).build()
+ val images = notificationImages(context, notification, ForwardingSettings(imageFormat = NotificationImageFormat.PNG)) { throw it }
+ try { assertEquals(48, images.single().width); assertEquals(64, images.single().height) }
+ finally { discardImages(images); input.delete() }
+ }
+
+ @Test fun blockedContentPipeExpiresAndStillCompletesTextCallback() {
+ val uri = android.net.Uri.parse("content://life.andre.message487.test.images/slow")
+ context.contentResolver.call(uri, "release", null, null)
+ val style = android.app.Notification.MessagingStyle("self").addMessage(
+ android.app.Notification.MessagingStyle.Message("", 1L, "sender").setData("image/png", uri))
+ val notification = android.app.Notification.Builder(context, "test").setSmallIcon(android.R.drawable.ic_dialog_info).setStyle(style).build()
+ val delivered = java.util.concurrent.CountDownLatch(1)
+ val callbackCount = java.util.concurrent.atomic.AtomicInteger()
+ val queue = NotificationImageQueue(timeoutMs = 500)
+ try {
+ queue.submit("slow", { signal -> notificationImages(context, notification, ForwardingSettings(), cancellation = signal) { } },
+ { images -> if (images.isEmpty()) { callbackCount.incrementAndGet(); delivered.countDown() } }, { })
+ assertTrue(delivered.await(3, TimeUnit.SECONDS))
+ assertEquals(1, callbackCount.get())
+ val opened = context.contentResolver.call(uri, "opened", null, null)!!.getInt("count")
+ assertTrue("Provider must have opened a blocking pipe", opened > 0)
+ } finally { context.contentResolver.call(uri, "release", null, null); queue.close() }
+ }
+
@Test fun fileProviderSharesApkButRejectsPrivateSettings() {
val apk = File(context.cacheDir, "updates/device-test.apk")
apk.parentFile!!.mkdirs()
diff --git a/app/src/main/java/life/andre/message487/ConnectionScreen.kt b/app/src/main/java/life/andre/message487/ConnectionScreen.kt
index 3558a4c..ba32481 100644
--- a/app/src/main/java/life/andre/message487/ConnectionScreen.kt
+++ b/app/src/main/java/life/andre/message487/ConnectionScreen.kt
@@ -7,6 +7,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -62,7 +64,27 @@ internal fun ConnectionScreen(state: ConnectionState, model: ConnectionViewModel
Button(onClick = { model.save(false) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp)) {
Text(stringResource(R.string.save))
}
- OutlinedButton(onClick = { model.save(true) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
+
+ }
+ }
+ item {
+ Panel {
+ SectionTitle(stringResource(R.string.test_message_heading))
+ SupportingText(stringResource(R.string.test_message_hint))
+ TestImageOption(R.string.test_big_picture, state.testOptions.bigPicture, !state.busy) {
+ model.setTestOptions(state.testOptions.copy(bigPicture = it))
+ }
+ TestImageOption(R.string.test_gallery, state.testOptions.gallery, !state.busy) {
+ model.setTestOptions(state.testOptions.copy(gallery = it))
+ }
+ TestImageOption(R.string.test_avatar, state.testOptions.avatar, !state.busy) {
+ model.setTestOptions(state.testOptions.copy(avatar = it))
+ }
+ TestImageOption(R.string.test_app_icon, state.testOptions.appIcon, !state.busy) {
+ model.setTestOptions(state.testOptions.copy(appIcon = it))
+ }
+ OutlinedButton(onClick = { model.save(true) }, enabled = !state.busy,
+ modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
Text(stringResource(R.string.send_test))
}
}
@@ -71,3 +93,13 @@ internal fun ConnectionScreen(state: ConnectionState, model: ConnectionViewModel
item { AppInfo() }
}
}
+
+@Composable
+private fun TestImageOption(title: Int, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) {
+ Row(Modifier.fillMaxWidth().heightIn(min = 48.dp)
+ .toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onChange),
+ verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(stringResource(title), modifier = Modifier.weight(1f))
+ Switch(checked = checked, onCheckedChange = null, enabled = enabled)
+ }
+}
diff --git a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt
index 501e5b4..a2f0548 100644
--- a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt
+++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt
@@ -28,6 +28,7 @@ data class ConnectionState(
val notice: Int? = null,
val authToken: String = "",
val invalidToken: Boolean = false,
+ val testOptions: TestMessageOptions = TestMessageOptions(),
) {
override fun toString(): String = "ConnectionState(redacted)"
}
@@ -114,6 +115,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun setDeviceCode(code: String) { mutableState.value = state.value.copy(deviceCode = code, invalidDeviceCode = false, notice = null) }
fun setRequireAck(value: Boolean) { mutableState.value = state.value.copy(requireAck = value, notice = null) }
+ fun setTestOptions(options: TestMessageOptions) { mutableState.value = state.value.copy(testOptions = options) }
+
fun save(sendTest: Boolean) {
val draft = state.value
val url = draft.url.trim()
@@ -123,7 +126,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
if (!validAuthToken(draft.authToken)) { mutableState.value = draft.copy(invalidToken = true); return }
action(if (sendTest) R.string.test_queued else R.string.saved) {
graph.settings.update { it.copy(url = url, deviceCode = code, requireAck = draft.requireAck, authToken = draft.authToken) }
- if (sendTest) graph.enqueueTest()
+ if (sendTest) graph.enqueueTest(draft.testOptions)
}
}
@@ -134,6 +137,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
fun smsDeduplication(enabled: Boolean) = action { graph.settings.update { it.copy(smsDeduplication = enabled) } }
fun notificationDeduplication(enabled: Boolean) = action { graph.settings.update { it.copy(notificationDeduplication = enabled) } }
+ fun forwardAvatars(enabled: Boolean) = action { graph.settings.update { it.copy(forwardAvatars = enabled) } }
+ fun forwardAppIcons(enabled: Boolean) = action { graph.settings.update { it.copy(forwardAppIcons = enabled) } }
+ fun imageLimits(enabled: Boolean) = action { graph.settings.update { it.copy(imageLimitsEnabled = enabled) } }
+ fun imageMaxCount(value: Int) = action { graph.settings.update { it.copy(imageMaxCount = value) } }
+ fun imageMaxEdge(value: Int) = action { graph.settings.update { it.copy(imageMaxEdge = value) } }
+ fun imageMaxKiB(value: Int) = action { graph.settings.update { it.copy(imageMaxKiB = value) } }
+ fun imageTotalKiB(value: Int) = action { graph.settings.update { it.copy(imageTotalKiB = value) } }
+ fun imageFormat(format: NotificationImageFormat) = action { graph.settings.update { it.copy(imageFormat = format) } }
+ fun imageQuality(quality: Int) = action { graph.settings.update { it.copy(imageQuality = quality) } }
fun sms(enabled: Boolean) = action { graph.settings.update { it.copy(sms = enabled) } }
fun autoSelectNewApps(enabled: Boolean) = action {
val baseline = if (enabled) installedApps().map { it.packageName }.toSet() else emptySet()
diff --git a/app/src/main/java/life/andre/message487/DeliveryWorker.kt b/app/src/main/java/life/andre/message487/DeliveryWorker.kt
index 11f2625..e691c11 100644
--- a/app/src/main/java/life/andre/message487/DeliveryWorker.kt
+++ b/app/src/main/java/life/andre/message487/DeliveryWorker.kt
@@ -54,11 +54,15 @@ class DeliveryWorker(context: Context, parameters: WorkerParameters) : Coroutine
graph.diagnostics.record(DiagnosticEvent.DELIVERY_STARTED)
val request = attempt.request
val result = if (validWebhookUrl(request.url, BuildConfig.DEBUG)) {
- WebhookClient().sendJson(request.url, id, request.json, request.requireAck, request.authToken)
+ WebhookClient().sendRequest(request, id)
} else DeliveryResult(id, DeliveryStatus.HTTP_ERROR)
graph.diagnostics.record(DiagnosticEvent.DELIVERY_FINISHED, outcome = result.status.name, http = result.httpCode)
val state = graph.outbox.finish(id, attempt.token, result)
if (state == QueueState.RETRY) Result.retry() else Result.success()
+ } catch (error: UnreadableImageException) {
+ graph.diagnostics.record(DiagnosticEvent.PAYLOAD_UNREADABLE, error = error)
+ graph.outbox.blockUnreadable(id)
+ Result.success()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Exception) {
diff --git a/app/src/main/java/life/andre/message487/ForwardingSettings.kt b/app/src/main/java/life/andre/message487/ForwardingSettings.kt
index 2d4b799..07d10b0 100644
--- a/app/src/main/java/life/andre/message487/ForwardingSettings.kt
+++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt
@@ -22,8 +22,20 @@ data class ForwardingSettings(
val smsDeduplication: Boolean = true,
val notificationDeduplication: Boolean = true,
val deduplicationWindowSeconds: Int = 1,
+ val imageFormat: NotificationImageFormat = NotificationImageFormat.JPEG,
+ val imageQuality: Int = 80,
+ val imageLimitsEnabled: Boolean = true,
+ val imageMaxCount: Int = MAX_NOTIFICATION_IMAGES,
+ val imageMaxEdge: Int = 1024,
+ val imageMaxKiB: Int = MAX_NOTIFICATION_IMAGE_BYTES / 1024,
+ val imageTotalKiB: Int = MAX_NOTIFICATION_IMAGES_BYTES / 1024,
+ val forwardAvatars: Boolean = false,
+ val forwardAppIcons: Boolean = false,
) {
- init { require(deduplicationWindowSeconds >= 0) }
+ init {
+ require(deduplicationWindowSeconds >= 0 && imageQuality in 0..100)
+ require(imageMaxCount > 0 && imageMaxEdge > 0 && imageMaxKiB > 0 && imageTotalKiB > 0)
+ }
override fun toString(): String = "ForwardingSettings(redacted)"
@@ -61,6 +73,15 @@ class SettingsStore(
autoSelectNewApps = preferences.getBoolean("auto_select_new_apps", false),
knownPackages = preferences.getStringSet("known_packages", emptySet()).orEmpty().toSet(),
captureFailed = preferences.getBoolean("capture_failed", false),
+ imageFormat = NotificationImageFormat.entries.firstOrNull { it.name == preferences.getString("image_format", null) } ?: NotificationImageFormat.JPEG,
+ imageQuality = preferences.getInt("image_quality", 80).coerceIn(0, 100),
+ imageLimitsEnabled = preferences.getBoolean("image_limits_enabled", true),
+ imageMaxCount = preferences.getInt("image_max_count", 3).coerceAtLeast(1),
+ imageMaxEdge = preferences.getInt("image_max_edge", 1024).coerceAtLeast(1),
+ imageMaxKiB = preferences.getInt("image_max_kib", 256).coerceAtLeast(1),
+ imageTotalKiB = preferences.getInt("image_total_kib", 512).coerceAtLeast(1),
+ forwardAvatars = preferences.getBoolean("forward_avatars", false),
+ forwardAppIcons = preferences.getBoolean("forward_app_icons", false),
)
@Synchronized
@@ -94,6 +115,11 @@ class SettingsStore(
.putBoolean("notification_deduplication", next.notificationDeduplication)
.remove("deduplication")
.putInt("deduplication_window_seconds", next.deduplicationWindowSeconds)
+ .putBoolean("image_limits_enabled", next.imageLimitsEnabled)
+ .putInt("image_max_count", next.imageMaxCount).putInt("image_max_edge", next.imageMaxEdge)
+ .putInt("image_max_kib", next.imageMaxKiB).putInt("image_total_kib", next.imageTotalKiB)
+ .putBoolean("forward_avatars", next.forwardAvatars).putBoolean("forward_app_icons", next.forwardAppIcons)
+ .putString("image_format", next.imageFormat.name).putInt("image_quality", next.imageQuality)
.putBoolean("auto_select_new_apps", next.autoSelectNewApps)
.putStringSet("known_packages", next.knownPackages)
.putBoolean("paused", next.paused).putStringSet("packages", next.packages)
diff --git a/app/src/main/java/life/andre/message487/MessageGraph.kt b/app/src/main/java/life/andre/message487/MessageGraph.kt
index 8ccaff1..0b60902 100644
--- a/app/src/main/java/life/andre/message487/MessageGraph.kt
+++ b/app/src/main/java/life/andre/message487/MessageGraph.kt
@@ -31,6 +31,7 @@ class MessageGraph internal constructor(private val context: Application) {
val outbox = Outbox(context, (context as MessageApplication).payloadCipher)
val scheduler by lazy { DeliveryScheduler(context) }
val captureExecutor = Executors.newSingleThreadExecutor()
+ internal val imageCaptureQueue = NotificationImageQueue()
private val sources = AppSourceResolver(context.packageManager)
fun start() {
@@ -45,17 +46,19 @@ class MessageGraph internal constructor(private val context: Application) {
fun recover() {
if (!settings.state.value.paused) {
+ outbox.cleanupFiles()
val pending = outbox.pendingIds()
pending.forEach { scheduler.schedule(it) }
diagnostics.record(DiagnosticEvent.RECOVERY, count = pending.size)
}
}
- fun enqueueTest() {
+ fun enqueueTest(options: TestMessageOptions = TestMessageOptions()) {
val config = settings.state.value
require(config.ready())
- val event = MessageEvent(config.deviceId, config.deviceCode, sources.resolve(context.packageName))
- enqueue(event, config)
+ val images = testMessageImages(context, config, options)
+ val event = MessageEvent(config.deviceId, config.deviceCode, sources.resolve(context.packageName), images = images)
+ try { enqueue(event, config) } finally { discardImages(images) }
}
fun captureNotification(notification: CapturedNotification) {
@@ -64,7 +67,7 @@ class MessageGraph internal constructor(private val context: Application) {
val event = MessageEvent(
config.deviceId, config.deviceCode, sources.resolve(notification.packageName),
occurredAt = Instant.ofEpochMilli(notification.postedAt).toString(),
- messageType = "notification", text = notification.text, title = notification.title,
+ messageType = "notification", text = notification.text, title = notification.title, images = notification.images,
)
enqueue(event, config)
}
diff --git a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt
index 3bed54f..3ec4427 100644
--- a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt
+++ b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt
@@ -13,6 +13,7 @@ data class CapturedNotification(
val title: String,
val text: String,
val postedAt: Long,
+ val images: List = emptyList(),
)
object ListenerState {
@@ -42,7 +43,8 @@ class NotificationCaptureService : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification) {
val graph = MessageGraph.get(this)
- if (!graph.settings.state.value.acceptsPackage(sbn.packageName, packageName)) return
+ val config = graph.settings.state.value
+ if (!config.acceptsPackage(sbn.packageName, packageName)) return
if (!sbn.isClearable || sbn.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) return
try {
val extras = sbn.notification.extras
@@ -51,11 +53,35 @@ class NotificationCaptureService : NotificationListenerService() {
?: extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
?: extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.joinToString("\n")
?: ""
- if (title.isBlank() && text.isBlank()) return
val captured = CapturedNotification(sbn.key, sbn.packageName, title, text, sbn.postTime)
- graph.captureExecutor.execute {
- try { graph.captureNotification(captured) } catch (error: Exception) { graph.captureFailed(error) }
+ val hasImages = config.forwardAvatars || config.forwardAppIcons || extras.containsKey(Notification.EXTRA_PICTURE) ||
+ extras.containsKey(Notification.EXTRA_MESSAGES) ||
+ (android.os.Build.VERSION.SDK_INT >= 31 && extras.containsKey(Notification.EXTRA_PICTURE_ICON))
+ if (!hasImages) {
+ if (title.isNotBlank() || text.isNotBlank()) graph.captureExecutor.execute {
+ try { graph.captureNotification(captured) } catch (error: Exception) { graph.captureFailed(error) }
+ }
+ return
}
+ graph.imageCaptureQueue.submit(captured.key,
+ capture = { cancellation ->
+ if (!graph.settings.state.value.acceptsPackage(captured.packageName, packageName)) emptyList()
+ else {
+ val current = getActiveNotifications(arrayOf(captured.key)).firstOrNull()
+ if (current == null || current.postTime != captured.postedAt) emptyList()
+ else notificationImages(this, current.notification, config, captured.packageName, cancellation) { graph.captureFailed(it) }
+ }
+ }, complete = { images ->
+ if (title.isNotBlank() || text.isNotBlank() || images.isNotEmpty()) {
+ if (images.isEmpty()) graph.captureExecutor.execute {
+ try { graph.captureNotification(captured) } catch (error: Exception) { graph.captureFailed(error) }
+ } else {
+ try { graph.captureNotification(captured.copy(images = images)) }
+ catch (error: Exception) { graph.captureFailed(error) }
+ }
+ }
+ }, onFailure = graph::captureFailed)
+
} catch (error: Exception) { graph.captureFailed(error) }
}
diff --git a/app/src/main/java/life/andre/message487/NotificationImageQueue.kt b/app/src/main/java/life/andre/message487/NotificationImageQueue.kt
new file mode 100644
index 0000000..9f399f8
--- /dev/null
+++ b/app/src/main/java/life/andre/message487/NotificationImageQueue.kt
@@ -0,0 +1,64 @@
+package life.andre.message487
+
+import android.os.CancellationSignal
+import java.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.ScheduledThreadPoolExecutor
+import java.util.concurrent.ThreadPoolExecutor
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+
+/** Queued tasks retain only notification keys/text; bitmaps are fetched when a task actually starts. */
+internal class NotificationImageQueue(private val timeoutMs: Long = 10_000, capacity: Int = 32) : AutoCloseable {
+ private val worker = ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, ArrayBlockingQueue(capacity))
+ private val timer = ScheduledThreadPoolExecutor(2).apply { removeOnCancelPolicy = true }
+ private var closed = false
+ private val pending = mutableMapOf()
+ private val tasks = mutableSetOf()
+
+ private inner class Task(val key: String, val capture: (CancellationSignal) -> List,
+ val complete: (List) -> Unit, val onFailure: (Exception) -> Unit) : Runnable {
+ val cancellation = CancellationSignal()
+ val done = AtomicBoolean()
+ var timeout: java.util.concurrent.ScheduledFuture<*>? = null
+ fun finish(images: List) {
+ if (done.compareAndSet(false, true)) {
+ timeout?.cancel(false)
+ synchronized(this@NotificationImageQueue) { pending.remove(key, this); tasks.remove(this) }
+ try { complete(images) } finally { discardImages(images) }
+ } else discardImages(images)
+ }
+ fun cancel() {
+ finish(emptyList())
+ cancellation.cancel()
+ worker.remove(this)
+ }
+ override fun run() {
+ synchronized(this@NotificationImageQueue) { pending.remove(key, this) }
+ if (done.get()) return
+ try { finish(capture(cancellation)) }
+ catch (error: Exception) {
+ if (!done.get()) onFailure(error)
+ finish(emptyList())
+ }
+ }
+ }
+
+ @Synchronized
+ fun submit(key: String, capture: (CancellationSignal) -> List,
+ complete: (List) -> Unit, onFailure: (Exception) -> Unit) {
+ if (closed) { complete(emptyList()); return }
+ pending[key]?.cancel()
+ val task = Task(key, capture, complete, onFailure)
+ pending[key] = task
+ tasks += task
+ task.timeout = timer.schedule({ task.cancel() }, timeoutMs, TimeUnit.MILLISECONDS)
+ try { worker.execute(task) } catch (_: java.util.concurrent.RejectedExecutionException) { task.cancel() }
+ }
+
+ override fun close() {
+ val snapshot = synchronized(this) { closed = true; tasks.toList() }
+ snapshot.forEach { it.cancel() }
+ worker.shutdownNow()
+ timer.shutdownNow()
+ }
+}
diff --git a/app/src/main/java/life/andre/message487/NotificationImages.kt b/app/src/main/java/life/andre/message487/NotificationImages.kt
new file mode 100644
index 0000000..8f0c2f5
--- /dev/null
+++ b/app/src/main/java/life/andre/message487/NotificationImages.kt
@@ -0,0 +1,272 @@
+package life.andre.message487
+
+import android.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.Rect
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.Icon
+import androidx.exifinterface.media.ExifInterface
+import android.net.Uri
+import android.os.Build
+import android.os.CancellationSignal
+import android.system.Os
+import android.system.OsConstants
+import android.system.StructPollfd
+import java.util.Base64
+import androidx.core.app.NotificationCompat
+import androidx.core.graphics.drawable.IconCompat
+import org.json.JSONObject
+import java.io.File
+import java.io.InterruptedIOException
+import java.security.MessageDigest
+import java.util.UUID
+import kotlin.math.roundToInt
+
+internal const val MAX_NOTIFICATION_IMAGES = 3
+internal const val MAX_NOTIFICATION_IMAGE_BYTES = 256 * 1024
+internal const val MAX_NOTIFICATION_IMAGES_BYTES = 512 * 1024
+private const val MAX_IMAGE_EDGE = 1024
+private const val MAX_IMAGE_INPUT_BYTES = 8 * 1024 * 1024
+
+enum class NotificationImageFormat { JPEG, PNG }
+
+data class NotificationImage(val mimeType: String, val width: Int, val height: Int, val file: File,
+ val kind: String = "attachment", val checksum: String = imageFileHash(file), val presentation: String = "file") {
+ fun toJson(): JSONObject = JSONObject().put("mime_type", mimeType).put("width", width)
+ .put("height", height).put("kind", kind).put("presentation", presentation)
+ override fun toString(): String = "NotificationImage(redacted)"
+}
+
+internal fun imageFileHash(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().use { input ->
+ val buffer = ByteArray(8192)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ digest.update(buffer, 0, count)
+ }
+ }
+ return Base64.getEncoder().encodeToString(digest.digest())
+}
+
+internal fun notificationImagesChecksum(images: List): String = Base64.getEncoder().encodeToString(
+ MessageDigest.getInstance("SHA-256").digest(images.map { it.kind + ":" + it.presentation + ":" + it.checksum }
+ .sorted().joinToString("\n").toByteArray(Charsets.UTF_8)))
+
+internal fun discardImages(images: List) {
+ images.forEach { it.file.delete() }
+ images.mapNotNull { it.file.parentFile }.distinct().forEach { if (it.name.startsWith("capture-")) it.delete() }
+}
+
+private fun workingPixelLimit(): Long = minOf(16_777_216L, Runtime.getRuntime().maxMemory() / 32)
+
+internal fun encodeNotificationImage(bitmap: Bitmap, format: NotificationImageFormat, quality: Int,
+ directory: File, maxBytes: Long = MAX_NOTIFICATION_IMAGE_BYTES.toLong(), maxEdge: Int = MAX_IMAGE_EDGE,
+ orientation: Int = ExifInterface.ORIENTATION_NORMAL, checkActive: () -> Unit = {}): NotificationImage {
+ require(quality in 0..100 && maxBytes > 0 && maxEdge > 0)
+ val ratio = minOf(1.0, maxEdge.toDouble() / maxOf(bitmap.width, bitmap.height))
+ var width = maxOf(1, (bitmap.width * ratio).toInt())
+ var height = maxOf(1, (bitmap.height * ratio).toInt())
+ require(width.toLong() * height <= workingPixelLimit()) { "Image exceeds working memory budget" }
+ directory.mkdirs()
+ val file = File.createTempFile("image-", if (format == NotificationImageFormat.JPEG) ".jpg" else ".png", directory)
+ var complete = false
+ try {
+ while (true) {
+ checkActive()
+ // Re-render from the source: no simultaneous old/new scaling buffers, no accumulated resampling loss.
+ var software: Bitmap? = null
+ var working: Bitmap? = null
+ try {
+ val source = if (bitmap.config == Bitmap.Config.HARDWARE) {
+ require(bitmap.width.toLong() * bitmap.height <= workingPixelLimit()) { "Hardware image exceeds working memory budget" }
+ bitmap.copy(Bitmap.Config.ARGB_8888, false).also { software = it }
+ } else bitmap
+ val transform = Matrix().apply {
+ when (orientation) {
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> setScale(-1f, 1f)
+ ExifInterface.ORIENTATION_ROTATE_180 -> setRotate(180f)
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> setScale(1f, -1f)
+ ExifInterface.ORIENTATION_TRANSPOSE -> { setRotate(90f); postScale(-1f, 1f) }
+ ExifInterface.ORIENTATION_ROTATE_90 -> setRotate(90f)
+ ExifInterface.ORIENTATION_TRANSVERSE -> { setRotate(90f); postScale(1f, -1f) }
+ ExifInterface.ORIENTATION_ROTATE_270 -> setRotate(270f)
+ }
+ }
+ val bounds = android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat())
+ transform.mapRect(bounds)
+ transform.postTranslate(-bounds.left, -bounds.top)
+ working = Bitmap.createBitmap(bounds.width().roundToInt(), bounds.height().roundToInt(), Bitmap.Config.ARGB_8888)
+ Canvas(working).apply {
+ if (format == NotificationImageFormat.JPEG) drawColor(Color.WHITE)
+ concat(transform)
+ drawBitmap(source, null, Rect(0, 0, width, height), Paint(Paint.FILTER_BITMAP_FLAG))
+ }
+ file.outputStream().use { output ->
+ check(working.compress(if (format == NotificationImageFormat.JPEG) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG,
+ if (format == NotificationImageFormat.JPEG) quality else 100, output)) { "Image encoding failed" }
+ }
+ checkActive()
+ if (file.length() <= maxBytes) {
+ java.io.FileOutputStream(file, true).use { it.fd.sync() }
+ checkActive()
+ val image = NotificationImage(if (format == NotificationImageFormat.JPEG) "image/jpeg" else "image/png",
+ working.width, working.height, file)
+ complete = true
+ return image
+ }
+ } finally {
+ working?.recycle()
+ software?.recycle()
+ }
+ check(width > 1 || height > 1) { "Image cannot fit the size limit" }
+ width = maxOf(1, width * 3 / 4)
+ height = maxOf(1, height * 3 / 4)
+ }
+ } finally { if (!complete) file.delete() }
+}
+
+private fun decodeImageFile(file: File, maxEdge: Int, limited: Boolean): Bitmap {
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true; inSampleSize = 1 }
+ BitmapFactory.decodeFile(file.path, options)
+ require(options.outWidth > 0 && options.outHeight > 0) { "Invalid image" }
+ fun pixels() = ((options.outWidth.toLong() + options.inSampleSize - 1) / options.inSampleSize) *
+ ((options.outHeight.toLong() + options.inSampleSize - 1) / options.inSampleSize)
+ while (maxOf(options.outWidth, options.outHeight) / options.inSampleSize > maxEdge ||
+ (limited && pixels() > workingPixelLimit())) options.inSampleSize *= 2
+ require(pixels() <= workingPixelLimit()) { "Image exceeds working memory budget" }
+ options.inJustDecodeBounds = false
+ return BitmapFactory.decodeFile(file.path, options) ?: error("Cannot decode image")
+}
+
+@Suppress("DEPRECATION")
+internal fun notificationImages(context: Context, notification: Notification, settings: ForwardingSettings,
+ sourcePackage: String = context.packageName, cancellation: CancellationSignal = CancellationSignal(),
+ onFailure: (Exception) -> Unit): List {
+ val directory = File(context.noBackupFilesDir, "outbox/capture-${UUID.randomUUID()}").apply { mkdirs() }
+ val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10)
+ fun checkActive() {
+ cancellation.throwIfCanceled()
+ if (System.nanoTime() >= deadline) throw InterruptedIOException("Image capture timed out")
+ }
+ val candidates = mutableListOf>()
+ val selected = mutableListOf()
+ val limited = settings.imageLimitsEnabled
+ val edge = if (limited) settings.imageMaxEdge else Int.MAX_VALUE
+ val imageBytes = if (limited) settings.imageMaxKiB.toLong() * 1024 else Long.MAX_VALUE
+ fun add(bitmap: Bitmap, kind: String = "attachment", time: Long = Long.MAX_VALUE, orientation: Int = ExifInterface.ORIENTATION_NORMAL, presentation: String = "file") {
+ checkActive()
+ val image = encodeNotificationImage(bitmap, settings.imageFormat, settings.imageQuality, directory,
+ imageBytes, edge, orientation, ::checkActive).copy(kind = kind, presentation = presentation)
+ if (candidates.any { it.first.kind == kind && it.first.presentation == presentation && it.first.checksum == image.checksum }) image.file.delete()
+ else candidates += image to time
+ }
+ fun addUri(uri: Uri, kind: String = "attachment", time: Long = Long.MAX_VALUE, presentation: String = "file") {
+ require(uri.scheme == "content") { "Only content image URIs are supported" }
+ checkActive()
+ val source = File.createTempFile("source-", ".image", directory)
+ try {
+ context.contentResolver.openAssetFileDescriptor(uri, "r", cancellation)?.use { asset ->
+ cancellation.setOnCancelListener { runCatching { asset.close() } }
+ try {
+ asset.createInputStream().use { input -> source.outputStream().use { output ->
+ val regular = asset.parcelFileDescriptor.statSize >= 0
+ val poll = StructPollfd().apply { fd = asset.parcelFileDescriptor.fileDescriptor; events = OsConstants.POLLIN.toShort() }
+ val buffer = ByteArray(8192)
+ var total = 0L
+ while (true) {
+ checkActive()
+ if (!regular && Os.poll(arrayOf(poll), 100) == 0) continue
+ val count = input.read(buffer)
+ if (count < 0) break
+ total += count
+ require(total <= MAX_IMAGE_INPUT_BYTES) { "Image input too large" }
+ output.write(buffer, 0, count)
+ }
+ } }
+ } finally { cancellation.setOnCancelListener(null) }
+ } ?: error("Image is unavailable")
+ val orientation = runCatching { ExifInterface(source.path).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) }
+ .getOrDefault(ExifInterface.ORIENTATION_NORMAL)
+ val bitmap = decodeImageFile(source, edge, limited)
+ try { add(bitmap, kind, time, orientation, presentation) } finally { bitmap.recycle() }
+ } finally { source.delete() }
+ }
+ fun addDrawable(drawable: Drawable, kind: String, presentation: String = "file") {
+ if (drawable is BitmapDrawable) { add(drawable.bitmap, kind, presentation = presentation); return }
+ val ratio = minOf(1.0, edge.toDouble() / maxOf(1, drawable.intrinsicWidth, drawable.intrinsicHeight))
+ val width = maxOf(1, (drawable.intrinsicWidth * ratio).toInt())
+ val height = maxOf(1, (drawable.intrinsicHeight * ratio).toInt())
+ require(width.toLong() * height <= workingPixelLimit()) { "Icon exceeds working memory budget" }
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val bounds = drawable.copyBounds()
+ try { drawable.setBounds(0, 0, width, height); drawable.draw(Canvas(bitmap)); add(bitmap, kind, presentation = presentation) }
+ finally { drawable.bounds = bounds; bitmap.recycle() }
+ }
+ fun addIcon(icon: IconCompat, kind: String, presentation: String = "file") {
+ checkActive()
+ if (icon.type in setOf(IconCompat.TYPE_URI, IconCompat.TYPE_URI_ADAPTIVE_BITMAP)) addUri(icon.uri, kind, presentation = presentation)
+ else {
+ require(icon.type in setOf(IconCompat.TYPE_BITMAP, IconCompat.TYPE_ADAPTIVE_BITMAP, IconCompat.TYPE_RESOURCE)) { "Unsupported picture icon" }
+ addDrawable(icon.loadDrawable(context) ?: error("Icon unavailable"), kind, presentation)
+ }
+ }
+ fun attempt(block: () -> Unit) { try { checkActive(); block() } catch (error: Exception) { onFailure(error) } }
+ var transferred = false
+ try {
+ val extras = notification.extras
+ val messages = try { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification)?.messages.orEmpty() }
+ catch (error: Exception) { onFailure(error); emptyList() }
+ attempt { (extras.getParcelable(Notification.EXTRA_PICTURE) as? Bitmap)?.let { add(it, presentation = "big_picture") } }
+ if (candidates.none { it.first.kind == "attachment" } && Build.VERSION.SDK_INT >= 31) attempt {
+ (extras.getParcelable(Notification.EXTRA_PICTURE_ICON) as? Icon)?.let { addIcon(requireNotNull(IconCompat.createFromIcon(context, it)), "attachment", "big_picture") }
+ }
+ for (message in messages.filter { it.dataMimeType?.startsWith("image/") == true }
+ .sortedByDescending { it.timestamp }.distinctBy { it.dataUri }) {
+ val attachments = candidates.filter { it.first.kind == "attachment" }
+ // Only timestamp ties need additional hashes once enough successful, distinct attachments are ready.
+ if (limited && attachments.size >= settings.imageMaxCount && message.timestamp < attachments.minOf { it.second }) break
+ message.dataUri?.let { uri -> attempt { addUri(uri, time = message.timestamp, presentation = "gallery") } }
+ }
+ if (settings.forwardAvatars) attempt {
+ val sender = messages.filter { it.person?.icon != null }.sortedWith(
+ compareByDescending { it.timestamp }
+ .thenBy { it.person?.key ?: it.person?.name?.toString().orEmpty() }).firstOrNull()?.person?.icon
+ if (sender != null) addIcon(sender, "avatar")
+ else notification.getLargeIcon()?.let { addIcon(requireNotNull(IconCompat.createFromIcon(context, it)), "avatar") }
+ ?: (extras.getParcelable(Notification.EXTRA_LARGE_ICON) as? Bitmap)?.let { add(it, "avatar") }
+ }
+ if (settings.forwardAppIcons) attempt { addDrawable(context.packageManager.getApplicationIcon(sourcePackage), "app_icon") }
+ var remaining = if (limited) settings.imageTotalKiB.toLong() * 1024 else Long.MAX_VALUE
+ // Canonical order precedes count/size limits so input permutations cannot change encoding or selection.
+ for ((candidate, _) in candidates.sortedWith(compareBy> {
+ when (it.first.kind) { "attachment" -> 0; "avatar" -> 1; else -> 2 }
+ }.thenByDescending { it.second }.thenBy { it.first.checksum })) {
+ if (limited && candidate.kind == "attachment" && selected.count { it.kind == "attachment" } >= settings.imageMaxCount) continue
+ if (remaining <= 0) break
+ attempt {
+ val image = if (candidate.file.length() <= remaining) candidate else {
+ val bitmap = decodeImageFile(candidate.file, edge, limited)
+ try { encodeNotificationImage(bitmap, settings.imageFormat, settings.imageQuality, directory,
+ remaining, edge, checkActive = ::checkActive).copy(kind = candidate.kind, presentation = candidate.presentation) } finally { bitmap.recycle() }
+ }
+ selected += image
+ remaining -= image.file.length()
+ }
+ }
+ checkActive()
+ val keep = selected.map { it.file }.toSet()
+ directory.listFiles()?.filter { it !in keep }?.forEach { it.delete() }
+ if (selected.isEmpty()) directory.delete()
+ transferred = true
+ return selected
+ } finally { if (!transferred) directory.deleteRecursively() }
+}
diff --git a/app/src/main/java/life/andre/message487/Outbox.kt b/app/src/main/java/life/andre/message487/Outbox.kt
index 2b5ff53..35d5789 100644
--- a/app/src/main/java/life/andre/message487/Outbox.kt
+++ b/app/src/main/java/life/andre/message487/Outbox.kt
@@ -9,6 +9,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.util.UUID
+import java.util.concurrent.TimeUnit
+import java.io.File
+import java.io.ByteArrayOutputStream
+import android.util.AtomicFile
+import java.util.Base64
+
+private const val ENTRY_COLUMNS = "id,type,source_name,created_at,state,attempts,outcome,http_code"
enum class QueueState { PENDING, SENDING, RETRY, BLOCKED, ACCEPTED, HTTP_SUCCESS }
@@ -25,7 +32,8 @@ data class QueueEntry(
val delivered: Boolean get() = state == QueueState.ACCEPTED || state == QueueState.HTTP_SUCCESS
}
-data class QueuedRequest(val url: String, val requireAck: Boolean, val json: String, val authToken: String) {
+/** json contains the request without images; images are streamed from private files. */
+data class QueuedRequest(val url: String, val requireAck: Boolean, val json: String, val authToken: String, val images: List = emptyList()) {
override fun toString(): String = "QueuedRequest(redacted)"
}
data class Attempt(val token: String, val entry: QueueEntry, val request: QueuedRequest)
@@ -40,7 +48,8 @@ fun deliveryQueueState(result: DeliveryResult): QueueState = when (result.status
} else QueueState.BLOCKED
}
-class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHelper(context, "outbox.db", null, 3) {
+class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHelper(context, "outbox.db", null, 4) {
+ internal val files = File(context.noBackupFilesDir, "outbox").apply { mkdirs() }
private val mutableRevision = MutableStateFlow(0L)
val revision = mutableRevision.asStateFlow()
@@ -48,7 +57,7 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe
db.execSQL("""CREATE TABLE events (
id TEXT PRIMARY KEY, type TEXT NOT NULL, source_name TEXT NOT NULL,
created_at INTEGER NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
- outcome TEXT, http_code INTEGER, payload BLOB, attempt_token TEXT
+ outcome TEXT, http_code INTEGER, payload BLOB, payload_file TEXT, attempt_token TEXT
)""")
db.execSQL("CREATE INDEX events_state ON events(state)")
createDeduplicationTable(db)
@@ -61,6 +70,14 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe
db.execSQL("DROP TABLE IF EXISTS notifications")
}
if (oldVersion < 3) createWindowTable(db)
+ if (oldVersion < 4) {
+ val hasFileColumn = db.rawQuery("PRAGMA table_info(events)", null).use { cursor ->
+ var found = false
+ while (cursor.moveToNext()) if (cursor.getString(1) == "payload_file") found = true
+ found
+ }
+ if (!hasFileColumn) db.execSQL("ALTER TABLE events ADD COLUMN payload_file TEXT")
+ }
}
private fun createWindowTable(db: SQLiteDatabase) {
@@ -78,12 +95,16 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe
fun enqueue(event: MessageEvent, settings: ForwardingSettings): Boolean {
val db = writableDatabase
db.beginTransaction()
+ var saved: String? = null
+ var committed = false
try {
// Test requests are deliberate user actions and are never content-deduplicated.
val captured = event.messageType in setOf("sms", "notification")
val occurredAt = if (captured) java.time.Instant.parse(event.occurredAt).toEpochMilli() else 0L
val fingerprint = if (captured) digest(
- org.json.JSONArray().put(event.source.packageName).put(event.text).toString()
+ org.json.JSONArray().put(event.source.packageName).put(event.text).apply {
+ if (event.images.isNotEmpty()) put(notificationImagesChecksum(event.images))
+ }.toString()
) else null
val deduplication = when (event.messageType) {
"sms" -> settings.smsDeduplication
@@ -96,32 +117,37 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe
"SELECT 1 FROM message_windows WHERE fingerprint = ? AND occurred_at BETWEEN ? AND ?",
arrayOf(fingerprint, (occurredAt - window).toString(), (occurredAt + window).toString())
).use { if (it.moveToFirst()) return false }
- // Old hashes cannot reveal their content; retain exact matching for pre-upgrade history.
- val legacy = digest(org.json.JSONArray().put(event.source.packageName)
- .put(java.time.Instant.parse(event.occurredAt).toString()).put(event.text).toString())
- db.rawQuery("SELECT 1 FROM message_fingerprints WHERE fingerprint = ?", arrayOf(legacy)).use {
- if (it.moveToFirst()) return false
+ if (event.images.isEmpty()) {
+ // Old hashes cannot reveal their content; retain exact matching for pre-upgrade history.
+ val legacy = digest(org.json.JSONArray().put(event.source.packageName)
+ .put(java.time.Instant.parse(event.occurredAt).toString()).put(event.text).toString())
+ db.rawQuery("SELECT 1 FROM message_fingerprints WHERE fingerprint = ?", arrayOf(legacy)).use {
+ if (it.moveToFirst()) return false
+ }
}
}
db.rawQuery("SELECT id FROM events WHERE id = ?", arrayOf(event.eventId)).use {
if (it.moveToFirst()) return false
}
val envelope = JSONObject().put("url", settings.url).put("require_ack", settings.requireAck).put("auth_token", settings.authToken)
- .put("event", JSONObject(event.toJson())).toString()
+ .put("event", JSONObject(event.copy(images = emptyList()).toJson()))
+ saved = saveSnapshot(event.eventId, envelope, event.images)
db.insertOrThrow("events", null, ContentValues().apply {
put("id", event.eventId)
put("type", event.messageType)
put("source_name", event.source.name)
put("created_at", System.currentTimeMillis())
put("state", QueueState.PENDING.name)
- put("payload", cipher.encrypt(envelope))
+ put("payload_file", saved)
})
if (fingerprint != null) {
db.execSQL("INSERT OR IGNORE INTO message_windows (fingerprint, occurred_at) VALUES (?, ?)", arrayOf(fingerprint, occurredAt))
}
db.setTransactionSuccessful()
+ committed = true
} finally {
- db.endTransaction()
+ try { db.endTransaction() } catch (error: Exception) { committed = false; throw error }
+ finally { if (!committed) saved?.let { File(files, it).deleteRecursively() } }
}
changed()
return true
@@ -134,7 +160,7 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe
@Synchronized
fun entries(): List = readableDatabase.rawQuery(
- "SELECT * FROM events ORDER BY CASE WHEN state IN ('ACCEPTED', 'HTTP_SUCCESS') THEN 1 ELSE 0 END, created_at DESC LIMIT 200", null
+ "SELECT $ENTRY_COLUMNS FROM events ORDER BY CASE WHEN state IN ('ACCEPTED', 'HTTP_SUCCESS') THEN 1 ELSE 0 END, created_at DESC LIMIT 200", null
).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.entry()) } }
@Synchronized
@@ -145,15 +171,53 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe
@Synchronized
fun beginAttempt(id: String): Attempt? {
val db = writableDatabase
- val (entry, request) = db.rawQuery("SELECT * FROM events WHERE id = ?", arrayOf(id)).use {
+ val (entry, stored) = db.rawQuery("SELECT $ENTRY_COLUMNS,payload_file FROM events WHERE id = ?", arrayOf(id)).use {
if (!it.moveToFirst()) return null
val entry = it.entry()
if (entry.delivered || entry.state == QueueState.BLOCKED) return null
- val envelope = JSONObject(cipher.decrypt(it.getBlob(it.getColumnIndexOrThrow("payload"))))
- val authToken = envelope.getString("auth_token")
- require(validAuthToken(authToken)) { "Missing queued authentication token" }
- entry to QueuedRequest(envelope.getString("url"), envelope.getBoolean("require_ack"), envelope.getJSONObject("event").toString(), authToken)
+ entry to it.getString(it.getColumnIndexOrThrow("payload_file"))
+ }
+ var directory = stored?.let(::snapshotDirectory)
+ val envelope = if (directory != null) JSONObject(cipher.decrypt(AtomicFile(File(directory, "request.enc")).readFully()))
+ else JSONObject(cipher.decrypt(readLegacyPayload(id)))
+ val authToken = envelope.getString("auth_token")
+ require(validAuthToken(authToken)) { "Missing queued authentication token" }
+ if (directory == null) {
+ val event = envelope.getJSONObject("event")
+ val old = event.optJSONArray("images")
+ val legacyImages = mutableListOf()
+ val temporary = File(files, "capture-${UUID.randomUUID()}").apply { mkdirs() }
+ try {
+ if (old != null) for (index in 0 until old.length()) {
+ val image = old.getJSONObject(index)
+ val file = File.createTempFile("image-", ".image", temporary)
+ Base64.getDecoder().wrap(image.getString("data_base64").byteInputStream()).use { input ->
+ file.outputStream().use { input.copyTo(it) }
+ }
+ legacyImages += NotificationImage(image.getString("mime_type"), image.getInt("width"), image.getInt("height"),
+ file, image.optString("kind", "attachment"), presentation = image.optString("presentation", "file"))
+ }
+ event.remove("images")
+ val name = saveSnapshot(id, envelope, legacyImages)
+ try {
+ db.update("events", ContentValues().apply { put("payload_file", name); putNull("payload") }, "id = ?", arrayOf(id))
+ } catch (error: Exception) { File(files, name).deleteRecursively(); throw error }
+ directory = snapshotDirectory(name)
+ } finally { temporary.deleteRecursively() }
+ }
+ val event = envelope.getJSONObject("event")
+ val storedImages = event.optJSONArray("images")
+ val images = if (storedImages == null) emptyList() else (0 until storedImages.length()).map { index ->
+ val image = storedImages.getJSONObject(index)
+ val name = image.getString("file")
+ require(name == File(name).name && name.startsWith("image-")) { "Invalid image filename" }
+ val file = File(requireNotNull(directory), name)
+ require(file.isFile && file.length() == image.getLong("bytes")) { "Queued image is unavailable" }
+ NotificationImage(image.getString("mime_type"), image.getInt("width"), image.getInt("height"), file,
+ image.getString("kind"), image.getString("checksum"), image.optString("presentation", "file"))
}
+ event.remove("images")
+ val request = QueuedRequest(envelope.getString("url"), envelope.getBoolean("require_ack"), event.toString(), authToken, images)
val token = UUID.randomUUID().toString()
db.execSQL("UPDATE events SET state = 'SENDING', attempts = attempts + 1, attempt_token = ? WHERE id = ?", arrayOf(token, id))
changed()
@@ -168,9 +232,14 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe
put("outcome", result.status.name)
result.httpCode?.let { put("http_code", it) } ?: putNull("http_code")
putNull("attempt_token")
- if (state == QueueState.ACCEPTED || state == QueueState.HTTP_SUCCESS) putNull("payload")
+ if (state == QueueState.ACCEPTED || state == QueueState.HTTP_SUCCESS) {
+ putNull("payload"); putNull("payload_file")
+ }
+ }
+ val updated = writableDatabase.update("events", values, "id = ? AND attempt_token = ?", arrayOf(id, token)) > 0
+ if (updated && state in setOf(QueueState.ACCEPTED, QueueState.HTTP_SUCCESS)) {
+ snapshotDirectory("event-${digest(id)}").deleteRecursively()
}
- writableDatabase.update("events", values, "id = ? AND attempt_token = ?", arrayOf(id, token))
writableDatabase.execSQL("""DELETE FROM events WHERE state IN ('ACCEPTED', 'HTTP_SUCCESS') AND id NOT IN
(SELECT id FROM events WHERE state IN ('ACCEPTED', 'HTTP_SUCCESS') ORDER BY created_at DESC LIMIT 100)""")
changed()
@@ -179,7 +248,7 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe
@Synchronized
fun blockUnreadable(id: String) {
- writableDatabase.execSQL("UPDATE events SET state = 'BLOCKED', outcome = 'LOCAL_ERROR', attempt_token = NULL WHERE id = ? AND payload IS NOT NULL", arrayOf(id))
+ writableDatabase.execSQL("UPDATE events SET state = 'BLOCKED', outcome = 'LOCAL_ERROR', attempt_token = NULL WHERE id = ? AND (payload IS NOT NULL OR payload_file IS NOT NULL)", arrayOf(id))
changed()
}
@@ -195,10 +264,66 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe
@Synchronized
fun delete(id: String) {
- writableDatabase.delete("events", "id = ? AND state != 'SENDING'", arrayOf(id))
+ if (writableDatabase.delete("events", "id = ? AND state != 'SENDING'", arrayOf(id)) > 0) {
+ snapshotDirectory("event-${digest(id)}").deleteRecursively()
+ }
changed()
}
+ private fun snapshotDirectory(name: String): File {
+ require(name.matches(Regex("event-[0-9a-f]{64}"))) { "Invalid snapshot directory" }
+ return File(files, name)
+ }
+
+ private fun saveSnapshot(id: String, envelope: JSONObject, images: List): String {
+ val directory = snapshotDirectory("event-${digest(id)}")
+ // The caller has verified there is no referenced snapshot for this id. A previous crash may have left one before its DB commit.
+ if (directory.exists()) check(directory.deleteRecursively()) { "Cannot remove abandoned snapshot" }
+ val source = images.firstOrNull()?.file?.parentFile
+ val moved = source != null && source.parentFile == files && source.name.startsWith("capture-") &&
+ images.all { it.file.parentFile == source } && source.renameTo(directory)
+ try {
+ if (!moved) check(directory.mkdirs()) { "Cannot create snapshot directory" }
+ val metadata = org.json.JSONArray()
+ images.forEachIndexed { index, image ->
+ val file = if (moved) File(directory, image.file.name) else File(directory, "image-$index").also { image.file.copyTo(it) }
+ metadata.put(image.toJson().put("file", file.name).put("bytes", file.length()).put("checksum", image.checksum))
+ }
+ if (images.isNotEmpty()) envelope.getJSONObject("event").put("images", metadata)
+ val atomic = AtomicFile(File(directory, "request.enc"))
+ val encrypted = cipher.encrypt(envelope.toString())
+ val output = atomic.startWrite()
+ try { output.write(encrypted); atomic.finishWrite(output) }
+ catch (error: Exception) { atomic.failWrite(output); throw error }
+ return directory.name
+ } catch (error: Exception) { directory.deleteRecursively(); throw error }
+ }
+
+ private fun readLegacyPayload(id: String): ByteArray {
+ // Legacy bodies can exceed CursorWindow. Read only a small SQL substr in each cursor.
+ val output = ByteArrayOutputStream()
+ var offset = 1
+ while (true) {
+ val chunk = readableDatabase.rawQuery("SELECT substr(payload, ?, 262144) FROM events WHERE id = ?", arrayOf(offset.toString(), id)).use {
+ check(it.moveToFirst() && !it.isNull(0)) { "Missing queued payload" }
+ it.getBlob(0)
+ }
+ output.write(chunk)
+ if (chunk.size < 262144) return output.toByteArray()
+ offset += chunk.size
+ }
+ }
+
+ @Synchronized
+ fun cleanupFiles() {
+ val referenced = readableDatabase.rawQuery("SELECT payload_file FROM events WHERE payload_file IS NOT NULL", null).use {
+ buildSet { while (it.moveToNext()) add(it.getString(0)) }
+ }
+ val cutoff = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5)
+ files.listFiles()?.filter { it.isDirectory && it.name !in referenced && it.lastModified() < cutoff }
+ ?.forEach { it.deleteRecursively() }
+ }
+
private fun changed() { mutableRevision.value += 1 }
private fun Cursor.entry() = QueueEntry(
diff --git a/app/src/main/java/life/andre/message487/SourcesScreen.kt b/app/src/main/java/life/andre/message487/SourcesScreen.kt
index 149dcdf..4f7025b 100644
--- a/app/src/main/java/life/andre/message487/SourcesScreen.kt
+++ b/app/src/main/java/life/andre/message487/SourcesScreen.kt
@@ -120,6 +120,39 @@ internal fun SourcesScreen(settings: ForwardingSettings, permissions: Permission
)
}
}
+ item {
+ Panel {
+ Text(stringResource(R.string.notification_images), style = MaterialTheme.typography.titleMedium)
+ SupportingText(stringResource(R.string.notification_images_hint))
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ NotificationImageFormat.entries.forEach { format ->
+ FilterChip(selected = settings.imageFormat == format, enabled = !busy,
+ onClick = { model.imageFormat(format) }, label = { Text(format.name) })
+ }
+ }
+ var quality by remember(settings.imageQuality) { mutableFloatStateOf(settings.imageQuality.toFloat()) }
+ Text(stringResource(R.string.image_quality, quality.toInt()))
+ Slider(value = quality, onValueChange = { quality = it },
+ onValueChangeFinished = { model.imageQuality(quality.toInt()) }, valueRange = 0f..100f, steps = 99,
+ enabled = !busy && settings.imageFormat == NotificationImageFormat.JPEG,
+ modifier = Modifier.switchLabel(stringResource(R.string.image_quality_label)))
+ if (settings.imageFormat == NotificationImageFormat.PNG) SupportingText(stringResource(R.string.png_quality_hint))
+ HorizontalDivider()
+ SourceSwitch(Icons.Outlined.Person, R.string.forward_avatars, R.string.forward_avatars_hint,
+ settings.forwardAvatars, !busy, model::forwardAvatars)
+ SourceSwitch(Icons.Outlined.Apps, R.string.forward_app_icons, R.string.forward_app_icons_hint,
+ settings.forwardAppIcons, !busy, model::forwardAppIcons)
+ HorizontalDivider()
+ SourceSwitch(Icons.Outlined.Image, R.string.image_limits_enabled, R.string.image_limits_hint,
+ settings.imageLimitsEnabled, !busy, model::imageLimits)
+ if (settings.imageLimitsEnabled) {
+ ImageLimitField(R.string.image_max_count, settings.imageMaxCount, !busy, model::imageMaxCount)
+ ImageLimitField(R.string.image_max_edge, settings.imageMaxEdge, !busy, model::imageMaxEdge)
+ ImageLimitField(R.string.image_max_kib, settings.imageMaxKiB, !busy, model::imageMaxKiB)
+ ImageLimitField(R.string.image_total_kib, settings.imageTotalKiB, !busy, model::imageTotalKiB)
+ }
+ }
+ }
item {
SectionTitle(stringResource(R.string.selected_apps, settings.packages.size), stringResource(R.string.add_package)) { addPackage = true }
SupportingText(stringResource(R.string.app_selection_short))
@@ -204,3 +237,16 @@ private fun SourceSwitch(icon: ImageVector, title: Int, hint: Int, checked: Bool
SupportingText(stringResource(hint))
}
}
+
+@Composable
+private fun ImageLimitField(label: Int, value: Int, enabled: Boolean, onChange: (Int) -> Unit) {
+ var text by remember(value) { mutableStateOf(value.toString()) }
+ OutlinedTextField(value = text, onValueChange = { input ->
+ text = input
+ input.toIntOrNull()?.takeIf { it > 0 }?.let(onChange)
+ }, label = { Text(stringResource(label)) },
+ isError = text.toIntOrNull()?.let { it > 0 } != true,
+ enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
+ keyboardType = androidx.compose.ui.text.input.KeyboardType.Number))
+}
diff --git a/app/src/main/java/life/andre/message487/TestMessageImages.kt b/app/src/main/java/life/andre/message487/TestMessageImages.kt
new file mode 100644
index 0000000..30dfb08
--- /dev/null
+++ b/app/src/main/java/life/andre/message487/TestMessageImages.kt
@@ -0,0 +1,64 @@
+package life.andre.message487
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import java.io.File
+import java.util.UUID
+
+data class TestMessageOptions(
+ val bigPicture: Boolean = false,
+ val gallery: Boolean = false,
+ val avatar: Boolean = false,
+ val appIcon: Boolean = false,
+)
+
+internal fun testMessageImages(context: Context, settings: ForwardingSettings,
+ options: TestMessageOptions): List {
+ if (options == TestMessageOptions()) return emptyList()
+ val directory = File(context.noBackupFilesDir, "outbox/capture-${UUID.randomUUID()}").apply { mkdirs() }
+ val images = mutableListOf()
+ val limited = settings.imageLimitsEnabled
+ var remaining = if (limited) settings.imageTotalKiB.toLong() * 1024 else Long.MAX_VALUE
+ fun add(kind: String, presentation: String = "file", label: String = "", color: Int = Color.BLUE) {
+ if (remaining <= 0 || (limited && kind == "attachment" &&
+ images.count { it.kind == "attachment" } >= settings.imageMaxCount)) return
+ val bitmap = Bitmap.createBitmap(if (kind == "attachment") 640 else 512,
+ if (kind == "attachment") 400 else 512, Bitmap.Config.ARGB_8888)
+ try {
+ val canvas = Canvas(bitmap)
+ if (kind == "attachment") {
+ canvas.drawColor(color)
+ canvas.drawText(label, 32f, 220f, Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ this.color = Color.WHITE; textSize = 56f
+ })
+ } else {
+ val icon = requireNotNull(context.getDrawable(context.applicationInfo.icon))
+ icon.setBounds(0, 0, bitmap.width, bitmap.height)
+ icon.draw(canvas)
+ }
+ val image = encodeNotificationImage(bitmap, settings.imageFormat, settings.imageQuality, directory,
+ maxBytes = minOf(remaining, if (limited) settings.imageMaxKiB.toLong() * 1024 else Long.MAX_VALUE),
+ maxEdge = if (limited) settings.imageMaxEdge else Int.MAX_VALUE)
+ .copy(kind = kind, presentation = presentation)
+ images += image
+ remaining -= image.file.length()
+ } finally { bitmap.recycle() }
+ }
+ try {
+ if (options.bigPicture) add("attachment", "big_picture", "Big Picture", Color.rgb(40, 90, 190))
+ if (options.gallery) {
+ add("attachment", "gallery", "Gallery 1", Color.rgb(40, 140, 90))
+ add("attachment", "gallery", "Gallery 2", Color.rgb(150, 60, 160))
+ }
+ if (options.avatar) add("avatar")
+ if (options.appIcon) add("app_icon")
+ if (images.isEmpty()) directory.delete()
+ return images
+ } catch (error: Exception) {
+ directory.deleteRecursively()
+ throw error
+ }
+}
diff --git a/app/src/main/java/life/andre/message487/WebhookClient.kt b/app/src/main/java/life/andre/message487/WebhookClient.kt
index 493d3c8..eb1a1e6 100644
--- a/app/src/main/java/life/andre/message487/WebhookClient.kt
+++ b/app/src/main/java/life/andre/message487/WebhookClient.kt
@@ -1,7 +1,6 @@
package life.andre.message487
import org.json.JSONObject
-import java.io.IOException
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
@@ -9,6 +8,11 @@ import java.net.URI
import java.nio.charset.StandardCharsets
import java.time.Instant
import java.util.UUID
+import java.io.OutputStream
+import java.io.IOException
+import java.util.Base64
+import java.io.FilterOutputStream
+import java.security.MessageDigest
enum class DeliveryStatus { ACCEPTED, HTTP_SUCCESS, HTTP_ERROR, INVALID_ACK, TIMEOUT, NETWORK_ERROR }
@@ -29,7 +33,9 @@ data class MessageEvent(
val text: String = "Message487 connection test",
val title: String? = null,
val sender: String? = null,
+ val images: List = emptyList(),
) {
+ /** Image bytes are appended separately by QueuedRequest.writeTo to keep the body streaming. */
fun toJson(): String = JSONObject()
.put("schema_version", 1)
.put("event_id", eventId)
@@ -58,9 +64,19 @@ fun validAuthToken(value: String): Boolean = value.length in 1..4096 &&
class WebhookClient(private val timeoutMs: Int = 10_000) {
fun send(url: String, event: MessageEvent, requireAck: Boolean, authToken: String): DeliveryResult =
- sendJson(url, event.eventId, event.toJson(), requireAck, authToken)
+ sendRequest(QueuedRequest(url, requireAck, event.toJson(), authToken, event.images), event.eventId)
+
+ fun sendRequest(request: QueuedRequest, eventId: String): DeliveryResult =
+ if (request.images.isEmpty()) sendJson(request.url, eventId, request.json, request.requireAck, request.authToken)
+ else sendBody(request.url, eventId, request.requireAck, request.authToken, null, request::writeTo)
fun sendJson(url: String, eventId: String, json: String, requireAck: Boolean, authToken: String): DeliveryResult {
+ val payload = json.toByteArray(StandardCharsets.UTF_8)
+ return sendBody(url, eventId, requireAck, authToken, payload.size.toLong()) { it.write(payload) }
+ }
+
+ private fun sendBody(url: String, eventId: String, requireAck: Boolean, authToken: String, length: Long?,
+ write: (OutputStream) -> Unit): DeliveryResult {
require(validAuthToken(authToken)) { "Invalid authentication token" }
val start = System.nanoTime()
var connection: HttpURLConnection? = null
@@ -77,9 +93,8 @@ class WebhookClient(private val timeoutMs: Int = 10_000) {
setRequestProperty("Accept", "application/json")
setRequestProperty("Authorization", "Bearer $authToken")
}
- val payload = json.toByteArray(StandardCharsets.UTF_8)
- connection.setFixedLengthStreamingMode(payload.size)
- connection.outputStream.use { it.write(payload) }
+ if (length == null) connection.setChunkedStreamingMode(8192) else connection.setFixedLengthStreamingMode(length)
+ connection.outputStream.use(write)
httpCode = connection.responseCode
when {
httpCode !in 200..299 -> DeliveryStatus.HTTP_ERROR
@@ -99,6 +114,8 @@ class WebhookClient(private val timeoutMs: Int = 10_000) {
else validateAck(String(bytes, StandardCharsets.UTF_8), eventId)
}
}
+ } catch (error: UnreadableImageException) {
+ throw error
} catch (_: SocketTimeoutException) {
DeliveryStatus.TIMEOUT
} catch (_: IOException) {
@@ -120,3 +137,36 @@ class WebhookClient(private val timeoutMs: Int = 10_000) {
}.getOrDefault(DeliveryStatus.INVALID_ACK)
}
}
+
+internal class UnreadableImageException(cause: Exception? = null) : IOException("Queued image is unavailable or changed", cause)
+
+internal fun QueuedRequest.writeTo(output: OutputStream) {
+ if (images.isEmpty()) { output.write(json.toByteArray(Charsets.UTF_8)); return }
+ require(json.endsWith("}"))
+ output.write(json.dropLast(1).toByteArray(Charsets.UTF_8))
+ output.write(",\"images\":[".toByteArray())
+ images.forEachIndexed { index, image ->
+ if (index != 0) output.write(','.code)
+ output.write(image.toJson().toString().dropLast(1).toByteArray(Charsets.UTF_8))
+ output.write(",\"data_base64\":\"".toByteArray())
+ val digest = MessageDigest.getInstance("SHA-256")
+ val input = try { image.file.inputStream() } catch (error: IOException) { throw UnreadableImageException(error) }
+ input.use {
+ Base64.getEncoder().wrap(object : FilterOutputStream(output) {
+ override fun write(buffer: ByteArray, offset: Int, length: Int) { output.write(buffer, offset, length) }
+ override fun close() { flush() }
+ }).use { encoded ->
+ val buffer = ByteArray(8192)
+ while (true) {
+ val count = try { input.read(buffer) } catch (error: IOException) { throw UnreadableImageException(error) }
+ if (count < 0) break
+ digest.update(buffer, 0, count)
+ encoded.write(buffer, 0, count)
+ }
+ }
+ }
+ if (Base64.getEncoder().encodeToString(digest.digest()) != image.checksum) throw UnreadableImageException()
+ output.write("\"}".toByteArray())
+ }
+ output.write("]}".toByteArray())
+}
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 574c170..a1c7048 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -1,4 +1,15 @@
+ Пересылать аватары
+ По умолчанию выключено. Добавлять последний доступный аватар отправителя или крупный значок уведомления. Используются выбранный формат и качество JPEG.
+ Пересылать значки приложений
+ По умолчанию выключено. Добавлять значок выбранного приложения-источника. Используются выбранный формат и качество JPEG.
+
+ Графика
+ К изображениям выбранных приложений применяются формат и ограничения ниже. Недоступные изображения пропускаются, текст отправляется. В разделе Connection есть отдельный блок выбора тестовых изображений.
+ Качество JPEG: %1$d%%
+ Качество изображений JPEG
+ PNG использует сжатие без потерь; качество JPEG к нему не применяется. Размеры изображения могут уменьшаться для соблюдения лимита.
+
Автоматически выбирать новые приложения
По умолчанию выключено. При открытии или возврате в Message487 новые приложения со значком запуска выбираются для пересылки уведомлений. Текущий выбор сохраняется; снятый вручную выбор не возвращается. Избегайте циклов пересылки.
Выбрать все приложения
@@ -34,12 +45,12 @@
Приостановить пересылку
Приостановить приём и отправку очереди. Уже начавшийся запрос может завершиться.
Нет разрешения на SMS. Разрешите доступ в настройках приложения.
- Не удалось сохранить или запланировать некоторые события. Проверьте свободное место и журнал.
+ Не удалось прочитать некоторые изображения уведомлений, сохранить события или запланировать отправку. Проверьте свободное место, доступ к источникам и журнал.
Скрыть
Не удалось выполнить операцию. Проверьте место на устройстве и настройки.
Не удалось открыть настройки Android.
События в очереди сохраняют адрес получателя, токен и режим подтверждения на момент приёма.
- Включённые источники передают текст, имя источника и время на сохранённый webhook. SMS также содержит отправителя. По умолчанию оба источника выключены.
+ Включённые источники отправляют текст, изображения уведомлений, названия источников и время в сохранённый webhook. SMS также содержит отправителя. Оба источника изначально выключены.
Уведомления
Только выбранные приложения. Постоянные уведомления и сводки групп пропускаются.
Настройки доступа к уведомлениям
@@ -85,7 +96,7 @@
Выключено
Нужна настройка
Не настроено
- Отправить тест
+ Отправить текстовый тест
Куда отправлять сообщения
Подключите сценарий n8n или свой webhook.
Получатель
@@ -168,4 +179,16 @@
Ошибка сети или временная ошибка сервера. Повтор запрошен через 30 минут с увеличением интервала; Android может отложить запуск.
Android прервал проверку. Запрошена повторная попытка.
Проверка не удалась. Повторите вручную; выбранный источник не изменён.
+ Ограничивать изображения
+ Выключите, чтобы отправлять все доступные вложения без уменьшения и ограничений объёма отправки. Защита при чтении входных изображений сохраняется. Количество относится к вложениям, без аватара и значка; общий объём учитывает все изображения.
+ Максимум вложений на уведомление
+ Максимальная сторона изображения (пиксели)
+ Максимальный объём изображения (КиБ)
+ Общий объём на уведомление (КиБ)
+ Тестовое сообщение
+ Выберите дополнения только для этого теста. Если всё выключено, отправляется только текст. Формат, качество и ограничения берутся из Источники → Графика.
+ Большая картинка
+ Галерея (2 изображения)
+ Аватар
+ Значок приложения
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cc45c00..2c3deda 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,4 +1,15 @@
+ Forward avatars
+ Off by default. Include the latest available sender avatar, or the notification’s large icon. Uses the chosen image format and JPEG quality.
+ Forward app icons
+ Off by default. Include the selected source app’s icon. Uses the chosen image format and JPEG quality.
+
+ Graphics
+ Images from selected apps use the format and limits below. Unavailable images are skipped; text is still forwarded. Connection has a separate block for choosing test images.
+ JPEG quality: %1$d%%
+ JPEG image quality
+ PNG uses lossless compression; JPEG quality does not apply. Images may still be resized to fit the size limit.
+
Automatically select new apps
Off by default. When you open or return to Message487, newly detected apps with launcher icons are selected for notification forwarding. Existing choices stay unchanged; apps you deselect stay excluded. Avoid forwarding loops.
Select all applications
@@ -34,12 +45,12 @@
Pause forwarding
Pause capture and queued delivery. A request already in progress may finish.
SMS permission is missing. Enable SMS access in the app permissions.
- Some events could not be saved or scheduled. Check available storage and the journal.
+ Some notification images could not be read, or events could not be saved or scheduled. Check available storage, source permissions and the journal.
Dismiss
Could not complete the local operation. Check storage and settings.
Could not open Android settings.
Queued events keep the recipient, token and confirmation mode used when they were captured.
- Enabled sources send message text, source names and timestamps to your saved webhook. SMS also includes the sender. Both sources are off initially.
+ Enabled sources send message text, notification images, source names and timestamps to your saved webhook. SMS also includes the sender. Both sources are off initially.
Notifications
Selected apps only. Ongoing notifications and group summaries are skipped.
Notification access settings
@@ -85,7 +96,7 @@
Off
Needs setup
Not configured
- Send test event
+ Send text test
Where messages go
Connect an n8n workflow or your own webhook.
Recipient
@@ -168,4 +179,16 @@
A network or temporary server error occurred. A retry is requested after 30 minutes, with increasing delays; Android may defer it.
Android interrupted the check. A retry was requested.
The check failed. Retry manually; the selected source has not changed.
+ Limit images
+ Turn off to send all available attachments without resizing or output size limits. Input decoding safeguards still apply. Limits count attachments separately from the optional avatar and app icon; the size budget includes all images.
+ Maximum attachments per notification
+ Maximum image side (pixels)
+ Maximum per image (KiB)
+ Maximum total per notification (KiB)
+ Test message
+ Choose extra content for this test only. All options off sends text only. Image format, quality and limits come from Sources → Graphics.
+ Big Picture
+ Gallery (2 images)
+ Avatar
+ Application icon
diff --git a/app/src/test/java/life/andre/message487/ImageFixtures.kt b/app/src/test/java/life/andre/message487/ImageFixtures.kt
new file mode 100644
index 0000000..a46ae32
--- /dev/null
+++ b/app/src/test/java/life/andre/message487/ImageFixtures.kt
@@ -0,0 +1,13 @@
+package life.andre.message487
+
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.util.Base64
+
+internal fun imageFixture(value: String, mime: String = "image/jpeg", width: Int = 2, height: Int = 2): NotificationImage {
+ val file = File.createTempFile("message487-image-fixture-", ".image").apply { writeText(value); deleteOnExit() }
+ return NotificationImage(mime, width, height, file)
+}
+
+internal fun imageBase64(image: NotificationImage): String = Base64.getEncoder().encodeToString(image.file.readBytes())
+internal fun requestJson(request: QueuedRequest): String = ByteArrayOutputStream().also { request.writeTo(it) }.toString("UTF-8")
diff --git a/app/src/test/java/life/andre/message487/MessageGraphTest.kt b/app/src/test/java/life/andre/message487/MessageGraphTest.kt
new file mode 100644
index 0000000..192c56f
--- /dev/null
+++ b/app/src/test/java/life/andre/message487/MessageGraphTest.kt
@@ -0,0 +1,73 @@
+package life.andre.message487
+
+import org.json.JSONObject
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = UiTestApplication::class, sdk = [26, 35])
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class MessageGraphTest {
+ @Test fun `overview tests are text only and connection tests use explicitly selected image features`() {
+ val app = RuntimeEnvironment.getApplication() as UiTestApplication
+ // Queue requests without running delivery workers while inspecting payloads.
+ val idle = java.util.concurrent.Executor { }
+ androidx.work.WorkManager.initialize(app, androidx.work.Configuration.Builder()
+ .setExecutor(idle).setTaskExecutor(idle).build())
+ org.robolectric.Shadows.shadowOf(app.packageManager).setApplicationIcon(
+ app.packageName, requireNotNull(app.getDrawable(app.applicationInfo.icon)))
+ val graph = app.graph
+ try {
+ for ((avatars, icons) in listOf(false to false, true to false, false to true, true to true)) {
+ graph.settings.update { it.copy(url = "https://example.test/receive", authToken = "test-token",
+ deviceCode = "test", forwardAvatars = avatars, forwardAppIcons = icons,
+ imageFormat = NotificationImageFormat.PNG) }
+ val previous = graph.outbox.pendingIds().toSet()
+ graph.enqueueTest()
+ val id = graph.outbox.pendingIds().single { it !in previous }
+ val event = JSONObject(requestJson(graph.outbox.beginAttempt(id)!!.request))
+ assertEquals("test", event.getString("message_type"))
+ assertFalse(event.has("images"))
+ }
+ // Test options are independent from the capture opt-ins above.
+ graph.settings.update { it.copy(forwardAvatars = false, forwardAppIcons = false) }
+ for (mask in 0..15) {
+ val options = TestMessageOptions(mask and 1 != 0, mask and 2 != 0,
+ mask and 4 != 0, mask and 8 != 0)
+ val previous = graph.outbox.pendingIds().toSet()
+ graph.enqueueTest(options)
+ val id = graph.outbox.pendingIds().single { it !in previous }
+ val event = JSONObject(requestJson(graph.outbox.beginAttempt(id)!!.request))
+ val expected = buildList {
+ if (options.bigPicture) add("attachment:big_picture")
+ if (options.gallery) { add("attachment:gallery"); add("attachment:gallery") }
+ if (options.avatar) add("avatar:file")
+ if (options.appIcon) add("app_icon:file")
+ }
+ val images = event.optJSONArray("images")
+ assertEquals(expected, (0 until (images?.length() ?: 0)).map {
+ val image = images!!.getJSONObject(it)
+ assertEquals("image/png", image.getString("mime_type"))
+ assertTrue(image.getString("data_base64").isNotEmpty())
+ image.getString("kind") + ":" + image.getString("presentation")
+ })
+ }
+ graph.settings.update { it.copy(imageMaxCount = 1) }
+ val limited = testMessageImages(app, graph.settings.state.value,
+ TestMessageOptions(bigPicture = true, gallery = true))
+ try {
+ assertEquals(listOf("big_picture"), limited.map { it.presentation })
+ } finally { discardImages(limited) }
+ } finally {
+ graph.captureExecutor.shutdownNow()
+ graph.imageCaptureQueue.close()
+ graph.outbox.close()
+ app.diagnostics.close()
+ }
+ }
+}
diff --git a/app/src/test/java/life/andre/message487/NotificationImageQueueTest.kt b/app/src/test/java/life/andre/message487/NotificationImageQueueTest.kt
new file mode 100644
index 0000000..4b48fb8
--- /dev/null
+++ b/app/src/test/java/life/andre/message487/NotificationImageQueueTest.kt
@@ -0,0 +1,70 @@
+package life.andre.message487
+
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [26, 35], manifest = Config.NONE)
+class NotificationImageQueueTest {
+ @Test fun `hung capture expires without blocking text callbacks and late images are discarded`() {
+ val started = CountDownLatch(1)
+ val release = CountDownLatch(1)
+ val delivered = CountDownLatch(2)
+ val count = AtomicInteger()
+ val late = imageFixture("late")
+ NotificationImageQueue(timeoutMs = 200, capacity = 1).use { queue ->
+ try {
+ queue.submit("slow", {
+ started.countDown()
+ release.await(3, TimeUnit.SECONDS)
+ listOf(late)
+ }, { images -> assertTrue(images.isEmpty()); count.incrementAndGet(); delivered.countDown() }, { throw it })
+ assertTrue(started.await(1, TimeUnit.SECONDS))
+ queue.submit("waiting", { error("Expired queued task must not execute") },
+ { images -> assertTrue(images.isEmpty()); count.incrementAndGet(); delivered.countDown() }, { throw it })
+ assertTrue(delivered.await(2, TimeUnit.SECONDS))
+ assertEquals(2, count.get())
+ release.countDown()
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2)
+ while (late.file.exists() && System.nanoTime() < deadline) Thread.sleep(10)
+ assertFalse(late.file.exists())
+ assertEquals(2, count.get())
+ } finally { release.countDown() }
+ }
+ }
+ @Test fun `coalescing and capacity fallback keep text and only latest pending image capture runs`() {
+ val started = CountDownLatch(1)
+ val release = CountDownLatch(1)
+ val delivered = CountDownLatch(4)
+ val oldRuns = AtomicInteger()
+ val latestRuns = AtomicInteger()
+ val overflowRuns = AtomicInteger()
+ val imageCallbacks = AtomicInteger()
+ val photo = imageFixture("latest")
+ NotificationImageQueue(timeoutMs = 3000, capacity = 1).use { queue ->
+ try {
+ queue.submit("active", { started.countDown(); release.await(2, TimeUnit.SECONDS); emptyList() },
+ { delivered.countDown() }, { throw it })
+ assertTrue(started.await(1, TimeUnit.SECONDS))
+ queue.submit("same", { oldRuns.incrementAndGet(); emptyList() }, { delivered.countDown() }, { throw it })
+ queue.submit("same", { latestRuns.incrementAndGet(); listOf(photo) }, {
+ if (it.singleOrNull()?.file?.exists() == true) imageCallbacks.incrementAndGet()
+ delivered.countDown()
+ }, { throw it })
+ queue.submit("overflow", { overflowRuns.incrementAndGet(); emptyList() }, { delivered.countDown() }, { throw it })
+ release.countDown()
+ assertTrue(delivered.await(2, TimeUnit.SECONDS))
+ assertEquals(0, oldRuns.get())
+ assertEquals(1, latestRuns.get())
+ assertEquals(0, overflowRuns.get())
+ assertEquals(1, imageCallbacks.get())
+ } finally { release.countDown() }
+ }
+ }
+}
diff --git a/app/src/test/java/life/andre/message487/NotificationImagesTest.kt b/app/src/test/java/life/andre/message487/NotificationImagesTest.kt
new file mode 100644
index 0000000..b756530
--- /dev/null
+++ b/app/src/test/java/life/andre/message487/NotificationImagesTest.kt
@@ -0,0 +1,297 @@
+package life.andre.message487
+
+import android.app.Application
+import android.app.Notification
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Color
+import android.graphics.drawable.Icon
+import android.net.Uri
+import android.os.Bundle
+import android.util.Base64
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import org.robolectric.Shadows.shadowOf
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [26, 35], manifest = Config.NONE)
+class NotificationImagesTest {
+ private val context get() = RuntimeEnvironment.getApplication()
+ private fun bitmap(size: Int = 128, seed: Long = 487) = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
+ val random = java.util.Random(seed)
+ setPixels(IntArray(size * size) { Color.rgb(random.nextInt(256), random.nextInt(256), random.nextInt(256)) },
+ 0, size, 0, 0, size, size)
+ }
+ private val inputFiles = mutableMapOf()
+ private fun registerImage(uri: Uri, data: ByteArray) {
+ val file = java.io.File.createTempFile("input-", ".image", context.cacheDir).apply { writeBytes(data) }
+ inputFiles[uri.toString()] = file
+ val provider = object : android.content.ContentProvider() {
+ override fun onCreate() = true
+ override fun getType(uri: Uri) = "image/png"
+ override fun query(uri: Uri, projection: Array?, selection: String?, args: Array?, sort: String?) = null
+ override fun insert(uri: Uri, values: android.content.ContentValues?) = null
+ override fun delete(uri: Uri, selection: String?, args: Array?) = 0
+ override fun update(uri: Uri, values: android.content.ContentValues?, selection: String?, args: Array?) = 0
+ override fun openFile(uri: Uri, mode: String) = android.os.ParcelFileDescriptor.open(
+ requireNotNull(inputFiles[uri.toString()]), android.os.ParcelFileDescriptor.MODE_READ_ONLY)
+ }
+ provider.attachInfo(context, android.content.pm.ProviderInfo().apply { authority = uri.authority })
+ org.robolectric.shadows.ShadowContentResolver.registerProviderInternal(requireNotNull(uri.authority), provider)
+ }
+ private fun bytes(image: NotificationImage) = image.file.readBytes()
+ private fun png(bitmap: Bitmap) = ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray()
+ private fun extract(notification: Notification, format: NotificationImageFormat = NotificationImageFormat.JPEG,
+ failures: MutableList = mutableListOf()) = notificationImages(context, notification,
+ ForwardingSettings(imageFormat = format), onFailure = failures::add)
+
+ @Test fun `collection checksum uses the SHA256 reference empty hash`() {
+ assertEquals("47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", notificationImagesChecksum(emptyList()))
+ }
+
+ @Test fun `JPEG quality changes encoded size while PNG keeps transparency and ignores JPEG quality`() {
+ val picture = bitmap()
+ val low = encodeNotificationImage(picture, NotificationImageFormat.JPEG, 10, context.cacheDir)
+ val high = encodeNotificationImage(picture, NotificationImageFormat.JPEG, 100, context.cacheDir)
+ assertEquals("image/jpeg", low.mimeType)
+ assertEquals(0xff, bytes(low)[0].toInt() and 255)
+ assertEquals(0xd8, bytes(low)[1].toInt() and 255)
+ assertTrue(bytes(low).size < bytes(high).size)
+ assertEquals(128, BitmapFactory.decodeByteArray(bytes(high), 0, bytes(high).size).width)
+ picture.eraseColor(Color.TRANSPARENT)
+ val first = encodeNotificationImage(picture, NotificationImageFormat.PNG, 0, context.cacheDir)
+ val second = encodeNotificationImage(picture, NotificationImageFormat.PNG, 100, context.cacheDir)
+ assertEquals(first.checksum, second.checksum)
+ assertEquals("image/png", first.mimeType)
+ assertEquals(Color.TRANSPARENT, BitmapFactory.decodeByteArray(bytes(first), 0, bytes(first).size).getPixel(0, 0))
+ val jpeg = encodeNotificationImage(picture, NotificationImageFormat.JPEG, 80, context.cacheDir)
+ assertEquals(Color.WHITE, BitmapFactory.decodeByteArray(bytes(jpeg), 0, bytes(jpeg).size).getPixel(0, 0))
+ assertFalse(picture.isRecycled)
+ assertFalse(first.toString().contains(imageBase64(first)))
+ assertThrows(IllegalArgumentException::class.java) { encodeNotificationImage(picture, NotificationImageFormat.JPEG, 101, context.cacheDir) }
+ picture.recycle()
+ }
+
+ @Test fun `large PNG is resized to bound dimensions and payload without changing the original bitmap`() {
+ val picture = bitmap(1600)
+ val image = encodeNotificationImage(picture, NotificationImageFormat.PNG, 80, context.cacheDir)
+ assertTrue(image.width <= 1024 && image.height <= 1024)
+ assertTrue(bytes(image).size <= MAX_NOTIFICATION_IMAGE_BYTES)
+ assertEquals(image.width, BitmapFactory.decodeByteArray(bytes(image), 0, bytes(image).size).width)
+ assertEquals(1600, picture.width)
+ assertFalse(picture.isRecycled)
+ picture.recycle()
+ }
+
+ @Test fun `BigPicture is forwarded but avatars are excluded`() {
+ val picture = bitmap()
+ val notification = Notification().apply {
+ extras = Bundle().apply { putParcelable(Notification.EXTRA_PICTURE, picture) }
+ }
+ assertEquals("big_picture", extract(notification).single().presentation)
+ notification.extras = Bundle().apply { putParcelable(Notification.EXTRA_LARGE_ICON, picture) }
+ assertTrue(extract(notification).isEmpty())
+ if (android.os.Build.VERSION.SDK_INT >= 31) {
+ notification.extras = Bundle().apply { putParcelable(Notification.EXTRA_PICTURE_ICON, Icon.createWithBitmap(picture)) }
+ assertEquals("image/png", extract(notification, NotificationImageFormat.PNG).single().mimeType)
+ }
+ picture.recycle()
+ }
+
+ @Test fun `avatars and application icons require independent opt ins`() {
+ val picture = bitmap()
+ val source = "example.sender"
+ shadowOf(context.packageManager).setApplicationIcon(source, android.graphics.drawable.BitmapDrawable(context.resources, picture))
+ val notification = Notification.Builder(context, "test")
+ .setSmallIcon(android.R.drawable.ic_dialog_info).setLargeIcon(picture).build()
+ val failures = mutableListOf()
+ fun images(avatars: Boolean = false, icons: Boolean = false) = notificationImages(
+ context, notification, ForwardingSettings(forwardAvatars = avatars, forwardAppIcons = icons), source,
+ onFailure = failures::add)
+ assertTrue(images().isEmpty())
+ assertEquals(listOf("avatar"), images(avatars = true).map { it.kind })
+ assertEquals(listOf("app_icon"), images(icons = true).map { it.kind })
+ val both = images(avatars = true, icons = true)
+ assertEquals(listOf("avatar", "app_icon"), both.map { it.kind })
+ both.forEach {
+ assertTrue(bytes(it).size <= MAX_NOTIFICATION_IMAGE_BYTES)
+ assertEquals(it.kind, it.toJson().getString("kind"))
+ }
+ assertTrue(failures.toString(), failures.isEmpty())
+ assertFalse(picture.isRecycled)
+ picture.recycle()
+ }
+
+ @Test fun `icons retain source resolution when they fit the configured image limits`() {
+ val picture = Bitmap.createBitmap(800, 400, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
+ val source = "example.sender"
+ shadowOf(context.packageManager).setApplicationIcon(source,
+ android.graphics.drawable.BitmapDrawable(context.resources, picture))
+ val notification = Notification().apply {
+ extras = Bundle().apply {
+ putParcelable(Notification.EXTRA_PICTURE, picture)
+ putParcelable(Notification.EXTRA_LARGE_ICON, picture)
+ }
+ }
+ val failures = mutableListOf()
+ try {
+ for (limited in listOf(true, false)) {
+ val images = notificationImages(context, notification, ForwardingSettings(
+ forwardAvatars = true, forwardAppIcons = true,
+ imageFormat = NotificationImageFormat.PNG, imageLimitsEnabled = limited), source,
+ onFailure = failures::add)
+ try {
+ assertEquals(listOf("attachment", "avatar", "app_icon"), images.map { it.kind })
+ for (image in images) {
+ val expectedWidth = 800
+ assertEquals(expectedWidth, image.width)
+ assertEquals(expectedWidth / 2, image.height)
+ val decoded = BitmapFactory.decodeFile(image.file.path)
+ try {
+ assertEquals(expectedWidth, decoded.width)
+ assertEquals(expectedWidth / 2, decoded.height)
+ } finally { decoded.recycle() }
+ }
+ } finally { discardImages(images) }
+ }
+ assertTrue(failures.toString(), failures.isEmpty())
+ assertFalse(picture.isRecycled)
+ } finally { picture.recycle() }
+ }
+
+ @Test fun `sender avatar takes priority over large icon`() {
+ val picture = bitmap(32)
+ val sender = androidx.core.app.Person.Builder().setName("sender")
+ .setIcon(androidx.core.graphics.drawable.IconCompat.createWithBitmap(picture)).build()
+ val notification = androidx.core.app.NotificationCompat.Builder(context, "test")
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setStyle(androidx.core.app.NotificationCompat.MessagingStyle("self")
+ .addMessage(androidx.core.app.NotificationCompat.MessagingStyle.Message("hello", 1L, sender)))
+ .build()
+ val failures = mutableListOf()
+ val images = notificationImages(context, notification, ForwardingSettings(forwardAvatars = true), onFailure = failures::add)
+ assertEquals("avatar", images.single().kind)
+ assertEquals(32, images.single().width)
+ assertTrue(failures.toString(), failures.isEmpty())
+ picture.recycle()
+ }
+
+ @Test fun `configurable limits bound output and can be disabled`() {
+ val picture = bitmap(1200)
+ val notification = Notification().apply {
+ extras = Bundle().apply { putParcelable(Notification.EXTRA_PICTURE, picture) }
+ }
+ val failures = mutableListOf()
+ val limits = ForwardingSettings(imageMaxEdge = 64, imageMaxKiB = 1, imageTotalKiB = 1)
+ val limited = notificationImages(context, notification, limits, onFailure = failures::add).single()
+ assertTrue(limited.width <= 64 && bytes(limited).size <= 1024)
+ val unlimited = notificationImages(context, notification, limits.copy(imageLimitsEnabled = false),
+ onFailure = failures::add).single()
+ assertEquals(1200, unlimited.width)
+ assertTrue(bytes(unlimited).size > 1024)
+ val messages = (0..3).map { index ->
+ val uri = Uri.parse("content://example.test/distinct/$index")
+ val small = bitmap(32).apply { eraseColor(Color.rgb(index * 50, 30, 80)) }
+ registerImage(uri, png(small))
+ small.recycle()
+ Notification.MessagingStyle.Message("", 1L, "sender").setData("image/png", uri)
+ }
+ val grouped = messaging(messages)
+ val bounded = notificationImages(context, grouped, limits.copy(imageMaxCount = 2, imageTotalKiB = 4), onFailure = failures::add)
+ assertEquals(failures.joinToString("\n") { it.stackTraceToString() }, 2, bounded.size)
+ // Register fresh streams because content providers may expose a single stream per read in this test.
+ messages.forEachIndexed { index, message ->
+ val small = bitmap(32).apply { eraseColor(Color.rgb(index * 50, 30, 80)) }
+ registerImage(message.dataUri!!, png(small))
+ small.recycle()
+ }
+ assertEquals(4, notificationImages(context, grouped, limits.copy(imageLimitsEnabled = false), onFailure = failures::add).size)
+ assertTrue(failures.toString(), failures.isEmpty())
+ picture.recycle()
+ }
+
+ @Test fun `size budgets are order independent and duplicates or broken images do not consume count`() {
+ val a = bitmap(128, 1).let { png(it).also { _ -> it.recycle() } }
+ val b = bitmap(128, 2).let { png(it).also { _ -> it.recycle() } }
+ val settings = ForwardingSettings(imageFormat = NotificationImageFormat.PNG, imageMaxKiB = 8, imageTotalKiB = 6)
+ val failures = mutableListOf()
+ fun extract(order: List>): List {
+ val messages = order.map { (name, data) ->
+ val uri = Uri.parse("content://example.test/review/$name")
+ registerImage(uri, data)
+ Notification.MessagingStyle.Message("", 1L, "sender").setData("image/png", uri)
+ }
+ return notificationImages(context, messaging(messages), settings, onFailure = failures::add)
+ }
+ val first = extract(listOf("a" to a, "b" to b))
+ val reversed = extract(listOf("b" to b, "a" to a))
+ assertEquals(2, first.size)
+ assertEquals(notificationImagesChecksum(first), notificationImagesChecksum(reversed))
+ val duplicate = extract(listOf("a" to a, "b" to b, "a-copy" to a))
+ assertEquals(2, duplicate.size)
+ assertEquals(notificationImagesChecksum(first), notificationImagesChecksum(duplicate))
+ assertTrue(first.sumOf { it.file.length() } <= 6 * 1024)
+ val afterBroken = extract(listOf("valid" to a, "bad1" to byteArrayOf(1), "bad2" to byteArrayOf(2), "bad3" to byteArrayOf(3)))
+ assertEquals(1, afterBroken.size)
+ assertEquals(3, failures.size)
+ }
+
+ @Test fun `all EXIF rotations and mirrors are applied without mutating source`() {
+ val palette = listOf(Color.RED, Color.GREEN, Color.BLUE, Color.YELLOW)
+ val picture = Bitmap.createBitmap(64, 48, Bitmap.Config.ARGB_8888).apply {
+ setPixels(IntArray(64 * 48) { index -> palette[(if (index / 64 >= 24) 2 else 0) + (if (index % 64 >= 32) 1 else 0)] },
+ 0, 64, 0, 0, 64, 48)
+ }
+ val expected = listOf(listOf(0,1,2,3), listOf(1,0,3,2), listOf(3,2,1,0), listOf(2,3,0,1),
+ listOf(0,2,1,3), listOf(2,0,3,1), listOf(3,1,2,0), listOf(1,3,0,2))
+ for (orientation in 1..8) {
+ val image = encodeNotificationImage(picture, NotificationImageFormat.PNG, 80, context.cacheDir, orientation = orientation)
+ val decoded = BitmapFactory.decodeFile(image.file.path)
+ assertEquals(if (orientation >= 5) 48 else 64, decoded.width)
+ assertEquals(if (orientation >= 5) 64 else 48, decoded.height)
+ val corners = listOf(decoded.width/4 to decoded.height/4, decoded.width*3/4 to decoded.height/4,
+ decoded.width/4 to decoded.height*3/4, decoded.width*3/4 to decoded.height*3/4)
+ assertEquals("orientation=$orientation", expected[orientation-1], corners.map { (x,y) -> palette.indexOf(decoded.getPixel(x,y)) })
+ decoded.recycle()
+ }
+ assertFalse(picture.isRecycled)
+ picture.recycle()
+ }
+
+ private fun messaging(messages: List): Notification =
+ Notification.Builder(context, "test").setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setStyle(Notification.MessagingStyle("self").also { style -> messages.forEach(style::addMessage) }).build()
+
+ @Test fun `messaging attachments are decoded from bounded content URIs and invalid sources do not abort valid images`() {
+ val picture = bitmap(32)
+ val data = png(picture)
+ val uris = (0..3).map { Uri.parse("content://example.test/image/$it") }
+ uris.forEach { registerImage(it, data) }
+ val messages = uris.map { Notification.MessagingStyle.Message("", 1L, "sender")
+ .setData("image/png", it) }
+ var notification = messaging(messages)
+ // Identical image content is included only once.
+ assertEquals("gallery", extract(notification).single().presentation)
+ val failures = mutableListOf()
+ notification = messaging(listOf(
+ Notification.MessagingStyle.Message("", 1L, "sender").setData("image/png", Uri.parse("https://example.test/image")),
+ Notification.MessagingStyle.Message("", 1L, "sender").setData("image/png", Uri.parse("file:///private/image.png"))))
+ assertTrue(extract(notification, failures = failures).isEmpty())
+ assertEquals(2, failures.size)
+ val oversized = Uri.parse("content://example.test/large")
+ registerImage(oversized, ByteArray(8 * 1024 * 1024 + 1))
+ notification = messaging(listOf(
+ Notification.MessagingStyle.Message("", 1L, "sender").setData("image/png", oversized)))
+ assertTrue(extract(notification, failures = failures).isEmpty())
+ assertTrue(failures.last().message!!.contains("large"))
+ picture.recycle()
+ }
+}
diff --git a/app/src/test/java/life/andre/message487/OutboxTest.kt b/app/src/test/java/life/andre/message487/OutboxTest.kt
index 4ee6034..0c09c00 100644
--- a/app/src/test/java/life/andre/message487/OutboxTest.kt
+++ b/app/src/test/java/life/andre/message487/OutboxTest.kt
@@ -34,6 +34,123 @@ class OutboxTest {
@After fun close() { outbox.close(); context.deleteDatabase("outbox.db") }
+ @Test fun `image collection checksum and deduplication ignore order but preserve content roles and multiplicity`() {
+ val first = imageFixture("first-image")
+ val second = imageFixture("second-image")
+ val avatar = first.copy(kind = "avatar")
+ val images = listOf(first, second, avatar)
+ val original = event().copy(images = images)
+ assertEquals(notificationImagesChecksum(images), notificationImagesChecksum(images.reversed()))
+ assertTrue(outbox.enqueue(original, settings))
+ outbox.close()
+ outbox = Outbox(context, codec)
+ assertFalse(outbox.enqueue(original.copy(eventId = "reordered", images = images.reversed()), settings))
+ val variants = listOf(
+ listOf(first, imageFixture("changed"), avatar),
+ listOf(first, second, avatar.copy(kind = "app_icon")),
+ listOf(first.copy(presentation = "big_picture"), second, avatar),
+ listOf(first, avatar),
+ images + second,
+ emptyList(),
+ )
+ variants.forEachIndexed { index, variant ->
+ assertNotEquals(notificationImagesChecksum(images), notificationImagesChecksum(variant))
+ assertTrue(outbox.enqueue(original.copy(eventId = "changed-$index", images = variant), settings))
+ }
+ val request = JSONObject(requestJson(outbox.beginAttempt(original.eventId)!!.request)).getJSONArray("images")
+ assertEquals(images.map(::imageBase64), (0 until request.length()).map { request.getJSONObject(it).getString("data_base64") })
+ }
+
+ @Test fun `images survive encrypted queue restart and retries and participate in deduplication`() {
+ val photo = imageFixture("synthetic-image-base64").copy(presentation = "big_picture")
+ val first = event().copy(text = "", images = listOf(photo))
+ assertTrue(outbox.enqueue(first.copy(eventId = "text-only-before-image", images = emptyList()), settings))
+ val legacy = digest(org.json.JSONArray().put(first.source.packageName).put(first.occurredAt).put(first.text).toString())
+ outbox.writableDatabase.execSQL("INSERT INTO message_fingerprints (fingerprint) VALUES (?)", arrayOf(legacy))
+ assertTrue(outbox.enqueue(first, settings))
+ assertFalse(outbox.enqueue(first.copy(eventId = "same-image"), settings))
+ val changed = first.copy(eventId = "different-image", images = listOf(imageFixture("changed-image")))
+ assertTrue(outbox.enqueue(changed, settings))
+ outbox.close()
+ outbox = Outbox(context, codec)
+ val attempt = outbox.beginAttempt(first.eventId)!!
+ val image = JSONObject(requestJson(attempt.request)).getJSONArray("images").getJSONObject(0)
+ assertEquals(imageBase64(photo), image.getString("data_base64"))
+ assertEquals(photo.mimeType, image.getString("mime_type"))
+ assertEquals("big_picture", image.getString("presentation"))
+ outbox.finish(first.eventId, attempt.token, DeliveryResult(first.eventId, DeliveryStatus.TIMEOUT))
+ assertEquals(attempt.request, outbox.beginAttempt(first.eventId)!!.request)
+ val delivered = outbox.beginAttempt(changed.eventId)!!
+ outbox.finish(changed.eventId, delivered.token, DeliveryResult(changed.eventId, DeliveryStatus.ACCEPTED))
+ outbox.readableDatabase.rawQuery("SELECT payload FROM events WHERE id = ?", arrayOf(changed.eventId)).use {
+ assertTrue(it.moveToFirst()); assertTrue(it.isNull(0))
+ }
+ assertFalse(JSONObject(event().toJson()).has("images"))
+ }
+
+ @Test fun `large file images stay outside SQLite and stream through retries until acknowledged`() {
+ val photo = imageFixture("x".repeat(3 * 1024 * 1024))
+ val original = event().copy(images = listOf(photo))
+ assertTrue(outbox.enqueue(original, settings))
+ assertEquals(1, outbox.entries().size)
+ outbox.readableDatabase.rawQuery("SELECT payload,payload_file FROM events", null).use {
+ assertTrue(it.moveToFirst()); assertTrue(it.isNull(0)); assertFalse(it.isNull(1))
+ }
+ val first = outbox.beginAttempt(original.eventId)!!
+ val file = first.request.images.single().file
+ assertEquals(photo.file.readText(), file.readText())
+ assertFalse(first.request.json.contains("data_base64"))
+ var count = 0L
+ first.request.writeTo(object : java.io.OutputStream() {
+ override fun write(value: Int) { count++ }
+ override fun write(buffer: ByteArray, offset: Int, length: Int) { count += length }
+ })
+ assertTrue(count > 4 * 1024 * 1024)
+ outbox.finish(original.eventId, first.token, DeliveryResult(original.eventId, DeliveryStatus.TIMEOUT))
+ assertTrue(file.exists())
+ val retry = outbox.beginAttempt(original.eventId)!!
+ outbox.finish(original.eventId, first.token, DeliveryResult(original.eventId, DeliveryStatus.ACCEPTED))
+ assertTrue(file.exists())
+ outbox.finish(original.eventId, retry.token, DeliveryResult(original.eventId, DeliveryStatus.ACCEPTED))
+ assertFalse(file.exists())
+ }
+
+ @Test fun `legacy inline image payload migrates even above CursorWindow size`() {
+ val photo = imageFixture("y".repeat(3 * 1024 * 1024))
+ val original = event()
+ val body = JSONObject(original.toJson()).put("images", org.json.JSONArray().put(photo.toJson().put("data_base64", imageBase64(photo))))
+ val old = JSONObject().put("url", settings.url).put("require_ack", true).put("auth_token", settings.authToken).put("event", body)
+ outbox.writableDatabase.insertOrThrow("events", null, android.content.ContentValues().apply {
+ put("id", original.eventId); put("type", "notification"); put("source_name", "Chat")
+ put("created_at", 1L); put("state", "PENDING"); put("payload", codec.encrypt(old.toString()))
+ })
+ assertEquals(1, outbox.entries().size)
+ val request = outbox.beginAttempt(original.eventId)!!.request
+ assertEquals(photo.file.readText(), request.images.single().file.readText())
+ assertEquals(original.text, JSONObject(request.json).getString("text"))
+ outbox.readableDatabase.rawQuery("SELECT payload,payload_file FROM events", null).use {
+ assertTrue(it.moveToFirst()); assertTrue(it.isNull(0)); assertFalse(it.isNull(1))
+ }
+ }
+
+ @Test fun `cleanup preserves pending files and removes abandoned capture and snapshot directories`() {
+ val original = event().copy(images = listOf(imageFixture("keep")))
+ val interrupted = java.io.File(outbox.files, "event-${digest(original.eventId)}").apply {
+ mkdirs(); resolve("partial").writeText("Crash before DB commit")
+ }
+ assertTrue(outbox.enqueue(original, settings))
+ assertFalse(interrupted.resolve("partial").exists())
+ val pending = outbox.beginAttempt(original.eventId)!!.request.images.single().file
+ val old = System.currentTimeMillis() - 600_000
+ pending.parentFile!!.setLastModified(old)
+ val capture = java.io.File(outbox.files, "capture-abandoned").apply { mkdirs(); resolve("partial").writeText("partial"); setLastModified(old) }
+ val orphan = java.io.File(outbox.files, "event-abandoned").apply { mkdirs(); resolve("partial").writeText("partial"); setLastModified(old) }
+ outbox.cleanupFiles()
+ assertTrue(pending.exists())
+ assertFalse(capture.exists())
+ assertFalse(orphan.exists())
+ }
+
@Test fun `restart preserves payload destination and identity across retries`() {
val event = event()
assertTrue(outbox.enqueue(event, settings))
@@ -57,7 +174,7 @@ class OutboxTest {
outbox.enqueue(event, settings)
val envelope = JSONObject().put("url", settings.url).put("require_ack", true)
.put("event", JSONObject(event.toJson()))
- val values = android.content.ContentValues().apply { put("payload", codec.encrypt(envelope.toString())) }
+ val values = android.content.ContentValues().apply { put("payload", codec.encrypt(envelope.toString())); putNull("payload_file") }
outbox.writableDatabase.update("events", values, "id = ?", arrayOf(event.eventId))
assertThrows(org.json.JSONException::class.java) { outbox.beginAttempt(event.eventId) }
}
@@ -165,7 +282,7 @@ class OutboxTest {
outbox = Outbox(context, codec)
assertEquals(original.text, JSONObject(outbox.beginAttempt(original.eventId)!!.request.json).getString("text"))
assertTrue(outbox.enqueue(event(), settings))
- assertEquals(3, outbox.readableDatabase.version)
+ assertEquals(4, outbox.readableDatabase.version)
}
@Test fun `version two migration preserves queued payloads and old exact history`() {
@@ -185,7 +302,7 @@ class OutboxTest {
val fresh = original.copy(eventId = "fresh", occurredAt = "2026-09-09T10:01:00Z")
assertTrue(outbox.enqueue(fresh, settings))
assertFalse(outbox.enqueue(fresh.copy(eventId = "fresh-duplicate", occurredAt = "2026-09-09T10:01:00.500Z"), settings))
- assertEquals(3, outbox.readableDatabase.version)
+ assertEquals(4, outbox.readableDatabase.version)
}
@Test fun `invalid acknowledgement blocks automatic delivery until manual retry`() {
diff --git a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt
index bb72407..6843720 100644
--- a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt
+++ b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt
@@ -55,6 +55,7 @@ class ScreenInteractionTest {
@After fun close() {
application.graph.captureExecutor.shutdownNow()
+ application.graph.imageCaptureQueue.close()
application.diagnostics.close()
}
@@ -165,6 +166,86 @@ class ScreenInteractionTest {
assertTrue(application.diagnostics.readTail().contains(DiagnosticEvent.LOG_CLEARED.name))
}
+ @Test fun `connection test switches are independent from notification capture settings`() {
+ node(R.string.connection_nav).performClick()
+ val list = compose.onNode(hasScrollToIndexAction())
+ for (title in listOf(R.string.test_big_picture, R.string.test_gallery,
+ R.string.test_avatar, R.string.test_app_icon)) {
+ list.performScrollToNode(hasText(application.getString(title)))
+ node(title).assertIsOff().performClick().assertIsOn()
+ }
+ assertEquals(TestMessageOptions(true, true, true, true), model.state.value.testOptions)
+ assertFalse(application.graph.settings.state.value.forwardAvatars)
+ assertFalse(application.graph.settings.state.value.forwardAppIcons)
+ list.performScrollToNode(hasText(application.getString(R.string.test_avatar)))
+ node(R.string.test_avatar).performClick().assertIsOff()
+ assertFalse(model.state.value.testOptions.avatar)
+ assertTrue(model.state.value.testOptions.appIcon)
+ node(R.string.send_test).performScrollTo().assertExists()
+ }
+
+ @Test fun `image format and JPEG quality can be configured from sources`() {
+ compose.onNode(hasText(application.getString(R.string.sources_tab)) and hasClickAction()).performClick()
+ compose.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(application.getString(R.string.notification_images)))
+ compose.onNodeWithText("JPEG").assertIsSelected()
+ icon(R.string.image_quality_label).performScrollTo().performSemanticsAction(
+ androidx.compose.ui.semantics.SemanticsActions.SetProgress) { it(35f) }
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertEquals(35, SettingsStore(application).state.value.imageQuality)
+ compose.onNodeWithText("PNG").performScrollTo().performClick()
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertEquals(NotificationImageFormat.PNG, SettingsStore(application).state.value.imageFormat)
+ icon(R.string.image_quality_label).performScrollTo().assertIsNotEnabled()
+ compose.onNodeWithText("JPEG").performScrollTo().performClick()
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertEquals(35, SettingsStore(application).state.value.imageQuality)
+ icon(R.string.image_quality_label).performScrollTo().assertIsEnabled()
+ }
+
+ @Test fun `graphics limits can be changed and disabled without losing values`() {
+ compose.onNode(hasText(application.getString(R.string.sources_tab)) and hasClickAction()).performClick()
+ compose.onNode(hasScrollToNodeAction()).performScrollToNode(
+ hasContentDescription(application.getString(R.string.image_limits_enabled)))
+ icon(R.string.image_limits_enabled).performScrollTo().assertIsOn()
+ node(R.string.image_max_count).performScrollTo().performTextReplacement("7")
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertEquals(7, SettingsStore(application).state.value.imageMaxCount)
+ node(R.string.image_max_edge).performScrollTo().performTextReplacement("2048")
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertEquals(2048, SettingsStore(application).state.value.imageMaxEdge)
+ node(R.string.image_max_kib).performScrollTo().performTextReplacement("1024")
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertEquals(1024, SettingsStore(application).state.value.imageMaxKiB)
+ node(R.string.image_total_kib).performScrollTo().performTextReplacement("4096")
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertEquals(4096, SettingsStore(application).state.value.imageTotalKiB)
+ icon(R.string.image_limits_enabled).performScrollTo().performClick()
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertFalse(SettingsStore(application).state.value.imageLimitsEnabled)
+ node(R.string.image_max_count).assertDoesNotExist()
+ icon(R.string.image_limits_enabled).performClick()
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ node(R.string.image_max_count).performScrollTo()
+ compose.onNode(hasSetTextAction() and hasText("7")).assertExists()
+ }
+
+ @Test fun `avatar and app icon switches are independent and off by default`() {
+ compose.onNode(hasText(application.getString(R.string.sources_tab)) and hasClickAction()).performClick()
+ compose.onNode(hasScrollToNodeAction()).performScrollToNode(
+ hasContentDescription(application.getString(R.string.forward_avatars)))
+ icon(R.string.forward_avatars).performScrollTo().assertIsOff().performClick()
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertTrue(SettingsStore(application).state.value.forwardAvatars)
+ assertFalse(SettingsStore(application).state.value.forwardAppIcons)
+ icon(R.string.forward_app_icons).performScrollTo().assertIsOff().performClick()
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertTrue(SettingsStore(application).state.value.forwardAppIcons)
+ icon(R.string.forward_avatars).performScrollTo().assertIsOn().performClick()
+ compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
+ assertFalse(SettingsStore(application).state.value.forwardAvatars)
+ assertTrue(SettingsStore(application).state.value.forwardAppIcons)
+ }
+
@Test fun `auto select switch preserves existing choices and selects newly discovered launcher apps`() {
compose.waitUntil(10_000) { model.apps.value.size == 2 }
compose.onNode(hasText(application.getString(R.string.sources_tab)) and hasClickAction()).performClick()
diff --git a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt
index cf8df2e..330e8a7 100644
--- a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt
+++ b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt
@@ -18,6 +18,43 @@ class SettingsStoreTest {
override fun decrypt(value: ByteArray) = String(value).reversed()
}
+ @Test fun `image limits are validated and survive disabling and restart`() {
+ val context = RuntimeEnvironment.getApplication()
+ val store = SettingsStore(context, cipher)
+ assertTrue(store.state.value.imageLimitsEnabled)
+ store.update { it.copy(imageMaxCount = 7, imageMaxEdge = 2048, imageMaxKiB = 1024, imageTotalKiB = 4096,
+ imageLimitsEnabled = false) }
+ val restored = SettingsStore(context, cipher).state.value
+ assertFalse(restored.imageLimitsEnabled)
+ assertEquals(7, restored.imageMaxCount)
+ assertEquals(2048, restored.imageMaxEdge)
+ assertEquals(1024, restored.imageMaxKiB)
+ assertEquals(4096, restored.imageTotalKiB)
+ assertThrows(IllegalArgumentException::class.java) { store.update { it.copy(imageMaxCount = 0) } }
+ assertThrows(IllegalArgumentException::class.java) { store.update { it.copy(imageMaxEdge = -1) } }
+ assertThrows(IllegalArgumentException::class.java) { store.update { it.copy(imageMaxKiB = 0) } }
+ assertThrows(IllegalArgumentException::class.java) { store.update { it.copy(imageTotalKiB = -1) } }
+ }
+
+ @Test fun `image format defaults to JPEG and quality settings survive restart`() {
+ context.getSharedPreferences("connection", Context.MODE_PRIVATE).edit().clear().commit()
+ val store = SettingsStore(context, cipher)
+ assertEquals(NotificationImageFormat.JPEG, store.state.value.imageFormat)
+ assertEquals(80, store.state.value.imageQuality)
+ assertFalse(store.state.value.forwardAvatars)
+ assertFalse(store.state.value.forwardAppIcons)
+ store.update { it.copy(imageFormat = NotificationImageFormat.PNG, imageQuality = 35, forwardAvatars = true, forwardAppIcons = true) }
+ assertEquals(NotificationImageFormat.PNG, SettingsStore(context, cipher).state.value.imageFormat)
+ assertEquals(35, SettingsStore(context, cipher).state.value.imageQuality)
+ assertTrue(SettingsStore(context, cipher).state.value.forwardAvatars)
+ assertTrue(SettingsStore(context, cipher).state.value.forwardAppIcons)
+ store.update { it.copy(forwardAvatars = false) }
+ assertFalse(SettingsStore(context, cipher).state.value.forwardAvatars)
+ assertTrue(SettingsStore(context, cipher).state.value.forwardAppIcons)
+ assertThrows(IllegalArgumentException::class.java) { store.update { it.copy(imageQuality = -1) } }
+ assertThrows(IllegalArgumentException::class.java) { store.update { it.copy(imageQuality = 101) } }
+ }
+
@Test fun `new app selection is opt in persistent and respects manual exclusions`() {
context.getSharedPreferences("connection", Context.MODE_PRIVATE).edit().clear().commit()
val store = SettingsStore(context, cipher)
diff --git a/app/src/test/java/life/andre/message487/WebhookClientTest.kt b/app/src/test/java/life/andre/message487/WebhookClientTest.kt
index f86892b..eef6728 100644
--- a/app/src/test/java/life/andre/message487/WebhookClientTest.kt
+++ b/app/src/test/java/life/andre/message487/WebhookClientTest.kt
@@ -39,7 +39,8 @@ class WebhookClientTest {
@Test
fun `HTTP transport sends event and validates confirmation`() = withServer { server ->
- val event = MessageEvent(deviceId = "installation", deviceCode = "test-device", source = source)
+ val image = imageFixture("photo", width = 1, height = 1)
+ val event = MessageEvent(deviceId = "installation", deviceCode = "test-device", source = source, images = listOf(image))
server.enqueue(MockResponse().setBody("""{"status":"accepted","event_id":"${event.eventId}"}"""))
val result = WebhookClient().send(server.url("/receive").toString(), event, true, "test-token")
assertEquals(DeliveryStatus.ACCEPTED, result.status)
@@ -55,6 +56,7 @@ class WebhookClientTest {
assertEquals("example.source", body.getString("source"))
assertEquals("Source app", body.getString("source_name"))
assertEquals("test", body.getString("message_type"))
+ assertEquals(image.toJson().put("data_base64", imageBase64(image)).toString(), body.getJSONArray("images").getJSONObject(0).toString())
}
@Test
diff --git a/docs/en/n8n-cloud.md b/docs/en/n8n-cloud.md
index cb11f6f..17512f5 100644
--- a/docs/en/n8n-cloud.md
+++ b/docs/en/n8n-cloud.md
@@ -2,6 +2,8 @@
[English](../en/n8n-cloud.md) | [Русский](../ru/n8n-cloud.md)
+For image handling or Telegram, see the [five importable example levels](n8n-examples.md). This guide starts with level 1, without Telegram.
+
You need a browser, an n8n Cloud account and a phone with [Message487 installed](apk-installation.md).
Every step uses the interface: no server installation, terminal or coding is required.
It is easiest to open n8n on a computer and the app on your phone.
@@ -17,7 +19,7 @@ Cloud provides an HTTPS address. Check [n8n pricing](https://n8n.io/pricing/)
for current trial conditions, prices and execution limits.
In n8n, an automation is called a **workflow** and its individual steps are called
-**nodes**. We will import a ready-made workflow with two nodes.
+**nodes**. We will import a ready-made workflow with validation and an ACK response.
## 2. Import the ready-made workflow
@@ -26,14 +28,14 @@ In n8n, an automation is called a **workflow** and its individual steps are call
3. Paste this address and confirm the import:
```text
- https://raw.githubusercontent.com/andre487/AndroidMessage487/main/DevServer/workflows/receive.json
+ https://raw.githubusercontent.com/andre487/AndroidMessage487/main/examples/n8n/workflows/01-webhook-text.json
```
-4. You should see two connected nodes: **Webhook → Respond**. The first receives
- the message; the second confirms receipt to the phone.
+4. You should see **Webhook → Validate event → Valid event**, followed by **Respond**
+ for valid requests or **Reject** for invalid ones.
5. Name the workflow, for example `Message487 — my phone`.
-If URL import is unavailable, open the [workflow file](../../DevServer/workflows/receive.json)
+If URL import is unavailable, open the [workflow file](../../examples/n8n/workflows/01-webhook-text.json)
on GitHub, download it using **Download raw file**, then select
**⋯ → Import from File** in n8n. You do not need to edit the file contents.
[Official import guide](https://docs.n8n.io/build/manage-workflows/export-and-import).
@@ -92,7 +94,7 @@ Open the **Connection** tab at the bottom of Message487 and fill in:
| **Device code** | A recognizable phone name, such as `my-phone` |
| **n8n confirmation** | Leave enabled |
-Tap **Save and send test event**. Open **Journal**, then the new test event: success means
+In **Connection → Test message**, leave image switches off and tap **Save and send test event**. Open **Journal**, then the new test event: success means
**Accepted by webhook** and **HTTP 200**. This test does not yet require notification
access or SMS permission.
diff --git a/docs/en/n8n-examples.md b/docs/en/n8n-examples.md
new file mode 100644
index 0000000..8bd8200
--- /dev/null
+++ b/docs/en/n8n-examples.md
@@ -0,0 +1,195 @@
+# Importable n8n examples
+
+[English](../en/n8n-examples.md) | [Русский](../ru/n8n-examples.md)
+
+Start with [n8n Cloud](n8n-cloud.md) or [self-hosted n8n](n8n-self-hosted.md), then choose
+one level. The Android app sends the same webhook contract at every level; Telegram is optional.
+Each JSON contains its scripts and can be imported without copying JavaScript from another file.
+Examples use generic placeholders and synthetic data, not an exported personal installation.
+
+## Choose a level
+
+| Level | Import workflow | Result | Requirements |
+| --- | --- | --- | --- |
+| 1. Basic: text webhook | [01-webhook-text.json](../../examples/n8n/workflows/01-webhook-text.json) | Validate text and return ACK; no Telegram | Built-in nodes |
+| 2. Basic + images | [02-webhook-images.json](../../examples/n8n/workflows/02-webhook-images.json) | Receive text and JPEG/PNG as n8n binary files; no Telegram | Built-in nodes |
+| 3. Telegram: text | [03-telegram-text.json](../../examples/n8n/workflows/03-telegram-text.json) | Formatted text, split when long | Built-in Telegram node |
+| 4. Telegram: standard images | [04-telegram-images-standard.json](../../examples/n8n/workflows/04-telegram-images-standard.json) | Text plus individual photos/files | Built-in nodes only |
+| 5. Telegram: advanced | [05-telegram-images-advanced.json](../../examples/n8n/workflows/05-telegram-images-advanced.json) | Text and all images in one Rich Message; saved retry progress | Our node + Data Table; self-hosted installation |
+
+[05-telegram-cleanup.json](../../examples/n8n/workflows/05-telegram-cleanup.json) is an optional
+companion to level 5, not another receiving endpoint. It starts inactive and in **Dry Run**.
+Examples target n8n 2.38.x. Levels 1–4 use built-in nodes and do not require installing our package.
+The advanced example requires [Telegram Rich Messages](https://core.telegram.org/bots/api#sendrichmessage), including document blocks (10.3+).
+
+## Import and connect
+
+1. Download one JSON file using **Download raw file** on GitHub.
+2. In an empty n8n workflow, select **⋯ → Import from File**. All examples start inactive.
+3. Open **Webhook** and select/create your **Header Auth** credential:
+ **Name** `Authorization`, **Value** `Bearer `.
+ Generate a token with a password manager or `openssl rand -hex 32`.
+4. If another workflow uses the same path, change **Webhook → Path**. Do not import over an
+ existing production workflow. Each example has a separate `message487/01-...` path.
+5. For Telegram levels, complete the additional setup below.
+6. Publish, then copy **Webhook → Production URL**, not the editor address or Test URL.
+7. In Android **Connection**, set this URL, the same token **without `Bearer `**, a device
+ label, and **n8n confirmation**. Save the connection. The Overview test sends text only.
+
+The JSON has no credential IDs/names, chat IDs, server URLs, pinned executions, bot tokens,
+API keys or project/table IDs. Missing credentials after import are intentional.
+See the official [import guide](https://docs.n8n.io/workflows/export-import/).
+
+## Levels 1 and 2: webhook without Telegram
+
+Level 1 validates the event and returns `{status:"accepted", event_id:"..."}`. It intentionally
+ignores image content if present; use level 2 to process images. Level 2 validates image metadata,
+creates `image0`, `image1`, … in the **Read images → Binary** output, and returns the same ACK.
+The JSON output keeps text and image metadata without repeating Base64. Text-only requests work too.
+
+Neither example sends anything to Telegram, deduplicates server-side, or creates a durable inbox.
+Execution history is an inspection tool, not a downstream delivery queue. To process events,
+insert your own storage/action **before Respond**. For slow work, durably store first, ACK,
+then process separately. An ACK removes the phone's payload after confirmed delivery.
+
+## Levels 3 and 4: standard Telegram nodes
+
+Follow [bot and credential setup](n8n-telegram.md). In **Configure**, replace `REPLACE_WITH_CHAT_ID`
+with your fixed chat ID or `@channelusername`; do not take it from incoming requests.
+Select your Telegram API credential in **every Telegram node**, in addition to Webhook Header Auth.
+
+Level 3 sends only text. Level 4 adds images:
+
+| Image | Level 4 behavior |
+| --- | --- |
+| `big_picture` attachment | Individual Send Photo |
+| `gallery` attachments | Individual Send Photo for every image |
+| Avatar / app icon | Individual Send Document with a role caption |
+| Other/legacy attachment | Individual Send Document |
+
+Text and images are separate messages in level 4. The built-in media-group operation does not
+upload binary files as an album. Level 4 avoids external image hosting and custom packages;
+choose level 5 for collages and media in the same message as text.
+
+Both examples preserve all text by splitting at 3500 UTF-16 units without breaking surrogate
+pairs; HTML inputs are escaped. The embedded formatter defaults to **UTC / en-GB**, with
+English labels. Edit `timeZone`, `locale` and `messageTypes` in **Prepare Telegram text** if desired.
+Keep Telegram **Parse Mode → HTML** and **On Error → Stop Workflow**.
+These two levels have no server-side retry deduplication: an ACK loss can duplicate sends.
+
+## Level 5: Rich Messages and retry progress
+
+1. Install [N8nTelegramNode487 v0.2.1+](https://github.com/andre487/N8nTelegramNode487/releases)
+ on your self-hosted n8n. Its MIT license and installation instructions are in that repository.
+ The private installation exposes `CUSTOM.telegramAlbum487`; restart n8n after installation.
+ Install on every main/worker process that executes workflows. No bot-token environment variable
+ is needed: choose your existing Telegram API credential.
+2. Import level 5. Select the Telegram credential in **Send rich message** and **Send text**.
+3. Create a [Data Table](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.datatable/) in the **same n8n project** as the workflow, for example `message487_sent_parts`.
+ Add one **String** column named exactly `part_key`; `id`, `createdAt`, `updatedAt` are system columns.
+4. In **Configure**, replace both `REPLACE_WITH_CHAT_ID` and `REPLACE_WITH_SENT_PARTS_TABLE_ID`.
+ Use the actual ID from your table's URL/selector, not its display name. Use a separate table
+ for independently deployed forwarding workflows to avoid cross-workflow key collisions.
+5. Publish. Old nodes showing `?` mean the private extension is not installed/loaded yet.
+
+Media are embedded in the first text message:
+
+| Image | Level 5 behavior |
+| --- | --- |
+| Big Picture | Photo block |
+| MessagingStyle images | Collage; a single remaining image stays a photo |
+| Avatar and app icon | Separate icon collage within the same message, with role captions |
+| Other/legacy image | Document block within the same message |
+
+No separate media messages are sent. If text exceeds the formatter's chunk size, later text
+parts are separate messages without repeated media. Telegram clients choose preview sizes,
+positions and visible captions; sending a 100 px source does not force a 100 px preview.
+Photos may be processed by Telegram; document blocks preserve file delivery semantics.
+
+The workflow uses `event_id:part_number` as its progress key. **Check already sent** skips
+successful parts; **Remember part key** runs only after a successful send. **Respond** runs
+only after all parts finish, including an already-delivered retry. Lookup replaces item data,
+so **Restore pending part** restores text and binary files before delivery. Do not remove it.
+
+This is not exactly-once delivery: a crash between Telegram acceptance and storing the key,
+or concurrent requests for the same event, can duplicate a message. A table lookup/upsert is
+not an atomic delivery lock. Serialize processing or add transactional claims if needed.
+The phone's image-collection hash handles content deduplication independently: it includes
+encoded bytes, roles and presentation, ignores collection order, and is combined with source/text.
+The server keys delivery retries by event ID, not by image similarity.
+
+### Optional key cleanup
+
+Import the cleanup companion separately. In its **Configure**, set the **same table ID** as
+level 5. The daily schedule selects keys older than 30 days. Start with **Dry Run** enabled,
+inspect the selected rows, then disable Dry Run and publish if that retention matches your
+retry horizon. Deleting keys allows old events to be sent again. Pending phone events can
+survive longer than 30 days, so adjust the cutoff or leave cleanup disabled for your setup.
+It does not delete notification bodies or images; those are not stored in the part-key table.
+
+## Android image features and contract
+
+**Sources → Graphics** controls JPEG (default, quality 80) or PNG, JPEG quality 0–100,
+attachment count, longest side, per-image KiB and combined KiB. PNG preserves transparency;
+JPEG uses white for transparent areas. Default limits are 3 attachments, 1024 px, 256 KiB each,
+512 KiB combined, before Base64. Avatars and app icons have independent opt-ins, off by default;
+they do not consume attachment count but share the byte budget. Disabling output limits keeps
+format conversion and input/working-memory safeguards. Sources can opt in to discovering and
+selecting new applications automatically. See [capture behavior](../../README.md#delivery-and-data)
+and the Sources UI for details.
+
+Each `images` entry has `mime_type`, `width`, `height`, `data_base64`, `kind` (`attachment`,
+`avatar`, `app_icon`) and `presentation` (`big_picture`, `gallery`, `file`). Older images may
+omit presentation; examples treat them as files. Gallery is our grouping of image attachments
+from MessagingStyle in one notification, not an Android album ID. Big Picture and messaging
+images can coexist in the payload. Text/SMS-only events omit `images`; schema version remains 1.
+
+**Overview → Send text test** is always text-only. **Connection → Test message** offers
+independent Big Picture, two-image gallery, avatar and app-icon switches; all off means text.
+These selections do not enable collection opt-ins. Tests use synthetic cards and the app logo,
+respect image settings, and are deliberately exempt from phone content deduplication.
+
+## Verify with synthetic data
+
+Use [text.json](../../examples/n8n/payloads/text.json) for every level and
+[images.json](../../examples/n8n/payloads/images.json) for image-enabled levels. The PNGs are
+synthetic colored rectangles. Set your own Production URL and token, then run from the repo root:
+
+```sh
+export MESSAGE487_WEBHOOK_URL='https://n8n.example.org/webhook/message487/YOUR-PATH'
+# Set WEBHOOK_TOKEN locally to your private token, without the Bearer prefix.
+curl --fail-with-body --max-time 10 "$MESSAGE487_WEBHOOK_URL" \
+ -H "Authorization: Bearer ${WEBHOOK_TOKEN:?Set WEBHOOK_TOKEN}" \
+ -H 'Content-Type: application/json' \
+ --data-binary @examples/n8n/payloads/text.json
+```
+
+Change the filename to `images.json` for media. Use a **new event_id** for a new test;
+repeat the exact ID to test level 5 retry suppression. Invalid core fields return HTTP 400;
+missing/wrong authentication returns 401/403. Verify the exact ACK event ID in Android Journal.
+Use Connection test switches to exercise all five image roles/presentations on a phone/emulator.
+
+For level 5, repeat a delivered event: expect the same ACK and no new Telegram message.
+Test text-only, each image type, mixed types, PNG/JPEG and a long escaped Unicode message.
+Simulate a downstream failure: there must be no accepted ACK; a retry resumes unsent parts.
+Disable pause and select sources to test real capture. Avoid selecting Telegram itself on the
+phone that receives the bot messages, or it can create a forwarding loop.
+
+## Limits, privacy and maintenance
+
+The app has 10-second connect/read timeouts. Large multipart uploads or many sends can exceed
+that; for heavy workloads add a durable server inbox/worker instead of acknowledging early.
+Telegram applies photo/document, rate and Rich Message limits; preview layout is client-controlled.
+Reverse proxies must accept chunked JSON uploads and sufficient body sizes; Base64 adds about one third.
+Server errors, 408/425/429 and network failures retry; permanent errors/invalid ACK need manual action.
+Changing Connection settings does not redirect old queued events, which retain their original destination.
+
+Execution history can contain full text, Base64, binary files and authorization headers. Restrict
+access and configure retention. Phone image files are private without extra application encryption;
+text and token remain AES-GCM encrypted. Payload/image files are removed after confirmed delivery
+or manual deletion. These examples contain no secrets, but configured exports may include credential
+names/IDs and pinned data: remove them before sharing.
+
+The JSON is generated, not edited independently. After changing shared scripts run
+`python3 scripts/generate_n8n_examples.py`, then `--check`. CI verifies generated files,
+validation, binary payloads, routing, progress keys and the two webhook endpoints in DevServer.
diff --git a/docs/en/n8n-self-hosted.md b/docs/en/n8n-self-hosted.md
index 535e84e..fb92f08 100644
--- a/docs/en/n8n-self-hosted.md
+++ b/docs/en/n8n-self-hosted.md
@@ -2,6 +2,8 @@
[English](../en/n8n-self-hosted.md) | [Русский](../ru/n8n-self-hosted.md)
+For image handling or Telegram, see the [five importable example levels](n8n-examples.md). This guide starts with level 1, without Telegram.
+
Installing on a phone? See [APK installation and Android restrictions](apk-installation.md).
This guide targets Message487 0.0.2 and later, with Bearer authentication. You need the n8n editor and an HTTPS endpoint
@@ -26,16 +28,19 @@ publicly with its bundled test credentials.
## 1. Create a receiving workflow
-Import [receive.json](../../DevServer/workflows/receive.json) using **Import from File**
+Import [01-webhook-text.json](../../examples/n8n/workflows/01-webhook-text.json) using **Import from File**
in the editor menu. It validates basic fields and acknowledges the incoming `event_id`.
Import a separate workflow rather than replacing an existing production workflow.
```mermaid
flowchart LR
- W[Webhook: POST] --> R[Respond to Webhook: JSON ACK]
+ W[Webhook: POST] --> V[Validate event]
+ V --> I{Valid event}
+ I -->|Yes| R[Respond: JSON ACK]
+ I -->|No| E[Reject: HTTP 400]
```
-To configure the same two nodes manually, set **Webhook** as follows:
+For a minimal two-node receiver without validation, set **Webhook** as follows:
| Field | Value |
| --- | --- |
@@ -58,7 +63,7 @@ this **Expression** in **Response Body**:
```
`Webhook` is the trigger node's name; adjust the expression if you rename it.
-This minimal manual example does not validate requests. The imported `receive.json`
+This minimal manual example does not validate requests. The imported `01-webhook-text.json`
also validates `schema_version`, `event_id`, `message_type` and `text`, returning
HTTP 400 for invalid input.
diff --git a/docs/en/n8n-telegram.md b/docs/en/n8n-telegram.md
index 044f6d5..7311732 100644
--- a/docs/en/n8n-telegram.md
+++ b/docs/en/n8n-telegram.md
@@ -67,111 +67,38 @@ instead. Do not delete a working bot's webhook just to obtain an ID. This forwar
workflow does not need Telegram Trigger: its incoming trigger is Webhook.
See [getUpdates](https://core.telegram.org/bots/api#getupdates).
-## 3. Build the forwarding chain
+## 3. Import the forwarding level you need
-```mermaid
-flowchart LR
- W[Webhook: POST] --> C[Code: Prepare Telegram text]
- C --> T[Telegram: Send Message]
- T --> R[Respond to Webhook: ACK]
-```
-
-Keep **Webhook → Respond → Using 'Respond to Webhook' Node**. If you imported
-`receive.json`, remove the direct Webhook → Respond connection and insert Code
-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 the contents of:
-
-[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 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).
-
-Configure the **Telegram** node:
-
-| Field | Value |
-| --- | --- |
-| Credential | Your Telegram credential |
-| Resource | `Message` |
-| Operation | `Send Message` |
-| Chat ID | A fixed ID for your chat, group or channel |
-| Text, Expression mode | `{{ $json.telegram_text }}` |
-| Additional Fields → Parse Mode | `HTML` |
-| Append n8n Attribution | Off |
-| Disable WebPage Preview | On if previews are not wanted |
-
-Set Chat ID yourself rather than taking it from the incoming payload. The node
-processes each chunk returned by Code. Keep **On Error → Stop Workflow** so a
-Telegram error does not turn into a successful ACK. See the official
-[Telegram operation reference](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.telegram/message-operations/#send-message).
-
-Set **Respond to Webhook** to JSON and HTTP 200. In Response Body's Expression mode,
-reference the original request rather than the Telegram result:
-
-```javascript
-{{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }}
-```
-
-After Telegram, `$json` contains Telegram's response, so `$json.body.event_id` is
-incorrect. If you imported `receive.json`, replace **both Response Body and Response
-Code**: both original expressions read `$json.body`. Code already performs basic
-validation before sending in this chain. Publish the workflow again.
-
-## 4. Verify delivery
-
-1. Enable n8n confirmation in Message487 and send a test event.
-2. Confirm that Telegram receives a message with type `test`.
-3. Check the n8n execution, successful Telegram operation and client ACK.
-4. Enable notifications, grant access and select a source app.
-5. Create a new notification and match it to the execution using `event_id`.
-
-The chain also accepts SMS and tests. To forward only notifications, add an **If**
-node after Webhook: `{{ $json.body.message_type }}` equals `notification`. Route true
-to Code → Telegram → Respond and false to a separate Respond with the same ACK.
-Filtered events must still be acknowledged or they remain in the client's queue.
-
-Do not select Telegram as a notification source on the phone receiving your bot's
-messages: that can create a Telegram → Message487 → n8n → Telegram loop. Excluding
-Telegram from selected apps is the simplest way to prevent it.
-
-## Errors and retries
-
-| Error | Check |
+| Example | Import |
| --- | --- |
-| `chat not found` | Chat ID, private chat started, bot added to group/channel |
-| `bot was blocked` / HTTP 403 | Unblock the bot or restore its permissions |
-| `can't parse entities` | HTML mode and the supplied escaping; do not use Markdown for this template |
-| HTTP 429 | Telegram limits; reduce frequency and honor `retry_after` |
-| Message arrives but the client retries | ACK event ID and time until webhook response |
-
-If sending all chunks exceeds the client's timeout, persist the event to a durable
-queue first, acknowledge receipt and send to Telegram in a separate workflow.
-Do not add a long wait before ACK.
-
-This simple example does not deduplicate. If Telegram accepted a message but ACK
-was lost, retrying creates a duplicate; a failed chunk may cause earlier chunks to
-repeat too. For more resilient processing, persist `event_id` and per-part progress.
-Even that cannot guarantee exactly-once delivery if a crash occurs between Telegram's
-response and recording the result; account for this in your retry design.
+| Text only | [03-telegram-text.json](../../examples/n8n/workflows/03-telegram-text.json) |
+| Images, built-in Telegram nodes | [04-telegram-images-standard.json](../../examples/n8n/workflows/04-telegram-images-standard.json) |
+| Rich Messages with all media and retry progress | [05-telegram-images-advanced.json](../../examples/n8n/workflows/05-telegram-images-advanced.json) |
+
+See the [complete setup and verification guide](n8n-examples.md), including the two webhook-only
+levels, image contract, gallery/icon collages, optional cleanup and limitations.
+After import, select your Header Auth credential in Webhook and Telegram API credential in
+every Telegram node. Set your fixed `chat_id` in **Configure**. Advanced additionally needs
+our MIT-licensed node and a project Data Table with a String column `part_key`.
+The exports contain no personal credentials, chat IDs, table IDs or execution data.
+
+All examples validate events before sending and acknowledge only after successful delivery.
+Level 3 ignores images. Level 4 sends photos/files separately using built-in nodes.
+Level 5 attaches Big Picture, gallery and icons to the first text Rich Message.
+Long text still has subsequent text-only parts. Text and image tests are independent from
+capture settings in Android **Connection → Test message**; the Overview test stays text-only.
+The public formatter defaults to UTC and English labels; customize its locale/time zone if needed.
+
+## 4. Verify and handle retries
+
+Publish, enter the Production URL and token in Android, then send a text test. For image-enabled
+levels use the Connection test switches. Match `event_id` in Journal, n8n Executions and ACK.
+Test each image type and a mixed set. Repeat an identical ID to verify level 5 progress keys.
+Levels 3–4 do not deduplicate retries. Level 5 reduces repeats but cannot guarantee exactly-once
+across concurrent requests or a crash between sending and persisting a key.
+
+Keep **On Error → Stop Workflow**. HTTP 429 needs rate-limit handling; network and server
+errors can cause phone retries. Large uploads/many sends may exceed the phone's 10-second
+read timeout: use a durable server inbox/worker for heavy workloads instead of an early ACK.
+Do not select Telegram as a notification source on the receiving phone, to avoid a forwarding loop.
+Execution history may contain text, binary data and tokens in request headers: configure retention.
diff --git a/docs/en/n8n-webhook.md b/docs/en/n8n-webhook.md
index 808702c..c8c7654 100644
--- a/docs/en/n8n-webhook.md
+++ b/docs/en/n8n-webhook.md
@@ -2,6 +2,8 @@
[English](../en/n8n-webhook.md) | [Русский](../ru/n8n-webhook.md)
+Choose an [importable example by level](n8n-examples.md): text webhook, image webhook, Telegram text, standard Telegram images, or advanced Rich Messages.
+
Choose a guide based on where your n8n runs:
| Your setup | Guide |
diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md
index f30e08d..14e3e56 100644
--- a/docs/en/release-testing.md
+++ b/docs/en/release-testing.md
@@ -166,8 +166,8 @@ a reason; emulator SMS does not establish carrier delivery.
| Q03 | Wrong/missing ACK ID/status, malformed/oversized JSON and generic mode without ACK. Invalid ACK with 2xx blocks for manual retry; generic 2xx yields HTTP_SUCCESS. A successful n8n error execution is not accepted delivery. |
| Q04 | Network/timeout and HTTP 408/425/429/5xx retry; other HTTP failures need correction/manual retry. Preserve event ID/body/original URL/token/ACK after recovery. Lost response may duplicate server receipt with the same ID. No exact WorkManager retry-time promise. |
| Q05 | Queue offline events for endpoint A, then switch settings to B. Old events retain A and its credentials; new events use B. UI token rotation does not repair old encrypted envelopes. Use synthetic endpoints; explicitly delete unwanted old events instead of silently rerouting. |
-| Q06 | Retry now, delete test records, repeated actions and details during state change. No duplicate local event/lost queue; in-flight entries are not freely deletable. Acceptance removes payload from active records while bounded metadata remains. Logical deletion is not forensic erasure. |
-| Q07 | Offline enqueue, process death/rotation/relaunch, network restoration; preserve encrypted payload and delivery, including previous-version queue. Keystore/storage failures on isolated fixtures: no garbage transmission or silent deletion, visible error. Robolectric success does not prove real Keystore. |
+| Q06 | Retry now, delete test records, repeated actions and details during state change. No duplicate local event/lost queue; in-flight entries are not freely deletable. Acceptance removes encrypted request and private image files while bounded metadata remains. Logical deletion is not forensic erasure. |
+| Q07 | Offline enqueue, process death/rotation/relaunch, network restoration; preserve the encrypted request, private image files and delivery, including previous-version queue migration. Keystore/storage failures on isolated fixtures: no garbage transmission or silent deletion, visible error. Robolectric success does not prove real Keystore. |
### Lifecycle and background
diff --git a/docs/ru/n8n-cloud.md b/docs/ru/n8n-cloud.md
index 697761d..8541d73 100644
--- a/docs/ru/n8n-cloud.md
+++ b/docs/ru/n8n-cloud.md
@@ -2,6 +2,8 @@
[English](../en/n8n-cloud.md) | [Русский](../ru/n8n-cloud.md)
+Для изображений и Telegram см. [пять уровней импортируемых примеров](n8n-examples.md). Эта инструкция начинается с уровня 1, без Telegram.
+
Понадобятся браузер, аккаунт n8n Cloud и телефон с [установленным Message487](apk-installation.md).
Все шаги выполняются через интерфейс: устанавливать сервер, открывать консоль или
писать код не нужно. Удобнее открыть n8n на компьютере, а приложение — на телефоне.
@@ -17,7 +19,7 @@ Telegram можно добавить после проверки подключ
лимиты запусков смотрите в [тарифах n8n](https://n8n.io/pricing/).
В n8n сценарий обработки называется **workflow**, а отдельные действия внутри него —
-**узлы**. Мы импортируем готовый сценарий с двумя узлами.
+**узлы**. Мы импортируем готовый сценарий с проверкой запроса и ответом ACK.
## 2. Импортируйте готовый сценарий
@@ -26,14 +28,14 @@ Telegram можно добавить после проверки подключ
3. Вставьте следующий адрес и подтвердите импорт:
```text
- https://raw.githubusercontent.com/andre487/AndroidMessage487/main/DevServer/workflows/receive.json
+ https://raw.githubusercontent.com/andre487/AndroidMessage487/main/examples/n8n/workflows/01-webhook-text.json
```
-4. На схеме должны появиться соединённые узлы **Webhook → Respond**.
- Первый принимает сообщение, второй сообщает телефону об успешном приёме.
+4. На схеме появятся **Webhook → Validate event → Valid event**, затем **Respond**
+ для корректных запросов или **Reject** для некорректных.
5. Назовите сценарий, например `Message487 — мой телефон`.
-Если импорт по URL недоступен, откройте [файл сценария](../../DevServer/workflows/receive.json)
+Если импорт по URL недоступен, откройте [файл сценария](../../examples/n8n/workflows/01-webhook-text.json)
на GitHub, скачайте его кнопкой **Download raw file**, затем выберите
**⋯ → Import from File** в n8n. Редактировать содержимое файла не нужно.
[Справка n8n по импорту](https://docs.n8n.io/build/manage-workflows/export-and-import).
diff --git a/docs/ru/n8n-examples.md b/docs/ru/n8n-examples.md
new file mode 100644
index 0000000..ee8dbca
--- /dev/null
+++ b/docs/ru/n8n-examples.md
@@ -0,0 +1,198 @@
+# Импортируемые примеры n8n
+
+[English](../en/n8n-examples.md) | [Русский](../ru/n8n-examples.md)
+
+Сначала настройте [n8n Cloud](n8n-cloud.md) или [свой сервер](n8n-self-hosted.md), затем
+выберите уровень. Android во всех случаях отправляет один и тот же webhook-контракт;
+Telegram необязателен. Каждый JSON содержит весь нужный JavaScript и готов к импорту.
+Примеры созданы с нуля: в них заглушки и синтетические данные, а не экспорт личной инсталляции.
+
+## Выберите уровень
+
+| Уровень | Workflow для импорта | Результат | Зависимости |
+| --- | --- | --- | --- |
+| 1. Базовый: текстовый webhook | [01-webhook-text.json](../../examples/n8n/workflows/01-webhook-text.json) | Проверка события и ACK, без Telegram | Стандартные nodes |
+| 2. Базовый + картинки | [02-webhook-images.json](../../examples/n8n/workflows/02-webhook-images.json) | Текст и JPEG/PNG как binary-файлы n8n, без Telegram | Стандартные nodes |
+| 3. Telegram: базовый | [03-telegram-text.json](../../examples/n8n/workflows/03-telegram-text.json) | Форматированный текст, разбиение длинных сообщений | Стандартный Telegram node |
+| 4. Telegram: картинки на стандартных nodes | [04-telegram-images-standard.json](../../examples/n8n/workflows/04-telegram-images-standard.json) | Текст и отдельные фото/файлы | Только стандартные nodes |
+| 5. Telegram: расширенный | [05-telegram-images-advanced.json](../../examples/n8n/workflows/05-telegram-images-advanced.json) | Текст и все картинки в одном Rich Message, сохранение прогресса отправки | Наш node + Data Table, self-hosted n8n |
+
+[05-telegram-cleanup.json](../../examples/n8n/workflows/05-telegram-cleanup.json) — дополнительный
+workflow очистки для уровня 5, а не ещё один приёмник. Он импортируется выключенным и с **Dry Run**.
+Примеры рассчитаны на n8n 2.38.x. Уровни 1–4 не требуют установки нашего пакета.
+Для расширенного варианта нужен Telegram Bot API с [Rich Messages](https://core.telegram.org/bots/api#sendrichmessage) и файловыми блоками, версия 10.3+.
+
+## Импорт и подключение
+
+1. Скачайте выбранный JSON через **Download raw file** на GitHub.
+2. В пустом workflow n8n выберите **⋯ → Import from File**. Все примеры изначально неактивны.
+3. В **Webhook** создайте или выберите свой **Header Auth** credential:
+ **Name** = `Authorization`, **Value** = `Bearer <ваш-приватный-токен>`.
+ Токен можно сгенерировать менеджером паролей или `openssl rand -hex 32`.
+4. Если такой **Path** уже занят, измените его. У каждого примера свой путь `message487/01-...`.
+ Не заменяйте существующий рабочий workflow импортом примера.
+5. Для Telegram выполните дополнительную настройку ниже.
+6. Опубликуйте workflow и скопируйте **Webhook → Production URL**, а не адрес редактора или Test URL.
+7. В Android **Connection** укажите URL, тот же токен **без `Bearer `**, метку устройства и
+ включённое **n8n confirmation**. Сохраните подключение. Тест на главном экране отправляет только текст.
+
+В JSON нет IDs/названий credentials, chat ID, адреса сервера, pinned executions, токенов бота,
+API-ключей, IDs проекта и таблицы. Предупреждения о ненастроенных credentials после импорта ожидаемы.
+[Официальная инструкция импорта](https://docs.n8n.io/workflows/export-import/).
+
+## Уровни 1 и 2: webhook без Telegram
+
+Уровень 1 проверяет событие и возвращает `{status:"accepted", event_id:"..."}`. Если пришли
+картинки, их содержимое намеренно не обрабатывается; для изображений используйте уровень 2.
+Уровень 2 проверяет изображения и создаёт `image0`, `image1`, … в **Read images → Binary**.
+В JSON-выходе остаются текст и метаданные без повторного Base64. Текст без изображений тоже принимается.
+ACK в обоих случаях одинаковый.
+
+Эти workflows ничего не отправляют в Telegram, не дедуплицируют события на сервере и не создают
+надёжный входящий буфер. История executions нужна для просмотра, это не очередь доставки.
+Для обработки вставьте своё действие/хранилище **перед Respond**. Долгую обработку лучше вынести
+отдельно: сначала надёжно сохраните событие, затем ACK и фоновая обработка.
+После подтверждённого ACK телефон удаляет payload из своей очереди.
+
+## Уровни 3 и 4: стандартный Telegram node
+
+Выполните [настройку бота и credential](n8n-telegram.md). В **Configure** замените
+`REPLACE_WITH_CHAT_ID` на свой фиксированный chat ID или `@channelusername`.
+Не берите получателя из входящего запроса. Выберите Telegram API credential **в каждом Telegram node**,
+а Header Auth — в Webhook.
+
+Уровень 3 отправляет только текст. Уровень 4 добавляет картинки:
+
+| Изображение | Поведение уровня 4 |
+| --- | --- |
+| Вложение `big_picture` | Отдельное Send Photo |
+| Вложения `gallery` | Отдельное Send Photo для каждой картинки |
+| Аватар / значок приложения | Отдельное Send Document с подписью роли |
+| Прочее или старое вложение | Отдельное Send Document |
+
+Текст и изображения приходят отдельными сообщениями. Встроенная операция media group не
+загружает binary-файлы альбомом. Уровню 4 не нужны внешнее хранилище картинок и свои пакеты;
+для коллажей и изображений в том же сообщении с текстом используйте уровень 5.
+
+Длинный текст делится по 3500 UTF-16 единиц без разрыва суррогатных пар emoji. Входные `<`, `>` и `&`
+экранируются. Встроенный formatter по умолчанию использует **UTC / en-GB** и английские подписи.
+При необходимости измените `timeZone`, `locale`, `messageTypes` в **Prepare Telegram text**.
+Оставьте **Parse Mode → HTML** и **On Error → Stop Workflow**.
+Серверной дедупликации повторов в уровнях 3–4 нет: потеря ACK может привести к повторной отправке.
+
+## Уровень 5: Rich Messages и прогресс повторов
+
+1. Установите [N8nTelegramNode487 v0.2.1+](https://github.com/andre487/N8nTelegramNode487/releases)
+ на self-hosted n8n. Лицензия MIT и инструкция установки находятся в отдельном репозитории.
+ Приватная установка даёт тип `CUSTOM.telegramAlbum487`; после неё перезапустите n8n.
+ Пакет нужен на всех main/worker процессах, исполняющих workflow. Отдельная переменная окружения
+ с токеном не нужна: используется существующий Telegram API credential.
+2. Импортируйте уровень 5. Выберите Telegram credential в **Send rich message** и **Send text**.
+3. В **том же проекте n8n** создайте [Data Table](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.datatable/), например `message487_sent_parts`.
+ Добавьте одну колонку **String** с точным именем `part_key`. `id`, `createdAt`, `updatedAt` — системные поля.
+4. В **Configure** замените `REPLACE_WITH_CHAT_ID` и `REPLACE_WITH_SENT_PARTS_TABLE_ID`.
+ Нужен ID таблицы из URL/selector, а не её название. Для независимо развёрнутых workflows
+ используйте отдельные таблицы, чтобы их ключи не пересекались.
+5. Опубликуйте workflow. Знак `?` у node означает, что расширение ещё не установлено/не загрузилось.
+
+Все изображения прикрепляются к первому сообщению с текстом:
+
+| Изображение | Поведение уровня 5 |
+| --- | --- |
+| Big Picture | Блок фото |
+| Изображения MessagingStyle | Коллаж; одна оставшаяся картинка — обычное фото |
+| Аватар и значок приложения | Свой коллаж внутри того же сообщения, с подписями ролей |
+| Прочие или старые изображения | Файловые блоки внутри того же сообщения |
+
+Отдельных медиа-сообщений нет. При длинном тексте следующие текстовые части отправляются отдельно,
+без повторения картинок. Telegram-клиент сам выбирает размер, расположение и видимость подписей.
+Отправка исходника 100 px не заставляет клиент показывать превью 100 px.
+Фото Telegram может обрабатывать; файловые блоки сохраняют семантику отправки документа.
+
+Прогресс хранится по ключу `event_id:номер_части`. **Check already sent** пропускает уже отправленные
+части, **Remember part key** выполняется только после успешной отправки. **Respond** срабатывает
+после всех частей, в том числе когда повтор уже доставленного события полностью пропущен.
+Поиск в таблице заменяет текущие данные: **Restore pending part** восстанавливает текст и binary
+из итерации цикла перед отправкой. Не удаляйте этот node.
+
+Это не гарантия exactly-once: креш между ответом Telegram и записью ключа или параллельные запросы
+одного события всё ещё могут дать повтор. Поиск и upsert в таблице не являются атомарной блокировкой
+доставки. Для более строгих гарантий сериализуйте обработку или используйте транзакционные claims.
+На телефоне отдельно работает дедупликация содержимого: fingerprint включает источник, текст и
+хеш коллекции закодированных изображений с их ролями и presentation, независимо от порядка.
+Сервер различает повторы доставки по event ID, а не по сходству картинок.
+
+### Дополнительная очистка ключей
+
+Импортируйте companion workflow отдельно и укажите в **Configure** **тот же ID таблицы**, что в
+уровне 5. Ежедневное расписание выбирает ключи старше 30 дней. Сначала оставьте **Dry Run**, посмотрите
+выбранные строки, затем выключите Dry Run и опубликуйте, если такой срок подходит.
+После удаления ключа старое событие может быть отправлено повторно. Недоставленные события на
+телефоне могут храниться дольше 30 дней: увеличьте срок или оставьте очистку выключенной.
+Очистка не удаляет тексты и картинки — в таблице ключей их нет.
+
+## Возможности Android и контракт изображений
+
+В **Sources → Graphics** выбираются JPEG (по умолчанию, качество 80) или PNG, качество JPEG 0–100,
+число вложений, максимальная сторона, KiB на картинку и общий объём. PNG сохраняет прозрачность,
+JPEG заменяет её белым. Лимиты по умолчанию: 3 вложения, 1024 px, 256 KiB на файл, 512 KiB суммарно
+до Base64. Аватары и значки включаются независимо, по умолчанию выключены; число вложений они не
+расходуют, но общий байтовый бюджет делят со всеми. Отключение лимитов не отменяет конвертацию формата
+и защиту памяти/чтения входных данных. Для Sources также есть opt-in автоматического выбора новых
+обнаруженных приложений. Подробнее — [основная документация](../../README.md#delivery-and-data) и экран Sources.
+
+У элемента `images` есть `mime_type`, `width`, `height`, `data_base64`, `kind` (`attachment`,
+`avatar`, `app_icon`) и `presentation` (`big_picture`, `gallery`, `file`). У старых изображений
+presentation может отсутствовать: примеры считают их файлами. Галерея — наше объединение вложений
+MessagingStyle одного уведомления, а не Android album ID. Big Picture и messaging-изображения могут
+быть в одном payload. Текст/SMS без картинок не содержат `images`; версия схемы остаётся 1.
+
+**Overview → Send text test** всегда отправляет только текст. В **Connection → Test message**
+независимо включаются Big Picture, галерея из двух изображений, аватар и значок. Всё выключено — только
+текст. Это не меняет opt-in сбора уведомлений. Используются синтетические карточки и логотип приложения,
+с учётом настроек графики. Пользовательские тесты намеренно не подавляются дедупликацией содержимого телефона.
+
+## Проверка синтетическими данными
+
+[text.json](../../examples/n8n/payloads/text.json) подходит всем уровням,
+[images.json](../../examples/n8n/payloads/images.json) — уровням с картинками. PNG — синтетические цветные
+прямоугольники. Из корня репозитория выполните запрос на **свой** Production URL:
+
+```sh
+export MESSAGE487_WEBHOOK_URL='https://n8n.example.org/webhook/message487/YOUR-PATH'
+# Задайте WEBHOOK_TOKEN локально: ваш токен без префикса Bearer.
+curl --fail-with-body --max-time 10 "$MESSAGE487_WEBHOOK_URL" \
+ -H "Authorization: Bearer ${WEBHOOK_TOKEN:?Set WEBHOOK_TOKEN}" \
+ -H 'Content-Type: application/json' \
+ --data-binary @examples/n8n/payloads/text.json
+```
+
+Для изображений замените имя файла на `images.json`. Для **нового** теста задавайте новый `event_id`;
+для проверки подавления повторов уровня 5 повторяйте исходный ID. Невалидные основные поля дают HTTP 400,
+неверная/отсутствующая авторизация — 401/403. Сверьте точный event ID подтверждения в Journal.
+Переключателями Connection можно проверить все варианты картинок с телефона/эмулятора.
+
+Для уровня 5 повторите уже доставленное событие: ACK должен остаться успешным, новых сообщений Telegram
+быть не должно. Проверьте текст, каждый тип изображения, смешанный набор, PNG/JPEG и длинный Unicode
+с HTML-символами. При ошибке получателя не должно быть accepted ACK; повтор продолжает недоставленные части.
+Для реального сбора снимите паузу и выберите источники. Не выбирайте Telegram на телефоне, получающем
+сообщения этого бота: иначе возможна петля пересылки.
+
+## Ограничения, приватность и сопровождение
+
+У приложения connect/read timeout по 10 секунд. Большая загрузка или много отправок могут не уложиться;
+для тяжёлых сценариев добавьте надёжную входящую очередь и фоновый обработчик, а не ранний ACK.
+Telegram ограничивает фото/документы, частоту отправки и Rich Messages, а превью определяет клиент.
+Reverse proxy должен принимать chunked JSON и достаточный объём тела; Base64 добавляет примерно треть.
+Сетевые ошибки, 408/425/429 и 5xx повторяются; постоянные ошибки/невалидный ACK требуют ручного действия.
+Изменение Connection не перенаправляет старую очередь: у событий сохраняется исходный получатель.
+
+В executions могут остаться полный текст, Base64, binary и заголовки авторизации. Ограничьте доступ
+и настройте retention. На телефоне картинки лежат в приватных файлах без дополнительного шифрования,
+текст и токен шифруются AES-GCM. Payload и картинки удаляются после подтверждённой доставки или ручного
+удаления. В самих примерах секретов нет, но настроенный экспорт может содержать имена/IDs credentials
+и pinned data: удаляйте их перед публикацией.
+
+JSON генерируется из общих скриптов. После изменений выполните
+`python3 scripts/generate_n8n_examples.py`, затем `--check`. CI проверяет синхронность файлов,
+валидацию, binary, маршрутизацию, ключи прогресса и оба webhook-примера в DevServer.
diff --git a/docs/ru/n8n-self-hosted.md b/docs/ru/n8n-self-hosted.md
index ac619af..a15f8ea 100644
--- a/docs/ru/n8n-self-hosted.md
+++ b/docs/ru/n8n-self-hosted.md
@@ -2,6 +2,8 @@
[English](../en/n8n-self-hosted.md) | [Русский](../ru/n8n-self-hosted.md)
+Для изображений и Telegram см. [пять уровней импортируемых примеров](n8n-examples.md). Эта инструкция начинается с уровня 1, без Telegram.
+
Для установки на телефон см. [инструкцию по APK и ограничениям Android](apk-installation.md).
Инструкция для Message487 0.0.2 и новее, с Bearer-авторизацией. Нужен доступ к редактору n8n и HTTPS-адрес,
@@ -26,19 +28,22 @@
## 1. Создайте принимающий workflow
-Быстрее всего импортировать [receive.json](../../DevServer/workflows/receive.json)
+Быстрее всего импортировать [01-webhook-text.json](../../examples/n8n/workflows/01-webhook-text.json)
через **Import from File** в меню редактора n8n. Это готовый пример приёмника с
проверкой базовых полей и подтверждением `event_id`. Импортируйте его как отдельный
workflow; не заменяйте им рабочий сценарий с вашими действиями.
-В импортированном workflow уже соединены два узла:
+Импортированный workflow проверяет запрос перед подтверждением:
```mermaid
flowchart LR
- W[Webhook: POST] --> R[Respond to Webhook: JSON ACK]
+ W[Webhook: POST] --> V[Validate event]
+ V --> I{Valid event}
+ I -->|Yes| R[Respond: JSON ACK]
+ I -->|No| E[Reject: HTTP 400]
```
-Для ручной настройки создайте те же узлы. В узле **Webhook** установите:
+Минимальный ручной вариант без валидации состоит только из Webhook и Respond. В узле **Webhook** установите:
| Поле | Значение |
| --- | --- |
@@ -62,7 +67,7 @@ credential DevServer нельзя использовать на рабочем
Здесь `Webhook` — имя первого узла; при переименовании исправьте ссылку в выражении.
Этот минимальный ручной пример подтверждает запрос без валидации. Импортированный
-`receive.json` дополнительно проверяет `schema_version`, `event_id`, `message_type`
+`01-webhook-text.json` дополнительно проверяет `schema_version`, `event_id`, `message_type`
и `text` и возвращает HTTP 400 для некорректного тела.
Клиент ждёт JSON-объект следующего вида, а не массив, строку с JSON или HTML:
diff --git a/docs/ru/n8n-telegram.md b/docs/ru/n8n-telegram.md
index 77c4392..c57bfc3 100644
--- a/docs/ru/n8n-telegram.md
+++ b/docs/ru/n8n-telegram.md
@@ -68,115 +68,38 @@ if not chats:
Для отправки из Message487 **Telegram Trigger не нужен**: входной триггер — Webhook.
Официальная справка: [getUpdates](https://core.telegram.org/bots/api#getupdates).
-## 3. Соберите цепочку отправки
+## 3. Импортируйте нужный уровень пересылки
-```mermaid
-flowchart LR
- W[Webhook: POST] --> C[Code: Prepare Telegram text]
- C --> T[Telegram: Send Message]
- T --> R[Respond to Webhook: ACK]
-```
-
-В Webhook оставьте **Respond → Using 'Respond to Webhook' Node**. Если вы
-импортировали `receive.json`, удалите прямое соединение Webhook → Respond и
-вставьте Code и Telegram перед Respond. Не оставляйте параллельный путь, который
-подтвердит событие раньше отправки.
-
-Добавьте узел **Code**, имя `Prepare Telegram text`, язык **JavaScript**, режим
-**Run Once for All Items**, и вставьте
-содержимое файла ниже:
-
-[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. В каждой части теги жирного текста закрыты.
-Запас по длине оставлен под номер части. Telegram допускает до 4096 символов
-после разбора entities; см. [sendMessage](https://core.telegram.org/bots/api#sendmessage).
-
-Настройте узел **Telegram**:
-
-| Поле | Значение |
-| --- | --- |
-| Credential | Созданный Telegram credential |
-| Resource | `Message` |
-| Operation | `Send Message` |
-| Chat ID | Постоянный ID вашего чата, группы или канала |
-| Text, режим Expression | `{{ $json.telegram_text }}` |
-| Additional Fields → Parse Mode | `HTML` |
-| Append n8n Attribution | Выключить |
-| Disable WebPage Preview | Включить, если не нужны превью ссылок |
-
-Chat ID задайте сами, не берите его из входящего запроса. Узел обработает каждую
-часть, которую вернул Code. Оставьте **On Error → Stop Workflow**, чтобы ошибка
-Telegram не превратилась в успешное подтверждение. Настройки операции описаны
-в [официальной документации узла Telegram](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.telegram/message-operations/#send-message).
-
-В **Respond to Webhook** выберите JSON и HTTP 200. В режиме Expression для
-Response Body используйте исходный запрос, а не результат узла Telegram:
-
-```javascript
-{{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }}
-```
-
-После узла Telegram `$json` содержит ответ Telegram, поэтому `$json.body.event_id`
-здесь не подходит. Если импортировали `receive.json`, замените **и Response Body,
-и Response Code**: старые выражения в обоих полях обращаются к `$json.body`.
-Базовая валидация в этой цепочке уже выполнена в Code до отправки.
-
-Опубликуйте workflow повторно.
-
-## 4. Проверьте доставку
-
-1. В Message487 включите подтверждение n8n и отправьте тестовое событие.
-2. Убедитесь, что в Telegram пришло сообщение с типом `test`.
-3. В n8n проверьте execution и успешное выполнение Telegram, а в приложении — ACK.
-4. Включите уведомления, выдайте доступ и выберите нужное приложение-источник.
-5. Создайте новое уведомление и сопоставьте его с execution по `event_id`.
-
-Эта цепочка принимает также SMS и тесты. Если нужны только уведомления, добавьте
-после Webhook узел **If** с условием `{{ $json.body.message_type }}` равным
-`notification`. Ветку true направьте в Code → Telegram → Respond, а false —
-в отдельный Respond с тем же ACK: отфильтрованное событие должно быть подтверждено,
-иначе оно останется в очереди клиента.
-
-Не выбирайте Telegram источником уведомлений в Message487, когда этот же телефон
-получает сообщения вашего бота: это может создать цикл «Telegram → Message487 →
-n8n → Telegram». Проще исключить Telegram целиком из выбранных приложений.
-
-## Ошибки и повторы
-
-| Ошибка | Что проверить |
+| Пример | Импорт |
| --- | --- |
-| `chat not found` | Chat ID; начат ли личный диалог; добавлен ли бот в группу/канал |
-| `bot was blocked` / HTTP 403 | Разблокируйте бота или восстановите его права |
-| `can't parse entities` | Parse Mode HTML и приведённое экранирование; не используйте Markdown для этого шаблона |
-| HTTP 429 | Ограничение Telegram; уменьшите частоту, учитывайте `retry_after` |
-| Сообщение пришло, но клиент повторяет запрос | Правильный `event_id` в ACK и время ответа webhook |
-
-Если отправка всех частей не успевает до таймаута клиента, сначала сохраняйте
-событие в надёжную очередь, подтверждайте приём и выполняйте Telegram-отправку
-отдельным workflow. Не добавляйте длинное ожидание перед ACK.
-
-В простом примере нет дедупликации. Если Telegram принял сообщение, а ACK потерялся,
-повтор создаст дубль; при ошибке одной из частей могут повториться и предыдущие.
-Для устойчивой обработки храните `event_id` и прогресс отправки частей в БД.
-Даже это не гарантирует ровно одну отправку при сбое между ответом Telegram и
-фиксацией результата — учитывайте этот случай в своей схеме повторов.
+| Только текст | [03-telegram-text.json](../../examples/n8n/workflows/03-telegram-text.json) |
+| Картинки на стандартных Telegram nodes | [04-telegram-images-standard.json](../../examples/n8n/workflows/04-telegram-images-standard.json) |
+| Rich Messages со всеми вложениями и прогрессом повторов | [05-telegram-images-advanced.json](../../examples/n8n/workflows/05-telegram-images-advanced.json) |
+
+В [полной инструкции по примерам](n8n-examples.md) описаны все пять уровней, включая два webhook
+без Telegram, контракт картинок, коллажи, дополнительную очистку ключей и ограничения.
+После импорта выберите Header Auth в Webhook и Telegram API credential в каждом Telegram node.
+В **Configure** задайте свой фиксированный `chat_id`. Расширенному варианту также нужны наш
+MIT-пакет и Data Table в том же проекте с колонкой String `part_key`.
+В экспортах нет личных credentials, chat ID, IDs таблиц и данных executions.
+
+Все примеры проверяют запрос перед отправкой и подтверждают только успешную доставку.
+Уровень 3 игнорирует картинки, уровень 4 отправляет фото/файлы отдельно стандартными nodes,
+уровень 5 прикрепляет Big Picture, галерею и значки к первому Rich Message с текстом.
+У длинного текста остаются следующие текстовые части. В **Connection → Test message** можно
+выбирать тестовые изображения независимо от настроек сбора, а Overview всегда проверяет только текст.
+Публичный formatter использует UTC и английские подписи; при необходимости измените locale/timeZone.
+
+## 4. Проверьте доставку и повторы
+
+Опубликуйте workflow, укажите Production URL и токен в Android и отправьте текстовый тест.
+Для вариантов с изображениями используйте переключатели Connection. Сверьте `event_id` в Journal,
+Executions и ACK. Проверьте каждый тип картинок и смешанный набор. Повторите исходный event ID для
+проверки ключей уровня 5. Уровни 3–4 не подавляют повторы; уровень 5 уменьшает их число, но не даёт
+exactly-once при параллельных запросах или креше между отправкой и сохранением ключа.
+
+Оставьте **On Error → Stop Workflow**. Учитывайте HTTP 429 и повторную доставку после сетевых/серверных
+ошибок. Большие файлы и много отправок могут превысить read timeout телефона 10 секунд: для тяжёлых
+сценариев нужна надёжная входящая очередь и worker, а не ранний ACK.
+Не выбирайте Telegram источником уведомлений на получающем телефоне, чтобы не создать петлю.
+Executions могут содержать текст, binary и токены в заголовках: настройте retention.
diff --git a/docs/ru/n8n-webhook.md b/docs/ru/n8n-webhook.md
index e20fb62..f362c3a 100644
--- a/docs/ru/n8n-webhook.md
+++ b/docs/ru/n8n-webhook.md
@@ -2,6 +2,8 @@
[English](../en/n8n-webhook.md) | [Русский](../ru/n8n-webhook.md)
+Выберите [готовый workflow по уровню](n8n-examples.md): текстовый webhook, webhook с картинками, Telegram с текстом, картинки на стандартных nodes или расширенный Rich Message.
+
Выберите инструкцию по тому, где работает ваш n8n:
| Вариант | Инструкция |
diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md
index 6b92131..713377a 100644
--- a/docs/ru/release-testing.md
+++ b/docs/ru/release-testing.md
@@ -176,8 +176,8 @@ SHA-256 APK с разными подписями не заменяют эту п
| Q03 | n8n ACK: чужой/отсутствующий event_id, неверный status, malformed/слишком большой JSON; generic webhook с ACK выключенным. HTTP 2xx с неверным ACK — BLOCKED и ручной retry, а generic 2xx — HTTP_SUCCESS. Отличайте отказ клиента от успеха выполнения n8n error-workflow. |
| Q04 | Timeout/network failure, HTTP 408/425/429/5xx → очередь повторяет; прочие HTTP failures → ручное исправление/retry. После восстановления сохраняются event_id, тело, старый URL/токен/ACK. Ответ потерян после приёма: возможен повтор на сервере, ID прежний. Нет обещания точного времени WorkManager retry. |
| Q05 | Создайте offline очередь на endpoint A, смените настройку на B. Старые события идут только на A с прежними реквизитами; новые — на B. Ротация токена в UI не исправляет старый encrypted envelope. Используйте отдельные тестовые endpoints; при необходимости старые события явно удаляют, а не тихо перенаправляют. |
-| Q06 | Retry now, удаление тестовой записи, быстрые повторные действия и открытый detail при смене состояния. Нет дублирования локального события/потери очереди; активную отправку нельзя удалить как свободную запись. После acceptance payload удаляется из активной записи, metadata остаётся ограниченной; logical delete не доказывает физическое стирание. |
-| Q07 | Offline enqueue → обычное завершение процесса/rotation/relaunch → восстановление сети. Проверить сохранность encrypted payload и доставку, включая очередь предыдущей версии. Недоступный Keystore/повреждённое хранилище — изолированный сценарий: нет отправки мусора или тихого удаления; ошибка видна. Успех Robolectric не доказывает настоящий Keystore. |
+| Q06 | Retry now, удаление тестовой записи, быстрые повторные действия и открытый detail при смене состояния. Нет дублирования локального события/потери очереди; активную отправку нельзя удалить как свободную запись. После acceptance зашифрованный запрос и приватные файлы изображений удаляются, metadata остаётся ограниченной; logical delete не доказывает физическое стирание. |
+| Q07 | Offline enqueue → обычное завершение процесса/rotation/relaunch → восстановление сети. Проверить сохранность зашифрованного запроса, приватных файлов изображений и доставку, включая миграцию очереди предыдущей версии. Недоступный Keystore/повреждённое хранилище — изолированный сценарий: нет отправки мусора или тихого удаления; ошибка видна. Успех Robolectric не доказывает настоящий Keystore. |
### Жизненный цикл и фон
diff --git a/examples/n8n/payloads/images.json b/examples/n8n/payloads/images.json
new file mode 100644
index 0000000..5292ad9
--- /dev/null
+++ b/examples/n8n/payloads/images.json
@@ -0,0 +1,53 @@
+{
+ "schema_version": 1,
+ "event_id": "example-images-001",
+ "device_id": "example-installation",
+ "device_code": "example-phone",
+ "message_type": "test",
+ "occurred_at": "2026-01-01T12:00:00Z",
+ "source": "org.example.demo",
+ "source_name": "Example App",
+ "text": "Synthetic connection test",
+ "images": [
+ {
+ "mime_type": "image/png",
+ "width": 64,
+ "height": 48,
+ "kind": "attachment",
+ "presentation": "big_picture",
+ "data_base64": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAZElEQVR4nO3PUQkAIBTAwFfIQuY0oCH8OITBAtxm7fN1wwUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY8dgHYLcTT/pYwuAAAAABJRU5ErkJggg=="
+ },
+ {
+ "mime_type": "image/png",
+ "width": 64,
+ "height": 48,
+ "kind": "attachment",
+ "presentation": "gallery",
+ "data_base64": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAZElEQVR4nO3PUQkAIBTAwBfHTGYytCH8OITBAtxmnf11wwUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY8dgH3xqi1qhsK/AAAAABJRU5ErkJggg=="
+ },
+ {
+ "mime_type": "image/png",
+ "width": 64,
+ "height": 48,
+ "kind": "attachment",
+ "presentation": "gallery",
+ "data_base64": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAY0lEQVR4nO3PwQkAIBDAsBvMOX07qkP4CEKhA6Sz1/m64YIGtKABLWhACxrQgga0oAEtaEALGtCCBrSgAS1oQAsa0IIGtKABLWhACxrQgga0oAEtaEALGtCCBrSgAS1oQAseu1BuWQARb1GyAAAAAElFTkSuQmCC"
+ },
+ {
+ "mime_type": "image/png",
+ "width": 64,
+ "height": 48,
+ "kind": "avatar",
+ "presentation": "file",
+ "data_base64": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAZElEQVR4nO3PUQkAIBTAwJfE2EY0hyH8OITBAtzm7PV1wwUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY8dgEmBTkeIEIIeQAAAABJRU5ErkJggg=="
+ },
+ {
+ "mime_type": "image/png",
+ "width": 64,
+ "height": 48,
+ "kind": "app_icon",
+ "presentation": "file",
+ "data_base64": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAZElEQVR4nO3PUQkAIBTAwBfHxGYyliH8OITBAtxm7fN1wwUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY8dgHGfkkPFymGhQAAAABJRU5ErkJggg=="
+ }
+ ]
+}
diff --git a/examples/n8n/payloads/text.json b/examples/n8n/payloads/text.json
new file mode 100644
index 0000000..10392e7
--- /dev/null
+++ b/examples/n8n/payloads/text.json
@@ -0,0 +1,11 @@
+{
+ "schema_version": 1,
+ "event_id": "example-text-001",
+ "device_id": "example-installation",
+ "device_code": "example-phone",
+ "message_type": "test",
+ "occurred_at": "2026-01-01T12:00:00Z",
+ "source": "org.example.demo",
+ "source_name": "Example App",
+ "text": "Synthetic connection test"
+}
diff --git a/examples/n8n/workflows/01-webhook-text.json b/examples/n8n/workflows/01-webhook-text.json
new file mode 100644
index 0000000..6a42334
--- /dev/null
+++ b/examples/n8n/workflows/01-webhook-text.json
@@ -0,0 +1,151 @@
+{
+ "name": "Message487 · 01 · Webhook text",
+ "active": false,
+ "nodes": [
+ {
+ "id": "a638475d-e440-5f20-a18e-dafb55a44735",
+ "name": "Webhook",
+ "type": "n8n-nodes-base.webhook",
+ "typeVersion": 2.1,
+ "position": [
+ 0,
+ 0
+ ],
+ "parameters": {
+ "httpMethod": "POST",
+ "path": "message487/01-webhook-text",
+ "authentication": "headerAuth",
+ "responseMode": "responseNode",
+ "options": {}
+ },
+ "webhookId": "dd26cd40-1653-5527-bc86-e0889e9b79f8"
+ },
+ {
+ "id": "44ddec63-b843-5e34-8a67-2aa2a8e3dad7",
+ "name": "Validate event",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 220,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "const processImages = false;\nconst event = $('Webhook').first().json.body;\nlet reason = '';\nif (!event || event.schema_version !== 1 || typeof event.event_id !== 'string' ||\n !event.event_id || !['test', 'notification', 'sms'].includes(event.message_type) ||\n typeof event.text !== 'string') reason = 'Invalid Message487 event';\nconst images = event?.images ?? [];\nif (!Array.isArray(images)) reason = 'images must be an array';\nelse if (processImages && images.some(image => !image || !['image/jpeg', 'image/png'].includes(image.mime_type) ||\n !Number.isInteger(image.width) || image.width <= 0 || !Number.isInteger(image.height) || image.height <= 0 ||\n typeof image.data_base64 !== 'string' || !image.data_base64.length ||\n image.data_base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(image.data_base64))) {\n reason = 'Invalid JPEG/PNG image';\n}\nreturn [{ json: { valid: !reason, reason, event_id: event?.event_id ?? null } }];\n"
+ }
+ },
+ {
+ "id": "f3581b2f-32e9-570d-a835-8b48aee4b777",
+ "name": "Valid event",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 440,
+ 0
+ ],
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict",
+ "version": 2
+ },
+ "conditions": [
+ {
+ "id": "ad1abe2e-40cb-5729-b1e2-5ab82f4422e0",
+ "leftValue": "={{ $json.valid }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true",
+ "singleValue": true
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "1e5e4191-e6c8-5b7f-8aec-5bc08710f4f4",
+ "name": "Reject",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.4,
+ "position": [
+ 640,
+ 300
+ ],
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { status: \"rejected\", event_id: $json.event_id, reason: $json.reason } }}",
+ "options": {
+ "responseCode": 400
+ }
+ }
+ },
+ {
+ "id": "3c09d965-83fc-5ced-9f95-24243d9a47ae",
+ "name": "Respond",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.4,
+ "position": [
+ 880,
+ -200
+ ],
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }}",
+ "options": {
+ "responseCode": 200
+ }
+ }
+ }
+ ],
+ "connections": {
+ "Webhook": {
+ "main": [
+ [
+ {
+ "node": "Validate event",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Validate event": {
+ "main": [
+ [
+ {
+ "node": "Valid event",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Valid event": {
+ "main": [
+ [
+ {
+ "node": "Respond",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Reject",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1",
+ "timezone": "UTC"
+ }
+}
diff --git a/examples/n8n/workflows/02-webhook-images.json b/examples/n8n/workflows/02-webhook-images.json
new file mode 100644
index 0000000..730a685
--- /dev/null
+++ b/examples/n8n/workflows/02-webhook-images.json
@@ -0,0 +1,175 @@
+{
+ "name": "Message487 · 02 · Webhook images",
+ "active": false,
+ "nodes": [
+ {
+ "id": "3c2a4ea6-a0fd-55ad-892d-7ebcfd0369c4",
+ "name": "Webhook",
+ "type": "n8n-nodes-base.webhook",
+ "typeVersion": 2.1,
+ "position": [
+ 0,
+ 0
+ ],
+ "parameters": {
+ "httpMethod": "POST",
+ "path": "message487/02-webhook-images",
+ "authentication": "headerAuth",
+ "responseMode": "responseNode",
+ "options": {}
+ },
+ "webhookId": "35f4b9d4-387d-5aec-b10d-7e56171c46f1"
+ },
+ {
+ "id": "2fba2d67-8054-5b2e-9b0d-e986f7842696",
+ "name": "Validate event",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 220,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "const processImages = true;\nconst event = $('Webhook').first().json.body;\nlet reason = '';\nif (!event || event.schema_version !== 1 || typeof event.event_id !== 'string' ||\n !event.event_id || !['test', 'notification', 'sms'].includes(event.message_type) ||\n typeof event.text !== 'string') reason = 'Invalid Message487 event';\nconst images = event?.images ?? [];\nif (!Array.isArray(images)) reason = 'images must be an array';\nelse if (processImages && images.some(image => !image || !['image/jpeg', 'image/png'].includes(image.mime_type) ||\n !Number.isInteger(image.width) || image.width <= 0 || !Number.isInteger(image.height) || image.height <= 0 ||\n typeof image.data_base64 !== 'string' || !image.data_base64.length ||\n image.data_base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(image.data_base64))) {\n reason = 'Invalid JPEG/PNG image';\n}\nreturn [{ json: { valid: !reason, reason, event_id: event?.event_id ?? null } }];\n"
+ }
+ },
+ {
+ "id": "c57e4445-4585-5ae7-81b2-5a21aff9039a",
+ "name": "Valid event",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 440,
+ 0
+ ],
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict",
+ "version": 2
+ },
+ "conditions": [
+ {
+ "id": "ad1abe2e-40cb-5729-b1e2-5ab82f4422e0",
+ "leftValue": "={{ $json.valid }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true",
+ "singleValue": true
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "b427fcc9-ee8b-5648-954e-06e4df3363b4",
+ "name": "Reject",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.4,
+ "position": [
+ 640,
+ 300
+ ],
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { status: \"rejected\", event_id: $json.event_id, reason: $json.reason } }}",
+ "options": {
+ "responseCode": 400
+ }
+ }
+ },
+ {
+ "id": "2379636e-be7e-5c90-8784-4fa4e92f3709",
+ "name": "Read images",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 640,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "// The previous validation node checks image metadata and Base64 before this step.\nconst event = $('Webhook').first().json.body;\nconst binary = {};\nconst images = (event.images ?? []).map((image, index) => {\n const { data_base64, ...metadata } = image;\n binary[`image${index}`] = { data: data_base64, mimeType: image.mime_type,\n fileName: `${['avatar', 'app_icon'].includes(image.kind) ? image.kind : 'attachment'}-${index + 1}.${image.mime_type === 'image/png' ? 'png' : 'jpg'}` };\n return metadata;\n});\nreturn [{ json: { ...event, images, image_count: images.length }, binary }];\n"
+ }
+ },
+ {
+ "id": "87c32929-f9c2-5d03-8d5c-47037a71ed3a",
+ "name": "Respond",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.4,
+ "position": [
+ 880,
+ -200
+ ],
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }}",
+ "options": {
+ "responseCode": 200
+ }
+ }
+ }
+ ],
+ "connections": {
+ "Webhook": {
+ "main": [
+ [
+ {
+ "node": "Validate event",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Validate event": {
+ "main": [
+ [
+ {
+ "node": "Valid event",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Valid event": {
+ "main": [
+ [
+ {
+ "node": "Read images",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Reject",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Read images": {
+ "main": [
+ [
+ {
+ "node": "Respond",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1",
+ "timezone": "UTC"
+ }
+}
diff --git a/examples/n8n/workflows/03-telegram-text.json b/examples/n8n/workflows/03-telegram-text.json
new file mode 100644
index 0000000..c52deea
--- /dev/null
+++ b/examples/n8n/workflows/03-telegram-text.json
@@ -0,0 +1,241 @@
+{
+ "name": "Message487 · 03 · Telegram text",
+ "active": false,
+ "nodes": [
+ {
+ "id": "d5fa8ddd-4254-5206-b95d-da543313894f",
+ "name": "Webhook",
+ "type": "n8n-nodes-base.webhook",
+ "typeVersion": 2.1,
+ "position": [
+ 0,
+ 0
+ ],
+ "parameters": {
+ "httpMethod": "POST",
+ "path": "message487/03-telegram-text",
+ "authentication": "headerAuth",
+ "responseMode": "responseNode",
+ "options": {}
+ },
+ "webhookId": "bba43173-ad93-5a9a-904c-f85bc821066f"
+ },
+ {
+ "id": "013aa04d-aed4-5b51-9599-ce1834d64d56",
+ "name": "Validate event",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 220,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "const processImages = false;\nconst event = $('Webhook').first().json.body;\nlet reason = '';\nif (!event || event.schema_version !== 1 || typeof event.event_id !== 'string' ||\n !event.event_id || !['test', 'notification', 'sms'].includes(event.message_type) ||\n typeof event.text !== 'string') reason = 'Invalid Message487 event';\nconst images = event?.images ?? [];\nif (!Array.isArray(images)) reason = 'images must be an array';\nelse if (processImages && images.some(image => !image || !['image/jpeg', 'image/png'].includes(image.mime_type) ||\n !Number.isInteger(image.width) || image.width <= 0 || !Number.isInteger(image.height) || image.height <= 0 ||\n typeof image.data_base64 !== 'string' || !image.data_base64.length ||\n image.data_base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(image.data_base64))) {\n reason = 'Invalid JPEG/PNG image';\n}\nreturn [{ json: { valid: !reason, reason, event_id: event?.event_id ?? null } }];\n"
+ }
+ },
+ {
+ "id": "0f799d88-a081-528e-969d-6a993ce1a0a7",
+ "name": "Valid event",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 440,
+ 0
+ ],
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict",
+ "version": 2
+ },
+ "conditions": [
+ {
+ "id": "ad1abe2e-40cb-5729-b1e2-5ab82f4422e0",
+ "leftValue": "={{ $json.valid }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true",
+ "singleValue": true
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "f47ddd97-c68b-5bf1-9f28-f3734623fcb7",
+ "name": "Reject",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.4,
+ "position": [
+ 640,
+ 300
+ ],
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { status: \"rejected\", event_id: $json.event_id, reason: $json.reason } }}",
+ "options": {
+ "responseCode": 400
+ }
+ }
+ },
+ {
+ "id": "d3a01bfb-84a7-5f52-a4f8-5a7eec7f69fc",
+ "name": "Configure",
+ "type": "n8n-nodes-base.set",
+ "typeVersion": 3.4,
+ "position": [
+ 640,
+ 0
+ ],
+ "parameters": {
+ "assignments": {
+ "assignments": [
+ {
+ "id": "cc14013d-4c02-5063-b108-47cb28b60fec",
+ "name": "chat_id",
+ "value": "REPLACE_WITH_CHAT_ID",
+ "type": "string"
+ }
+ ]
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "35ec0d83-45f3-5c05-b2aa-8385c8ee4b4c",
+ "name": "Prepare Telegram text",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 860,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "const timeZone = 'UTC';\nconst locale = 'en-GB';\nconst messageTypes = { sms: 'SMS', notification: 'Notification', test: 'Test' };\nconst event = $('Webhook').first().json.body;\n\nif (!event || event.schema_version !== 1 ||\n typeof event.event_id !== 'string' || !event.event_id ||\n !Object.hasOwn(messageTypes, event.message_type) ||\n typeof event.text !== 'string') {\n throw new Error('Invalid Message487 event');\n}\n\nconst escapeHtml = value => value.replace(/[&<>]/g, character => ({\n '&': '&', '<': '<', '>': '>',\n})[character]);\nconst firstText = (...values) => values.find(value => typeof value === 'string' && value) || '—';\nconst date = new Date(event.occurred_at);\nconst timestamp = typeof event.occurred_at === 'string' && !Number.isNaN(date.getTime())\n ? new Intl.DateTimeFormat(locale, {\n timeZone, day: '2-digit', month: '2-digit', year: 'numeric',\n hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',\n }).format(date).replace(/,/g, '')\n : '—';\nconst source = firstText(event.sender, event.source_name, event.source);\nconst device = firstText(event.device_code, event.device_id);\nconst segments = [\n { text: 'Message487: ', bold: false },\n { text: source, bold: true },\n { text: '\\n', bold: false },\n { text: `${messageTypes[event.message_type]}\\n${device}\\n${timestamp}`, bold: true },\n { text: '\\n', bold: false },\n { text: typeof event.title === 'string' && event.title ? `${event.title}\\n` : '', bold: false },\n { text: event.text, bold: false },\n];\n\n// Split unescaped text and close formatting in each part to keep HTML valid.\nconst chunks = [];\nlet html = '';\nlet length = 0;\nfor (const segment of segments) {\n let run = '';\n const flush = () => {\n if (run) {\n const escaped = escapeHtml(run);\n html += segment.bold ? `${escaped}` : escaped;\n run = '';\n }\n };\n for (const character of segment.text) {\n if (length + character.length > 3500) {\n flush();\n chunks.push(html);\n html = '';\n length = 0;\n }\n run += character;\n length += character.length;\n }\n flush();\n}\nif (html) chunks.push(html);\n\nreturn chunks.map((part, index) => ({\n json: {\n telegram_text: chunks.length > 1 ? `[${index + 1}/${chunks.length}]\\n${part}` : part,\n },\n}));\n"
+ }
+ },
+ {
+ "id": "00a1eef0-147d-5406-a534-04c8baae64d7",
+ "name": "Send text",
+ "type": "n8n-nodes-base.telegram",
+ "typeVersion": 1.2,
+ "position": [
+ 1080,
+ 0
+ ],
+ "parameters": {
+ "resource": "message",
+ "operation": "sendMessage",
+ "chatId": "={{ $('Configure').first().json.chat_id }}",
+ "additionalFields": {
+ "appendAttribution": false,
+ "parse_mode": "HTML",
+ "disable_web_page_preview": true
+ },
+ "text": "={{ $json.telegram_text }}"
+ }
+ },
+ {
+ "id": "9222c7ba-b293-5370-a7ee-3acfc8c1c8fa",
+ "name": "Respond",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.4,
+ "position": [
+ 1300,
+ -200
+ ],
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }}",
+ "options": {
+ "responseCode": 200
+ }
+ }
+ }
+ ],
+ "connections": {
+ "Webhook": {
+ "main": [
+ [
+ {
+ "node": "Validate event",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Validate event": {
+ "main": [
+ [
+ {
+ "node": "Valid event",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Valid event": {
+ "main": [
+ [
+ {
+ "node": "Configure",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Reject",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Configure": {
+ "main": [
+ [
+ {
+ "node": "Prepare Telegram text",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Prepare Telegram text": {
+ "main": [
+ [
+ {
+ "node": "Send text",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Send text": {
+ "main": [
+ [
+ {
+ "node": "Respond",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1",
+ "timezone": "UTC"
+ }
+}
diff --git a/examples/n8n/workflows/04-telegram-images-standard.json b/examples/n8n/workflows/04-telegram-images-standard.json
new file mode 100644
index 0000000..a61f668
--- /dev/null
+++ b/examples/n8n/workflows/04-telegram-images-standard.json
@@ -0,0 +1,463 @@
+{
+ "name": "Message487 · 04 · Telegram images · standard nodes",
+ "active": false,
+ "nodes": [
+ {
+ "id": "78a162bc-637c-5884-922b-82abd805f4b6",
+ "name": "Webhook",
+ "type": "n8n-nodes-base.webhook",
+ "typeVersion": 2.1,
+ "position": [
+ 0,
+ 0
+ ],
+ "parameters": {
+ "httpMethod": "POST",
+ "path": "message487/04-telegram-images-standard",
+ "authentication": "headerAuth",
+ "responseMode": "responseNode",
+ "options": {}
+ },
+ "webhookId": "154ba0e9-5b8f-52ef-9407-cbf5594c2fec"
+ },
+ {
+ "id": "de64de2d-3b14-5b23-8bfd-451367bf97c1",
+ "name": "Validate event",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 220,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "const processImages = true;\nconst event = $('Webhook').first().json.body;\nlet reason = '';\nif (!event || event.schema_version !== 1 || typeof event.event_id !== 'string' ||\n !event.event_id || !['test', 'notification', 'sms'].includes(event.message_type) ||\n typeof event.text !== 'string') reason = 'Invalid Message487 event';\nconst images = event?.images ?? [];\nif (!Array.isArray(images)) reason = 'images must be an array';\nelse if (processImages && images.some(image => !image || !['image/jpeg', 'image/png'].includes(image.mime_type) ||\n !Number.isInteger(image.width) || image.width <= 0 || !Number.isInteger(image.height) || image.height <= 0 ||\n typeof image.data_base64 !== 'string' || !image.data_base64.length ||\n image.data_base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(image.data_base64))) {\n reason = 'Invalid JPEG/PNG image';\n}\nreturn [{ json: { valid: !reason, reason, event_id: event?.event_id ?? null } }];\n"
+ }
+ },
+ {
+ "id": "0d734e56-db7a-5e7d-aa98-2c756872942e",
+ "name": "Valid event",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 440,
+ 0
+ ],
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict",
+ "version": 2
+ },
+ "conditions": [
+ {
+ "id": "ad1abe2e-40cb-5729-b1e2-5ab82f4422e0",
+ "leftValue": "={{ $json.valid }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true",
+ "singleValue": true
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "fc774526-7525-5439-a817-49abcd377df1",
+ "name": "Reject",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.4,
+ "position": [
+ 640,
+ 300
+ ],
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { status: \"rejected\", event_id: $json.event_id, reason: $json.reason } }}",
+ "options": {
+ "responseCode": 400
+ }
+ }
+ },
+ {
+ "id": "e184535f-6270-5868-b669-368ed35db075",
+ "name": "Configure",
+ "type": "n8n-nodes-base.set",
+ "typeVersion": 3.4,
+ "position": [
+ 640,
+ 0
+ ],
+ "parameters": {
+ "assignments": {
+ "assignments": [
+ {
+ "id": "cc14013d-4c02-5063-b108-47cb28b60fec",
+ "name": "chat_id",
+ "value": "REPLACE_WITH_CHAT_ID",
+ "type": "string"
+ }
+ ]
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "b7380935-2c78-5cd7-8e53-08475221b95b",
+ "name": "Prepare Telegram text",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 860,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "const timeZone = 'UTC';\nconst locale = 'en-GB';\nconst messageTypes = { sms: 'SMS', notification: 'Notification', test: 'Test' };\nconst event = $('Webhook').first().json.body;\n\nif (!event || event.schema_version !== 1 ||\n typeof event.event_id !== 'string' || !event.event_id ||\n !Object.hasOwn(messageTypes, event.message_type) ||\n typeof event.text !== 'string') {\n throw new Error('Invalid Message487 event');\n}\n\nconst escapeHtml = value => value.replace(/[&<>]/g, character => ({\n '&': '&', '<': '<', '>': '>',\n})[character]);\nconst firstText = (...values) => values.find(value => typeof value === 'string' && value) || '—';\nconst date = new Date(event.occurred_at);\nconst timestamp = typeof event.occurred_at === 'string' && !Number.isNaN(date.getTime())\n ? new Intl.DateTimeFormat(locale, {\n timeZone, day: '2-digit', month: '2-digit', year: 'numeric',\n hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',\n }).format(date).replace(/,/g, '')\n : '—';\nconst source = firstText(event.sender, event.source_name, event.source);\nconst device = firstText(event.device_code, event.device_id);\nconst segments = [\n { text: 'Message487: ', bold: false },\n { text: source, bold: true },\n { text: '\\n', bold: false },\n { text: `${messageTypes[event.message_type]}\\n${device}\\n${timestamp}`, bold: true },\n { text: '\\n', bold: false },\n { text: typeof event.title === 'string' && event.title ? `${event.title}\\n` : '', bold: false },\n { text: event.text, bold: false },\n];\n\n// Split unescaped text and close formatting in each part to keep HTML valid.\nconst chunks = [];\nlet html = '';\nlet length = 0;\nfor (const segment of segments) {\n let run = '';\n const flush = () => {\n if (run) {\n const escaped = escapeHtml(run);\n html += segment.bold ? `${escaped}` : escaped;\n run = '';\n }\n };\n for (const character of segment.text) {\n if (length + character.length > 3500) {\n flush();\n chunks.push(html);\n html = '';\n length = 0;\n }\n run += character;\n length += character.length;\n }\n flush();\n}\nif (html) chunks.push(html);\n\nreturn chunks.map((part, index) => ({\n json: {\n telegram_text: chunks.length > 1 ? `[${index + 1}/${chunks.length}]\\n${part}` : part,\n },\n}));\n"
+ }
+ },
+ {
+ "id": "165432a0-b202-5ef0-9fb5-98bb082f060a",
+ "name": "Prepare image parts",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 1080,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "// Standard Telegram nodes send each image separately; no custom node is needed.\nconst result = $input.all();\nconst images = $('Webhook').first().json.body.images ?? [];\nimages.forEach((image, index) => {\n const kind = ['avatar', 'app_icon'].includes(image.kind) ? image.kind : 'attachment';\n result.push({ json: {\n telegram_operation: kind === 'attachment' && ['big_picture', 'gallery'].includes(image.presentation)\n ? 'sendPhoto' : 'sendDocument',\n caption: kind === 'avatar' ? 'Avatar' : kind === 'app_icon' ? 'Application icon' : '',\n }, binary: { data: { data: image.data_base64, mimeType: image.mime_type,\n fileName: `${kind}-${index + 1}.${image.mime_type === 'image/png' ? 'png' : 'jpg'}` } } });\n});\nreturn result;\n"
+ }
+ },
+ {
+ "id": "01af9442-8d9b-553c-a16f-646c5a6eb6d2",
+ "name": "Loop Over Parts",
+ "type": "n8n-nodes-base.splitInBatches",
+ "typeVersion": 3,
+ "position": [
+ 1500,
+ 0
+ ],
+ "parameters": {
+ "batchSize": 1,
+ "options": {}
+ }
+ },
+ {
+ "id": "2c4640c7-2680-51e8-9f1e-98db68bd0773",
+ "name": "Respond",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.4,
+ "position": [
+ 1720,
+ -200
+ ],
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }}",
+ "options": {
+ "responseCode": 200
+ }
+ }
+ },
+ {
+ "id": "f95a6e24-6d49-562d-aba3-ee3f58916656",
+ "name": "Is text",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 1720,
+ 100
+ ],
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict",
+ "version": 2
+ },
+ "conditions": [
+ {
+ "id": "a737408d-de3a-5892-8b9a-cfa290e2e8e0",
+ "leftValue": "={{ !$json.telegram_operation }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true",
+ "singleValue": true
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "cc9c07e4-f3ff-54d8-b4a5-28efbaa3cf5d",
+ "name": "Is photo",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 1940,
+ 220
+ ],
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict",
+ "version": 2
+ },
+ "conditions": [
+ {
+ "id": "07deda4b-c3fe-5d93-9907-d755f47b4428",
+ "leftValue": "={{ $json.telegram_operation === \"sendPhoto\" }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true",
+ "singleValue": true
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "6dc0c188-042f-5435-9bc3-fa0634331d21",
+ "name": "Send text",
+ "type": "n8n-nodes-base.telegram",
+ "typeVersion": 1.2,
+ "position": [
+ 2160,
+ 0
+ ],
+ "parameters": {
+ "resource": "message",
+ "operation": "sendMessage",
+ "chatId": "={{ $('Configure').first().json.chat_id }}",
+ "additionalFields": {
+ "appendAttribution": false,
+ "parse_mode": "HTML",
+ "disable_web_page_preview": true
+ },
+ "text": "={{ $json.telegram_text }}"
+ }
+ },
+ {
+ "id": "33e9ecd8-9bc5-520c-b305-944730d62634",
+ "name": "Send photo",
+ "type": "n8n-nodes-base.telegram",
+ "typeVersion": 1.2,
+ "position": [
+ 2160,
+ 160
+ ],
+ "parameters": {
+ "resource": "message",
+ "operation": "sendPhoto",
+ "chatId": "={{ $('Configure').first().json.chat_id }}",
+ "additionalFields": {
+ "caption": "={{ $json.caption }}"
+ },
+ "binaryData": true,
+ "binaryPropertyName": "data"
+ }
+ },
+ {
+ "id": "e8b745ac-66e4-5451-bc52-12561da7783c",
+ "name": "Send file",
+ "type": "n8n-nodes-base.telegram",
+ "typeVersion": 1.2,
+ "position": [
+ 2160,
+ 320
+ ],
+ "parameters": {
+ "resource": "message",
+ "operation": "sendDocument",
+ "chatId": "={{ $('Configure').first().json.chat_id }}",
+ "additionalFields": {
+ "caption": "={{ $json.caption }}"
+ },
+ "binaryData": true,
+ "binaryPropertyName": "data"
+ }
+ }
+ ],
+ "connections": {
+ "Webhook": {
+ "main": [
+ [
+ {
+ "node": "Validate event",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Validate event": {
+ "main": [
+ [
+ {
+ "node": "Valid event",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Valid event": {
+ "main": [
+ [
+ {
+ "node": "Configure",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Reject",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Configure": {
+ "main": [
+ [
+ {
+ "node": "Prepare Telegram text",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Prepare Telegram text": {
+ "main": [
+ [
+ {
+ "node": "Prepare image parts",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Loop Over Parts": {
+ "main": [
+ [
+ {
+ "node": "Respond",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Is text",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Prepare image parts": {
+ "main": [
+ [
+ {
+ "node": "Loop Over Parts",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Is text": {
+ "main": [
+ [
+ {
+ "node": "Send text",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Is photo",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Is photo": {
+ "main": [
+ [
+ {
+ "node": "Send photo",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Send file",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Send text": {
+ "main": [
+ [
+ {
+ "node": "Loop Over Parts",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Send photo": {
+ "main": [
+ [
+ {
+ "node": "Loop Over Parts",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Send file": {
+ "main": [
+ [
+ {
+ "node": "Loop Over Parts",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1",
+ "timezone": "UTC"
+ }
+}
diff --git a/examples/n8n/workflows/05-telegram-cleanup.json b/examples/n8n/workflows/05-telegram-cleanup.json
new file mode 100644
index 0000000..4d2a415
--- /dev/null
+++ b/examples/n8n/workflows/05-telegram-cleanup.json
@@ -0,0 +1,109 @@
+{
+ "name": "Message487 · 05 · Optional sent-key cleanup",
+ "active": false,
+ "nodes": [
+ {
+ "id": "aae74778-23a6-5b95-b951-bf05e33e867b",
+ "name": "Daily",
+ "type": "n8n-nodes-base.scheduleTrigger",
+ "typeVersion": 1.2,
+ "position": [
+ 400,
+ 0
+ ],
+ "parameters": {
+ "rule": {
+ "interval": [
+ {
+ "field": "cronExpression",
+ "expression": "0 3 * * *"
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "8c1e16ff-1032-5f3a-8428-1606e9b7c2c4",
+ "name": "Configure",
+ "type": "n8n-nodes-base.set",
+ "typeVersion": 3.4,
+ "position": [
+ 640,
+ 0
+ ],
+ "parameters": {
+ "assignments": {
+ "assignments": [
+ {
+ "id": "fd0cda76-b600-5cbd-9182-6ffc99514f16",
+ "name": "sent_parts_table_id",
+ "value": "REPLACE_WITH_SENT_PARTS_TABLE_ID",
+ "type": "string"
+ }
+ ]
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "83ed3b9b-0b03-5130-8ae7-e01bde5bf1a6",
+ "name": "Delete old keys",
+ "type": "n8n-nodes-base.dataTable",
+ "typeVersion": 1.1,
+ "position": [
+ 880,
+ 0
+ ],
+ "parameters": {
+ "resource": "row",
+ "operation": "deleteRows",
+ "dataTableId": {
+ "__rl": true,
+ "mode": "id",
+ "value": "={{ $('Configure').first().json.sent_parts_table_id }}"
+ },
+ "matchType": "allConditions",
+ "filters": {
+ "conditions": [
+ {
+ "keyName": "createdAt",
+ "condition": "lt",
+ "keyValue": "={{ $now.minus({ days: 30 }).toISO() }}"
+ }
+ ]
+ },
+ "options": {
+ "dryRun": true
+ }
+ }
+ }
+ ],
+ "connections": {
+ "Daily": {
+ "main": [
+ [
+ {
+ "node": "Configure",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Configure": {
+ "main": [
+ [
+ {
+ "node": "Delete old keys",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1",
+ "timezone": "UTC"
+ }
+}
diff --git a/examples/n8n/workflows/05-telegram-images-advanced.json b/examples/n8n/workflows/05-telegram-images-advanced.json
new file mode 100644
index 0000000..da17fd9
--- /dev/null
+++ b/examples/n8n/workflows/05-telegram-images-advanced.json
@@ -0,0 +1,588 @@
+{
+ "name": "Message487 · 05 · Telegram images · rich messages and retry progress",
+ "active": false,
+ "nodes": [
+ {
+ "id": "38d3a705-5f0a-5a38-9ab8-1ab873b854a7",
+ "name": "Webhook",
+ "type": "n8n-nodes-base.webhook",
+ "typeVersion": 2.1,
+ "position": [
+ 0,
+ 0
+ ],
+ "parameters": {
+ "httpMethod": "POST",
+ "path": "message487/05-telegram-images-advanced",
+ "authentication": "headerAuth",
+ "responseMode": "responseNode",
+ "options": {}
+ },
+ "webhookId": "35f65d0e-fb9a-55c1-966a-902f19d1dbcd"
+ },
+ {
+ "id": "c0255c6a-d2bb-5e9e-ad63-283a91f64665",
+ "name": "Validate event",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 220,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "const processImages = true;\nconst event = $('Webhook').first().json.body;\nlet reason = '';\nif (!event || event.schema_version !== 1 || typeof event.event_id !== 'string' ||\n !event.event_id || !['test', 'notification', 'sms'].includes(event.message_type) ||\n typeof event.text !== 'string') reason = 'Invalid Message487 event';\nconst images = event?.images ?? [];\nif (!Array.isArray(images)) reason = 'images must be an array';\nelse if (processImages && images.some(image => !image || !['image/jpeg', 'image/png'].includes(image.mime_type) ||\n !Number.isInteger(image.width) || image.width <= 0 || !Number.isInteger(image.height) || image.height <= 0 ||\n typeof image.data_base64 !== 'string' || !image.data_base64.length ||\n image.data_base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(image.data_base64))) {\n reason = 'Invalid JPEG/PNG image';\n}\nreturn [{ json: { valid: !reason, reason, event_id: event?.event_id ?? null } }];\n"
+ }
+ },
+ {
+ "id": "811113f7-8af2-548b-bb20-49cf23f91e8d",
+ "name": "Valid event",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 440,
+ 0
+ ],
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict",
+ "version": 2
+ },
+ "conditions": [
+ {
+ "id": "ad1abe2e-40cb-5729-b1e2-5ab82f4422e0",
+ "leftValue": "={{ $json.valid }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true",
+ "singleValue": true
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "f59f466f-0005-50b9-bf71-768b5d48f191",
+ "name": "Reject",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.4,
+ "position": [
+ 640,
+ 300
+ ],
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { status: \"rejected\", event_id: $json.event_id, reason: $json.reason } }}",
+ "options": {
+ "responseCode": 400
+ }
+ }
+ },
+ {
+ "id": "16c27112-6589-5c27-99ca-3f13b3e9a84f",
+ "name": "Configure",
+ "type": "n8n-nodes-base.set",
+ "typeVersion": 3.4,
+ "position": [
+ 640,
+ 0
+ ],
+ "parameters": {
+ "assignments": {
+ "assignments": [
+ {
+ "id": "cc14013d-4c02-5063-b108-47cb28b60fec",
+ "name": "chat_id",
+ "value": "REPLACE_WITH_CHAT_ID",
+ "type": "string"
+ },
+ {
+ "id": "fd0cda76-b600-5cbd-9182-6ffc99514f16",
+ "name": "sent_parts_table_id",
+ "value": "REPLACE_WITH_SENT_PARTS_TABLE_ID",
+ "type": "string"
+ }
+ ]
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "687f24ac-e08d-5fb6-be5d-a5c2d6beccff",
+ "name": "Prepare Telegram text",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 860,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "const timeZone = 'UTC';\nconst locale = 'en-GB';\nconst messageTypes = { sms: 'SMS', notification: 'Notification', test: 'Test' };\nconst event = $('Webhook').first().json.body;\n\nif (!event || event.schema_version !== 1 ||\n typeof event.event_id !== 'string' || !event.event_id ||\n !Object.hasOwn(messageTypes, event.message_type) ||\n typeof event.text !== 'string') {\n throw new Error('Invalid Message487 event');\n}\n\nconst escapeHtml = value => value.replace(/[&<>]/g, character => ({\n '&': '&', '<': '<', '>': '>',\n})[character]);\nconst firstText = (...values) => values.find(value => typeof value === 'string' && value) || '—';\nconst date = new Date(event.occurred_at);\nconst timestamp = typeof event.occurred_at === 'string' && !Number.isNaN(date.getTime())\n ? new Intl.DateTimeFormat(locale, {\n timeZone, day: '2-digit', month: '2-digit', year: 'numeric',\n hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',\n }).format(date).replace(/,/g, '')\n : '—';\nconst source = firstText(event.sender, event.source_name, event.source);\nconst device = firstText(event.device_code, event.device_id);\nconst segments = [\n { text: 'Message487: ', bold: false },\n { text: source, bold: true },\n { text: '\\n', bold: false },\n { text: `${messageTypes[event.message_type]}\\n${device}\\n${timestamp}`, bold: true },\n { text: '\\n', bold: false },\n { text: typeof event.title === 'string' && event.title ? `${event.title}\\n` : '', bold: false },\n { text: event.text, bold: false },\n];\n\n// Split unescaped text and close formatting in each part to keep HTML valid.\nconst chunks = [];\nlet html = '';\nlet length = 0;\nfor (const segment of segments) {\n let run = '';\n const flush = () => {\n if (run) {\n const escaped = escapeHtml(run);\n html += segment.bold ? `${escaped}` : escaped;\n run = '';\n }\n };\n for (const character of segment.text) {\n if (length + character.length > 3500) {\n flush();\n chunks.push(html);\n html = '';\n length = 0;\n }\n run += character;\n length += character.length;\n }\n flush();\n}\nif (html) chunks.push(html);\n\nreturn chunks.map((part, index) => ({\n json: {\n telegram_text: chunks.length > 1 ? `[${index + 1}/${chunks.length}]\\n${part}` : part,\n },\n}));\n"
+ }
+ },
+ {
+ "id": "0d7d4c27-ed76-5a97-8380-2fe96ec60f17",
+ "name": "Prepare image parts",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 1080,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "// Attach all media to the first text part as one Rich Message.\nconst event = $('Webhook').first().json.body;\nconst images = event.images ?? [];\nif (!Array.isArray(images)) throw new Error('Invalid images collection');\nconst result = $input.all();\nconst gallery = [];\nconst icons = [];\nconst binaryImage = (image, index) => {\n if (!image || !['image/jpeg', 'image/png'].includes(image.mime_type) ||\n typeof image.data_base64 !== 'string' || !image.data_base64.length ||\n image.data_base64.length % 4 !== 0 ||\n !/^[A-Za-z0-9+/]*={0,2}$/.test(image.data_base64)) {\n throw new Error(`Invalid image ${index + 1}`);\n }\n const extension = image.mime_type === 'image/png' ? 'png' : 'jpg';\n const kind = ['attachment', 'avatar', 'app_icon'].includes(image.kind) ? image.kind : 'attachment';\n return { data: image.data_base64, mimeType: image.mime_type, fileName: `${kind}-${index + 1}.${extension}` };\n};\nconst blocks = [];\nconst binary = {};\nfor (const [index, image] of images.entries()) {\n binary[`image${index}`] = binaryImage(image, index);\n const photo = { type: 'photo', photo: { type: 'photo', media: `attach://file${index}` } };\n if (['avatar', 'app_icon'].includes(image.kind)) {\n photo.caption = { text: image.kind === 'avatar' ? 'Avatar' : 'Application icon' };\n icons.push(photo);\n } else if (image.presentation === 'gallery') {\n gallery.push(photo);\n } else if (image.presentation === 'big_picture') {\n blocks.push(photo);\n } else {\n blocks.push({ type: 'document', document: { type: 'document', media: `attach://file${index}` } });\n }\n}\nfor (const photos of [gallery, icons]) {\n if (photos.length) blocks.push(photos.length > 1 ? { type: 'collage', blocks: photos } : photos[0]);\n}\n// Preserve the formatter's bold/italic text without interpreting escaped user HTML.\nif (images.length) {\n const first = result[0];\n if (!first || typeof first.json.telegram_text !== 'string') throw new Error('Missing notification text');\n let bold = false;\n let italic = false;\n const text = [];\n for (const token of first.json.telegram_text.split(/(<\\/?[bi]>)/)) {\n if (token === '' || token === '') { bold = token === ''; continue; }\n if (token === '' || token === '') { italic = token === ''; continue; }\n if (!token) continue;\n let value = token.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');\n if (italic) value = { type: 'italic', text: value };\n if (bold) value = { type: 'bold', text: value };\n text.push(value);\n }\n blocks.unshift({ type: 'paragraph', text });\n first.json.telegram_operation = 'sendRichMessage';\n first.json.rich_message = { blocks };\n first.binary = binary;\n}\nreturn result;\n"
+ }
+ },
+ {
+ "id": "fdae84e7-ef7a-5099-b30e-1b7cfac40624",
+ "name": "Loop Over Parts",
+ "type": "n8n-nodes-base.splitInBatches",
+ "typeVersion": 3,
+ "position": [
+ 1500,
+ 0
+ ],
+ "parameters": {
+ "batchSize": 1,
+ "options": {}
+ }
+ },
+ {
+ "id": "19ede2fa-2fc9-5a7f-88f4-428855adbcfd",
+ "name": "Respond",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.4,
+ "position": [
+ 1720,
+ -200
+ ],
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }}",
+ "options": {
+ "responseCode": 200
+ }
+ }
+ },
+ {
+ "id": "e0d4c284-21fe-5de6-baf2-1ca9d5c04a8a",
+ "name": "Create Part Keys",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 1300,
+ 0
+ ],
+ "parameters": {
+ "jsCode": "const eventId = $('Webhook').first().json.body.event_id;\nreturn $input.all().map((item, index) => ({ ...item, json: { ...item.json, part_key: `${eventId}:${index + 1}` }, pairedItem: { item: index } }));"
+ }
+ },
+ {
+ "id": "55e0c21d-6f21-53de-ba82-097385bed594",
+ "name": "Check already sent",
+ "type": "n8n-nodes-base.dataTable",
+ "typeVersion": 1.1,
+ "position": [
+ 1720,
+ 100
+ ],
+ "parameters": {
+ "resource": "row",
+ "operation": "get",
+ "dataTableId": {
+ "__rl": true,
+ "mode": "id",
+ "value": "={{ $('Configure').first().json.sent_parts_table_id }}"
+ },
+ "matchType": "allConditions",
+ "filters": {
+ "conditions": [
+ {
+ "keyName": "part_key",
+ "condition": "eq",
+ "keyValue": "={{ $json.part_key }}"
+ }
+ ]
+ },
+ "limit": 1
+ },
+ "alwaysOutputData": true
+ },
+ {
+ "id": "96cc7f72-4606-53fc-89a1-b0d82ebee43e",
+ "name": "Already sent",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 1940,
+ 100
+ ],
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict",
+ "version": 2
+ },
+ "conditions": [
+ {
+ "id": "e24f393c-e99c-5abb-925b-f25d8bf7313d",
+ "leftValue": "={{ $json.id !== undefined && $json.id !== null }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true",
+ "singleValue": true
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "ae1a851b-cfe8-5c99-8441-e16ad4a421c2",
+ "name": "Restore pending part",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 2160,
+ 100
+ ],
+ "parameters": {
+ "jsCode": "const item = $('Loop Over Parts').item;\nreturn [{ json: { ...item.json }, ...(item.binary ? { binary: item.binary } : {}) }];"
+ }
+ },
+ {
+ "id": "56cb0cc1-ac17-5d2a-81fb-c7f5ee311b06",
+ "name": "Is rich message",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 2380,
+ 100
+ ],
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict",
+ "version": 2
+ },
+ "conditions": [
+ {
+ "id": "c7e6955a-73ca-51d8-9e0c-584e8fbcf909",
+ "leftValue": "={{ $json.telegram_operation === \"sendRichMessage\" }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true",
+ "singleValue": true
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ }
+ },
+ {
+ "id": "28aba54a-38a2-5e62-9208-760a120930d5",
+ "name": "Send rich message",
+ "type": "CUSTOM.telegramAlbum487",
+ "typeVersion": 1,
+ "position": [
+ 2600,
+ 0
+ ],
+ "parameters": {
+ "operation": "sendRichMessage",
+ "chatId": "={{ $('Configure').first().json.chat_id }}",
+ "binaryProperties": "={{ Object.keys($binary).join(',') }}",
+ "richMessage": "={{ $json.rich_message }}"
+ }
+ },
+ {
+ "id": "7681b85f-bc8e-5bef-b6c7-e1d48a457acc",
+ "name": "Send text",
+ "type": "n8n-nodes-base.telegram",
+ "typeVersion": 1.2,
+ "position": [
+ 2600,
+ 220
+ ],
+ "parameters": {
+ "resource": "message",
+ "operation": "sendMessage",
+ "chatId": "={{ $('Configure').first().json.chat_id }}",
+ "additionalFields": {
+ "appendAttribution": false,
+ "parse_mode": "HTML",
+ "disable_web_page_preview": true
+ },
+ "text": "={{ $json.telegram_text }}"
+ }
+ },
+ {
+ "id": "3e4e6353-df13-5f57-8292-7a770492c1e7",
+ "name": "Remember part key",
+ "type": "n8n-nodes-base.dataTable",
+ "typeVersion": 1.1,
+ "position": [
+ 2820,
+ 100
+ ],
+ "parameters": {
+ "resource": "row",
+ "operation": "upsert",
+ "dataTableId": {
+ "__rl": true,
+ "mode": "id",
+ "value": "={{ $('Configure').first().json.sent_parts_table_id }}"
+ },
+ "matchType": "allConditions",
+ "filters": {
+ "conditions": [
+ {
+ "keyName": "part_key",
+ "condition": "eq",
+ "keyValue": "={{ $('Loop Over Parts').item.json.part_key }}"
+ }
+ ]
+ },
+ "columns": {
+ "mappingMode": "defineBelow",
+ "value": {
+ "part_key": "={{ $('Loop Over Parts').item.json.part_key }}"
+ },
+ "matchingColumns": [
+ "part_key"
+ ],
+ "schema": [
+ {
+ "id": "part_key",
+ "displayName": "part_key",
+ "required": false,
+ "defaultMatch": false,
+ "display": true,
+ "type": "string",
+ "readOnly": false,
+ "removed": false
+ }
+ ],
+ "attemptToConvertTypes": false,
+ "convertFieldsToString": false
+ },
+ "options": {}
+ }
+ }
+ ],
+ "connections": {
+ "Webhook": {
+ "main": [
+ [
+ {
+ "node": "Validate event",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Validate event": {
+ "main": [
+ [
+ {
+ "node": "Valid event",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Valid event": {
+ "main": [
+ [
+ {
+ "node": "Configure",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Reject",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Configure": {
+ "main": [
+ [
+ {
+ "node": "Prepare Telegram text",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Prepare Telegram text": {
+ "main": [
+ [
+ {
+ "node": "Prepare image parts",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Loop Over Parts": {
+ "main": [
+ [
+ {
+ "node": "Respond",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Check already sent",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Prepare image parts": {
+ "main": [
+ [
+ {
+ "node": "Create Part Keys",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Create Part Keys": {
+ "main": [
+ [
+ {
+ "node": "Loop Over Parts",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Check already sent": {
+ "main": [
+ [
+ {
+ "node": "Already sent",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Already sent": {
+ "main": [
+ [
+ {
+ "node": "Loop Over Parts",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Restore pending part",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Restore pending part": {
+ "main": [
+ [
+ {
+ "node": "Is rich message",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Is rich message": {
+ "main": [
+ [
+ {
+ "node": "Send rich message",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Send text",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Send rich message": {
+ "main": [
+ [
+ {
+ "node": "Remember part key",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Send text": {
+ "main": [
+ [
+ {
+ "node": "Remember part key",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Remember part key": {
+ "main": [
+ [
+ {
+ "node": "Loop Over Parts",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1",
+ "timezone": "UTC"
+ }
+}
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index ccc4a88..5b2a41f 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -34,8 +34,9 @@ platform :android do
lane :python_checks do
python = ENV.fetch("PYTHON", "python3")
Dir.chdir(project_root) do
- sh(python, "-m", "isort", "--check-only", "DevServer/tests", "scripts/release_automation.py", "scripts/tests")
- sh(python, "-m", "black", "--check", "DevServer/tests", "scripts/release_automation.py", "scripts/tests")
+ sh(python, "-m", "isort", "--check-only", "DevServer/tests", "scripts/release_automation.py", "scripts/generate_n8n_examples.py", "scripts/tests")
+ sh(python, "-m", "black", "--check", "DevServer/tests", "scripts/release_automation.py", "scripts/generate_n8n_examples.py", "scripts/tests")
+ sh(python, "scripts/generate_n8n_examples.py", "--check")
FileUtils.rm_rf("test-results/python")
sh(python, "-m", "xmlrunner", "discover", "-s", "DevServer/tests", "-o", "test-results/python/dev-server")
sh(python, "-m", "xmlrunner", "discover", "-s", "scripts/tests", "-o", "test-results/python/scripts")
@@ -47,7 +48,7 @@ platform :android do
Dir.chdir(project_root) do
FileUtils.rm_rf("test-results/server")
FileUtils.mkdir_p("test-results/server")
- sh("docker compose -f DevServer/compose.yaml exec -T n8n node --test --test-reporter=junit /bootstrap/tests/telegram-format.test.cjs > test-results/server/telegram.xml")
+ sh("docker compose -f DevServer/compose.yaml exec -T n8n node --test --test-reporter=junit /bootstrap/tests/telegram-format.test.cjs /bootstrap/tests/telegram-images.test.cjs /bootstrap/tests/n8n-examples.test.cjs > test-results/server/telegram.xml")
sh(ENV.fetch("PYTHON", "python3"), "-m", "xmlrunner", "discover", "-s", "DevServer/tests", "-p", "smoke.py", "-o", "test-results/server")
end
end
diff --git a/scripts/generate_n8n_examples.py b/scripts/generate_n8n_examples.py
new file mode 100644
index 0000000..e24c06f
--- /dev/null
+++ b/scripts/generate_n8n_examples.py
@@ -0,0 +1,525 @@
+"""Build portable importable examples from the same scripts used by their tests."""
+
+import argparse
+import json
+import struct
+import uuid
+import zlib
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+OUT = ROOT / 'examples/n8n'
+
+
+def source(name):
+ return (ROOT / 'DevServer' / name).read_text()
+
+
+def public_script(name):
+ return (
+ source(name)
+ .replace("timeZone = 'Europe/Moscow'", "timeZone = 'UTC'")
+ .replace("locale = 'ru-RU'", "locale = 'en-GB'")
+ .replace(
+ "notification: 'Уведомление', test: 'Тест'",
+ "notification: 'Notification', test: 'Test'",
+ )
+ .replace("? 'Аватар' : 'Значок приложения'", "? 'Avatar' : 'Application icon'")
+ )
+
+
+class Workflow:
+ def __init__(self, slug, title):
+ self.slug = slug
+ self.data = {
+ 'name': f'Message487 · {title}',
+ 'active': False,
+ 'nodes': [],
+ 'connections': {},
+ 'settings': {'executionOrder': 'v1', 'timezone': 'UTC'},
+ }
+
+ def add(self, name, kind, parameters, x, y=0, version=2, **extra):
+ node = {
+ 'id': str(
+ uuid.uuid5(uuid.NAMESPACE_URL, f'message487-example:{self.slug}:{name}')
+ ),
+ 'name': name,
+ 'type': kind if '.' in kind else f'n8n-nodes-base.{kind}',
+ 'typeVersion': version,
+ 'position': [x, y],
+ 'parameters': parameters,
+ **extra,
+ }
+ self.data['nodes'].append(node)
+ return node
+
+ def connect(self, name, *branches):
+ self.data['connections'][name] = {
+ 'main': [
+ [{'node': dest, 'type': 'main', 'index': 0} for dest in branch]
+ for branch in branches
+ ]
+ }
+
+ def code(self, name, script, x, y=0):
+ return self.add(name, 'code', {'jsCode': script}, x, y)
+
+ def condition(self, name, expression, x, y=0):
+ return self.add(
+ name,
+ 'if',
+ {
+ 'conditions': {
+ 'options': {
+ 'caseSensitive': True,
+ 'leftValue': '',
+ 'typeValidation': 'strict',
+ 'version': 2,
+ },
+ 'conditions': [
+ {
+ 'id': str(uuid.uuid5(uuid.NAMESPACE_URL, name)),
+ 'leftValue': expression,
+ 'rightValue': True,
+ 'operator': {
+ 'type': 'boolean',
+ 'operation': 'true',
+ 'singleValue': True,
+ },
+ }
+ ],
+ 'combinator': 'and',
+ },
+ 'options': {},
+ },
+ x,
+ y,
+ version=2.2,
+ )
+
+ def configure(self, table=False, chat=True):
+ values = {}
+ if chat:
+ values['chat_id'] = 'REPLACE_WITH_CHAT_ID'
+ if table:
+ values['sent_parts_table_id'] = 'REPLACE_WITH_SENT_PARTS_TABLE_ID'
+ return self.add(
+ 'Configure',
+ 'set',
+ {
+ 'assignments': {
+ 'assignments': [
+ {
+ 'id': str(uuid.uuid5(uuid.NAMESPACE_URL, key)),
+ 'name': key,
+ 'value': value,
+ 'type': 'string',
+ }
+ for key, value in values.items()
+ ]
+ },
+ 'options': {},
+ },
+ 640,
+ version=3.4,
+ )
+
+ def receiver(self, images):
+ self.add(
+ 'Webhook',
+ 'webhook',
+ {
+ 'httpMethod': 'POST',
+ 'path': f'message487/{self.slug}',
+ 'authentication': 'headerAuth',
+ 'responseMode': 'responseNode',
+ 'options': {},
+ },
+ 0,
+ version=2.1,
+ webhookId=str(uuid.uuid5(uuid.NAMESPACE_URL, self.slug)),
+ )
+ self.code(
+ 'Validate event',
+ f'const processImages = {str(images).lower()};\n'
+ + source('workflow-validation.js'),
+ 220,
+ )
+ self.condition('Valid event', '={{ $json.valid }}', 440)
+ self.add(
+ 'Reject',
+ 'respondToWebhook',
+ {
+ 'respondWith': 'json',
+ 'responseBody': '={{ { status: "rejected", event_id: $json.event_id, reason: $json.reason } }}',
+ 'options': {'responseCode': 400},
+ },
+ 640,
+ 300,
+ version=1.4,
+ )
+ self.connect('Webhook', ['Validate event'])
+ self.connect('Validate event', ['Valid event'])
+ self.connect('Valid event', [], ['Reject'])
+
+ def ack(self, x):
+ self.add(
+ 'Respond',
+ 'respondToWebhook',
+ {
+ 'respondWith': 'json',
+ 'responseBody': "={{ { status: 'accepted', event_id: $('Webhook').first().json.body.event_id } }}",
+ 'options': {'responseCode': 200},
+ },
+ x,
+ -200,
+ version=1.4,
+ )
+
+ def telegram(self, name, operation, x, y=0):
+ parameters = {
+ 'resource': 'message',
+ 'operation': operation,
+ 'chatId': "={{ $('Configure').first().json.chat_id }}",
+ 'additionalFields': {},
+ }
+ if operation == 'sendMessage':
+ parameters['text'] = '={{ $json.telegram_text }}'
+ parameters['additionalFields'] = {
+ 'appendAttribution': False,
+ 'parse_mode': 'HTML',
+ 'disable_web_page_preview': True,
+ }
+ else:
+ parameters.update(binaryData=True, binaryPropertyName='data')
+ parameters['additionalFields'] = {'caption': '={{ $json.caption }}'}
+ return self.add(name, 'telegram', parameters, x, y, version=1.2)
+
+
+def table_id():
+ return {
+ '__rl': True,
+ 'mode': 'id',
+ 'value': "={{ $('Configure').first().json.sent_parts_table_id }}",
+ }
+
+
+def examples():
+ result = {}
+ for images, slug, title in [
+ (False, '01-webhook-text', '01 · Webhook text'),
+ (True, '02-webhook-images', '02 · Webhook images'),
+ ]:
+ w = Workflow(slug, title)
+ w.receiver(images)
+ if images:
+ w.code('Read images', source('workflow-images.js'), 640)
+ w.connect('Valid event', ['Read images'], ['Reject'])
+ w.connect('Read images', ['Respond'])
+ else:
+ w.connect('Valid event', ['Respond'], ['Reject'])
+ w.ack(880)
+ result[slug] = w.data
+
+ w = Workflow('03-telegram-text', '03 · Telegram text')
+ w.receiver(False)
+ w.configure()
+ w.code('Prepare Telegram text', public_script('telegram-format.js'), 860)
+ w.telegram('Send text', 'sendMessage', 1080)
+ w.ack(1300)
+ for a, b in [
+ ('Configure', 'Prepare Telegram text'),
+ ('Prepare Telegram text', 'Send text'),
+ ('Send text', 'Respond'),
+ ]:
+ w.connect(a, [b])
+ w.connect('Valid event', ['Configure'], ['Reject'])
+ result[w.slug] = w.data
+
+ for advanced, slug, title in [
+ (False, '04-telegram-images-standard', '04 · Telegram images · standard nodes'),
+ (
+ True,
+ '05-telegram-images-advanced',
+ '05 · Telegram images · rich messages and retry progress',
+ ),
+ ]:
+ w = Workflow(slug, title)
+ w.receiver(True)
+ w.configure(table=advanced)
+ w.code('Prepare Telegram text', public_script('telegram-format.js'), 860)
+ w.code(
+ 'Prepare image parts',
+ (
+ public_script('telegram-images.js')
+ if advanced
+ else source('telegram-standard-images.js')
+ ),
+ 1080,
+ )
+ w.add(
+ 'Loop Over Parts',
+ 'splitInBatches',
+ {'batchSize': 1, 'options': {}},
+ 1500,
+ version=3,
+ )
+ w.ack(1720)
+ w.connect('Valid event', ['Configure'], ['Reject'])
+ w.connect('Configure', ['Prepare Telegram text'])
+ w.connect('Prepare Telegram text', ['Prepare image parts'])
+ w.connect(
+ 'Loop Over Parts',
+ ['Respond'],
+ ['Check already sent' if advanced else 'Is text'],
+ )
+ if advanced:
+ w.code(
+ 'Create Part Keys',
+ "const eventId = $('Webhook').first().json.body.event_id;\nreturn $input.all().map((item, index) => ({ ...item, json: { ...item.json, part_key: `${eventId}:${index + 1}` }, pairedItem: { item: index } }));",
+ 1300,
+ )
+ w.connect('Prepare image parts', ['Create Part Keys'])
+ w.connect('Create Part Keys', ['Loop Over Parts'])
+ filters = {
+ 'conditions': [
+ {
+ 'keyName': 'part_key',
+ 'condition': 'eq',
+ 'keyValue': '={{ $json.part_key }}',
+ }
+ ]
+ }
+ w.add(
+ 'Check already sent',
+ 'dataTable',
+ {
+ 'resource': 'row',
+ 'operation': 'get',
+ 'dataTableId': table_id(),
+ 'matchType': 'allConditions',
+ 'filters': filters,
+ 'limit': 1,
+ },
+ 1720,
+ 100,
+ version=1.1,
+ alwaysOutputData=True,
+ )
+ w.condition(
+ 'Already sent',
+ '={{ $json.id !== undefined && $json.id !== null }}',
+ 1940,
+ 100,
+ )
+ w.code(
+ 'Restore pending part',
+ "const item = $('Loop Over Parts').item;\nreturn [{ json: { ...item.json }, ...(item.binary ? { binary: item.binary } : {}) }];",
+ 2160,
+ 100,
+ )
+ w.condition(
+ 'Is rich message',
+ '={{ $json.telegram_operation === "sendRichMessage" }}',
+ 2380,
+ 100,
+ )
+ w.add(
+ 'Send rich message',
+ 'CUSTOM.telegramAlbum487',
+ {
+ 'operation': 'sendRichMessage',
+ 'chatId': "={{ $('Configure').first().json.chat_id }}",
+ 'binaryProperties': "={{ Object.keys($binary).join(',') }}",
+ 'richMessage': '={{ $json.rich_message }}',
+ },
+ 2600,
+ 0,
+ version=1,
+ )
+ w.telegram('Send text', 'sendMessage', 2600, 220)
+ key = "={{ $('Loop Over Parts').item.json.part_key }}"
+ w.add(
+ 'Remember part key',
+ 'dataTable',
+ {
+ 'resource': 'row',
+ 'operation': 'upsert',
+ 'dataTableId': table_id(),
+ 'matchType': 'allConditions',
+ 'filters': {
+ 'conditions': [
+ {'keyName': 'part_key', 'condition': 'eq', 'keyValue': key}
+ ]
+ },
+ 'columns': {
+ 'mappingMode': 'defineBelow',
+ 'value': {'part_key': key},
+ 'matchingColumns': ['part_key'],
+ 'schema': [
+ {
+ 'id': 'part_key',
+ 'displayName': 'part_key',
+ 'required': False,
+ 'defaultMatch': False,
+ 'display': True,
+ 'type': 'string',
+ 'readOnly': False,
+ 'removed': False,
+ }
+ ],
+ 'attemptToConvertTypes': False,
+ 'convertFieldsToString': False,
+ },
+ 'options': {},
+ },
+ 2820,
+ 100,
+ version=1.1,
+ )
+ w.connect('Check already sent', ['Already sent'])
+ w.connect('Already sent', ['Loop Over Parts'], ['Restore pending part'])
+ w.connect('Restore pending part', ['Is rich message'])
+ w.connect('Is rich message', ['Send rich message'], ['Send text'])
+ for name in ['Send rich message', 'Send text']:
+ w.connect(name, ['Remember part key'])
+ w.connect('Remember part key', ['Loop Over Parts'])
+ else:
+ w.connect('Prepare image parts', ['Loop Over Parts'])
+ w.condition('Is text', '={{ !$json.telegram_operation }}', 1720, 100)
+ w.condition(
+ 'Is photo', '={{ $json.telegram_operation === "sendPhoto" }}', 1940, 220
+ )
+ w.telegram('Send text', 'sendMessage', 2160, 0)
+ w.telegram('Send photo', 'sendPhoto', 2160, 160)
+ w.telegram('Send file', 'sendDocument', 2160, 320)
+ w.connect('Is text', ['Send text'], ['Is photo'])
+ w.connect('Is photo', ['Send photo'], ['Send file'])
+ for name in ['Send text', 'Send photo', 'Send file']:
+ w.connect(name, ['Loop Over Parts'])
+ result[slug] = w.data
+
+ w = Workflow('05-telegram-cleanup', '05 · Optional sent-key cleanup')
+ w.add(
+ 'Daily',
+ 'scheduleTrigger',
+ {
+ 'rule': {
+ 'interval': [{'field': 'cronExpression', 'expression': '0 3 * * *'}]
+ }
+ },
+ 400,
+ version=1.2,
+ )
+ w.configure(table=True, chat=False)
+ w.add(
+ 'Delete old keys',
+ 'dataTable',
+ {
+ 'resource': 'row',
+ 'operation': 'deleteRows',
+ 'dataTableId': table_id(),
+ 'matchType': 'allConditions',
+ 'filters': {
+ 'conditions': [
+ {
+ 'keyName': 'createdAt',
+ 'condition': 'lt',
+ 'keyValue': '={{ $now.minus({ days: 30 }).toISO() }}',
+ }
+ ]
+ },
+ 'options': {'dryRun': True},
+ },
+ 880,
+ version=1.1,
+ )
+ w.connect('Daily', ['Configure'])
+ w.connect('Configure', ['Delete old keys'])
+ result[w.slug] = w.data
+ return result
+
+
+def png(width, height, color):
+ def chunk(kind, data):
+ return (
+ struct.pack('!I', len(data))
+ + kind
+ + data
+ + struct.pack('!I', zlib.crc32(kind + data))
+ )
+
+ return (
+ b'\x89PNG\r\n\x1a\n'
+ + chunk(b'IHDR', struct.pack('!2I5B', width, height, 8, 2, 0, 0, 0))
+ + chunk(b'IDAT', zlib.compress((b'\0' + bytes(color) * width) * height))
+ + chunk(b'IEND', b'')
+ )
+
+
+def payloads():
+ import base64
+
+ event = {
+ 'schema_version': 1,
+ 'event_id': 'example-text-001',
+ 'device_id': 'example-installation',
+ 'device_code': 'example-phone',
+ 'message_type': 'test',
+ 'occurred_at': '2026-01-01T12:00:00Z',
+ 'source': 'org.example.demo',
+ 'source_name': 'Example App',
+ 'text': 'Synthetic connection test',
+ }
+ images = []
+ for kind, presentation, color in [
+ ('attachment', 'big_picture', (45, 90, 180)),
+ ('attachment', 'gallery', (40, 140, 90)),
+ ('attachment', 'gallery', (150, 60, 160)),
+ ('avatar', 'file', (230, 150, 30)),
+ ('app_icon', 'file', (40, 150, 200)),
+ ]:
+ images.append(
+ {
+ 'mime_type': 'image/png',
+ 'width': 64,
+ 'height': 48,
+ 'kind': kind,
+ 'presentation': presentation,
+ 'data_base64': base64.b64encode(png(64, 48, color)).decode(),
+ }
+ )
+ return {
+ 'text': event,
+ 'images': {**event, 'event_id': 'example-images-001', 'images': images},
+ }
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ '--check',
+ action='store_true',
+ help='Fail if committed examples differ from generated ones',
+ )
+ args = parser.parse_args()
+ files = {
+ OUT / 'workflows' / f'{name}.json': data for name, data in examples().items()
+ }
+ files.update(
+ {OUT / 'payloads' / f'{name}.json': data for name, data in payloads().items()}
+ )
+ for path, data in files.items():
+ text = json.dumps(data, ensure_ascii=False, indent=2) + '\n'
+ if args.check:
+ if not path.exists() or path.read_text() != text:
+ raise SystemExit(f'Outdated example: {path.relative_to(ROOT)}')
+ else:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text)
+ print(f'{len(files)} example files {"verified" if args.check else "generated"}')
+
+
+if __name__ == '__main__':
+ main()