My reliable setup for notifications vikunja->node red->ntfy

This is my setup to receive notifications on my Android phone after Vikunja reminders. It is highly customizable, so you can modify it as you like.

Features:

  • push notifications for every reminder at the exact minute
  • reverse the flow to integrate with tasks using the actions below
  • open the task by clicking on the notification
  • “Mark as done" button on the push notification
  • Two configurable snooze options as buttons on the push notification.

With the current setup, notifications are sent only for reminders. With minimal code changes, it can be modified to send static reminders or notifications based on the due date. This setup uses the vikunja REST api.

Prerequisites

Before starting, make sure you have:

  • vikunja instance
  • local Node-RED instance (mine is 4.1.4)
  • local ntfy instance

Vikunja token

  1. Get the Vikunja api key in Profile/Settings/API Tokens menu and create one
  2. copy the token

Node-RED setup:

  • Import this json as a new flow
[
    {
        "id": "67843c252a7fb4f6",
        "type": "tab",
        "label": "Vikunja->ntfy",
        "disabled": false,
        "info": "",
        "env": []
    },
    {
        "id": "d1200662ef0a600b",
        "type": "inject",
        "z": "67843c252a7fb4f6",
        "name": "Poll every x minutes",
        "props": [],
        "repeat": "300",
        "crontab": "",
        "once": true,
        "onceDelay": "2",
        "topic": "",
        "x": 120,
        "y": 220,
        "wires": [
            [
                "91bc86c22cbb7bc6"
            ]
        ]
    },
    {
        "id": "3552ad7cc87caaa5",
        "type": "function",
        "z": "67843c252a7fb4f6",
        "name": "Build Vikunja URL",
        "func": "const config = flow.get(\"config\");\n\nconst pollMinutes = config.pollSeconds / 60;\nmsg.pollMinutes = pollMinutes;\n\nconst filter = `done = false && reminders >= now && reminders < now+${pollMinutes}m`;\n\nmsg.url = `${config.vikunjaBaseUrl}/api/v1/tasks?filter=${encodeURIComponent(filter)}`;\n\nreturn msg;\n",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 390,
        "y": 220,
        "wires": [
            [
                "2df9865c1f465271"
            ]
        ]
    },
    {
        "id": "2df9865c1f465271",
        "type": "http request",
        "z": "67843c252a7fb4f6",
        "name": "Get Tasks",
        "method": "GET",
        "ret": "obj",
        "paytoqs": "ignore",
        "url": "",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "bearer",
        "senderr": false,
        "headers": [
            {
                "keyType": "other",
                "keyValue": "Content-Type",
                "valueType": "other",
                "valueValue": "application/json"
            }
        ],
        "x": 560,
        "y": 220,
        "wires": [
            [
                "7b24ca23e6cfc037"
            ]
        ]
    },
    {
        "id": "7b24ca23e6cfc037",
        "type": "function",
        "z": "67843c252a7fb4f6",
        "name": "Find Reminder + Calculate Delay",
        "func": "const tasks = msg.payload;\nconst pollMinutes = msg.pollMinutes;\n\nif (!Array.isArray(tasks) || tasks.length === 0) {\n    return null;\n}\n\nconst now = new Date();\nconst windowEnd = new Date(now.getTime() + pollMinutes * 60000);\n\nlet messages = [];\n\nfor (let task of tasks) {\n    if (!task.reminders || !Array.isArray(task.reminders)) continue;\n\n    // find first reminder inside window\n    const validReminder = task.reminders.find(r => {\n        const reminderTime = new Date(r.reminder);\n        return reminderTime >= now && reminderTime < windowEnd;\n    });\n\n    if (!validReminder) continue;\n\n    const reminderTime = new Date(validReminder.reminder);\n    const delayMs = reminderTime.getTime() - now.getTime();\n\n    messages.push({\n        delay: delayMs,\n        task: task,\n        reminderTime: reminderTime\n    });\n}\n\nreturn [messages];",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 820,
        "y": 220,
        "wires": [
            [
                "96ce2634c522fd31"
            ]
        ]
    },
    {
        "id": "96ce2634c522fd31",
        "type": "delay",
        "z": "67843c252a7fb4f6",
        "name": "Wait until reminder",
        "pauseType": "delayv",
        "timeout": "1",
        "timeoutUnits": "seconds",
        "rate": "1",
        "nbRateUnits": "1",
        "rateUnits": "second",
        "randomFirst": "1",
        "randomLast": "5",
        "randomUnits": "seconds",
        "drop": false,
        "allowrate": false,
        "outputs": 1,
        "x": 1090,
        "y": 220,
        "wires": [
            [
                "19c0a4ea62a80496"
            ]
        ]
    },
    {
        "id": "19c0a4ea62a80496",
        "type": "function",
        "z": "67843c252a7fb4f6",
        "name": "Format ntfy Message",
        "func": "const crypto = global.get(\"crypto\");\nconst config = flow.get(\"config\");\nconst task = msg.task;\n\nconst taskUrl = `${config.vikunjaBaseUrl}/tasks/${task.id}`;\nconst nowTs = Math.floor(Date.now() / 1000);\n\nfunction buildActionUrl(action) {\n    const data = `${task.id}:${action}:${nowTs}`;\n    const token = crypto\n        .createHmac(\"sha256\", config.actionSecret)\n        .update(data)\n        .digest(\"hex\");\n\n    return `${config.actionBaseUrl}?task=${task.id}&action=${action}&ts=${nowTs}&token=${token}`;\n}\n\nfunction label(minutes) {\n    if (minutes >= 60) {\n        const hours = minutes / 60;\n        return `Snooze ${hours}h`;\n    }\n    return `Snooze ${minutes}m`;\n}\n\nconst doneUrl = buildActionUrl(\"done\");\nconst snooze1Url = buildActionUrl(\"snooze1\");\nconst snooze2Url = buildActionUrl(\"snooze2\");\nconst snooze1Label = label(config.snooze1);\nconst snooze2Label = label(config.snooze2);\n\n\nmsg.url = config.ntfyUrl;\nmsg.method = \"POST\";\n\nmsg.headers = {\n    \"Title\": task.title,\n    \"Click\": taskUrl,\n    \"Actions\":\n        `http, Mark Done, ${doneUrl}, clear=true; ` +\n        `http, ${snooze1Label}, ${snooze1Url}, clear=true; ` +\n        `http, ${snooze2Label}, ${snooze2Url}, clear=true`,\n    \"Content-Type\": \"text/plain\"\n};\n\nconst noDue =\n    !task.due_date ||\n    task.due_date === \"0001-01-01T00:00:00Z\";\n\nif (noDue) {\n    msg.payload = \"\";\n} else {\n    const d = new Date(task.due_date);\n\n    // If date is invalid (just in case)\n    if (isNaN(d.getTime())) {\n        msg.payload = \"\";\n    } else {\n        msg.payload = `Due: ${d.toLocaleString(config.locale)}`;\n    }\n}\n\n\nreturn msg;\n",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 1320,
        "y": 220,
        "wires": [
            [
                "87dddf2f677304cb"
            ]
        ]
    },
    {
        "id": "87dddf2f677304cb",
        "type": "http request",
        "z": "67843c252a7fb4f6",
        "name": "Send to ntfy",
        "method": "use",
        "ret": "txt",
        "paytoqs": "ignore",
        "url": "",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "",
        "senderr": false,
        "headers": [],
        "x": 1570,
        "y": 220,
        "wires": [
            []
        ]
    },
    {
        "id": "91bc86c22cbb7bc6",
        "type": "function",
        "z": "67843c252a7fb4f6",
        "name": "Config",
        "func": "// ===== CENTRAL CONFIG =====\nflow.set(\"config\", {\n    pollSeconds: 300,\n    vikunjaBaseUrl: \"https://vikunja.example.com\",\n    ntfyUrl: \"https://ntfy.example.com/vikunja\",\n\n    // Reverse action endpoint\n    actionBaseUrl: \"https://nodered.example.com/vikunja/action\",\n\n    // HMAC secret (CHANGE THIS)\n    actionSecret: \"SECRETSTRING\",\n\n    // locale for date formatting\n    locale: \"hu-HU\",\n\n    // snooze minutes\n    snooze1: 10,\n    snooze2: 60\n});\n// ===========================\n\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 270,
        "y": 100,
        "wires": [
            [
                "3552ad7cc87caaa5"
            ]
        ],
        "icon": "font-awesome/fa-gears"
    },
    {
        "id": "a61f4a7374d02f2e",
        "type": "http in",
        "z": "67843c252a7fb4f6",
        "name": "ntfy Action Endpoint",
        "url": "/vikunja/action",
        "method": "post",
        "upload": false,
        "skipBodyParsing": false,
        "swaggerDoc": "",
        "x": 130,
        "y": 540,
        "wires": [
            [
                "92c9b3eaf1d58dd9"
            ]
        ]
    },
    {
        "id": "92c9b3eaf1d58dd9",
        "type": "function",
        "z": "67843c252a7fb4f6",
        "name": "Validate Token",
        "func": "const crypto = global.get(\"crypto\");\nconst config = flow.get(\"config\");\n\nconst { task, action, ts, token } = msg.req.query;\n\nif (!task || !action || !ts || !token) {\n    msg.statusCode = 400;\n    msg.payload = \"Missing parameters\";\n    return [null, msg];\n}\n\nconst now = Math.floor(Date.now() / 1000);\nif (now - parseInt(ts) > 3600*24) {\n    msg.statusCode = 403;\n    msg.payload = \"Token expired\";\n    return [null, msg];\n}\n\nconst data = `${task}:${action}:${ts}`;\nconst expected = crypto\n    .createHmac(\"sha256\", config.actionSecret)\n    .update(data)\n    .digest(\"hex\");\n\nif (expected !== token) {\n    msg.statusCode = 403;\n    msg.payload = \"Invalid token\";\n    return [null, msg];\n}\n\nmsg.taskId = task;\nmsg.action = action;\nmsg.url = `${config.vikunjaBaseUrl}/api/v1/tasks/${task}`;\n\nreturn [msg, null];",
        "outputs": 2,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 320,
        "y": 620,
        "wires": [
            [
                "1320e67367a7df40"
            ],
            [
                "f16cf739d4bddd42"
            ]
        ]
    },
    {
        "id": "1320e67367a7df40",
        "type": "http request",
        "z": "67843c252a7fb4f6",
        "name": "GET Task",
        "method": "GET",
        "ret": "obj",
        "paytoqs": "ignore",
        "url": "",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "bearer",
        "senderr": false,
        "headers": [],
        "x": 550,
        "y": 580,
        "wires": [
            [
                "5d44df9c40cb16b8"
            ]
        ]
    },
    {
        "id": "5d44df9c40cb16b8",
        "type": "function",
        "z": "67843c252a7fb4f6",
        "name": "Modify Task",
        "func": "const config = flow.get(\"config\");\nconst task = msg.payload;\n\nif (!task) {\n    msg.statusCode = 500;\n    msg.payload = \"Task not found\";\n    return [null, msg];\n}\n\nif (msg.action === \"done\") {\n    task.done = true;\n}\n\nif (msg.action === \"snooze1\" || msg.action === \"snooze2\") {\n\n    const minutes =\n        msg.action === \"snooze1\"\n            ? config.snooze1\n            : config.snooze2;\n\n    const reminderDate = new Date(Date.now() + minutes * 60000);\n\n    if (!Array.isArray(task.reminders)) {\n        task.reminders = [];\n    }\n\n    task.reminders.push({\n        reminder: reminderDate.toISOString()\n    });\n\n}\n\nmsg.url = `${config.vikunjaBaseUrl}/api/v1/tasks/${msg.taskId}`;\nmsg.payload = task;\n\nreturn [msg, null];",
        "outputs": 2,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 770,
        "y": 580,
        "wires": [
            [
                "3cc1b72b1029616e"
            ],
            [
                "f16cf739d4bddd42"
            ]
        ]
    },
    {
        "id": "3cc1b72b1029616e",
        "type": "http request",
        "z": "67843c252a7fb4f6",
        "name": "POST Task Update",
        "method": "POST",
        "ret": "txt",
        "paytoqs": "ignore",
        "url": "",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "bearer",
        "senderr": false,
        "headers": [
            {
                "keyType": "other",
                "keyValue": "Content-Type",
                "valueType": "other",
                "valueValue": "application/json"
            }
        ],
        "x": 1010,
        "y": 580,
        "wires": [
            [
                "2ed225a118755f1b"
            ]
        ]
    },
    {
        "id": "f16cf739d4bddd42",
        "type": "http response",
        "z": "67843c252a7fb4f6",
        "name": "Return 200",
        "statusCode": "200",
        "headers": {},
        "x": 1330,
        "y": 680,
        "wires": []
    },
    {
        "id": "2ed225a118755f1b",
        "type": "function",
        "z": "67843c252a7fb4f6",
        "name": "Set Ok",
        "func": "msg.payload = \"ok\";\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 1210,
        "y": 580,
        "wires": [
            [
                "f16cf739d4bddd42"
            ]
        ]
    }
]
  • check the “Config” node and adjust the urls
  • open both “GET Tasks” and the “POST Task Update” http request nodes, and paste the vikunja api token to the “Token” part below “bearer authentication”
  • check the “Poll every x minutes” node and set it to the same value as the pollSeconds setting in the config node for the best results
  • Deploy the flow

Ntfy setup

  • create the topic what you set in config node in node-RED for ntfyUrl with “Subscribe to topic” menu (my topiv was “vikunja”
  • on android app, also subscribe to the same topic

That should be it.

2 Likes

This is really nice! Thanks for sharing.

The polling will be easier once we have webhooks for reminders: feat: add user-level webhooks for reminder and overdue events by kolaente · Pull Request #2295 · go-vikunja/vikunja · GitHub

1 Like