diff --git a/AppriseNotification.psm1 b/AppriseNotification.psm1 new file mode 100644 index 0000000..57921c8 --- /dev/null +++ b/AppriseNotification.psm1 @@ -0,0 +1,33 @@ +[string] $appriseWebhookURL = $ENV:APPRISEWEBHOOKURL +[string] $appriseWebhookTag = $ENV:APPRISEWEBHOOKTAG + +function Send-AppriseNotification +{ + param( + # Title of the notification + [Parameter(Mandatory=$true)] + [string] + $title, + + # Content of the notification + [Parameter(Mandatory=$true)] + [string] + $content + ) + + $headers = @{ + "Content-Type"="application/json" + } + $body = @{ + title=$title + body=$content + tags="$appriseWebhookTag" + } + $json = $body | ConvertTo-Json + try { + Invoke-WebRequest -URI $appriseWebhookURL -Method post -Body $json -Headers $headers + } + catch { + Write-Host "Notification Error Encountered: $($global:Error[0])" + } +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e737354..458b630 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,14 +3,21 @@ FROM mcr.microsoft.com/powershell:7.5-ubuntu-24.04 USER root RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ + && apt-get install -y --no-install-recommends tzdata wget \ + && wget -q https://github.com/aptible/supercronic/releases/latest/download/supercronic-linux-amd64 -O /usr/local/bin/supercronic \ + && chmod +x /usr/local/bin/supercronic \ && apt-get dist-upgrade -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* + USER 1000:1000 WORKDIR /data -COPY ["Start-DoneTickNotifier.ps1", "/data/"] +COPY ["crontab", "/data/"] +COPY --chmod=755 ["main.sh", "/data/"] +COPY ["Start-DoneTickNotifier.ps1", "Start-DoneTickConsumer.ps1", "AppriseNotification.psm1", "/data/"] -ENTRYPOINT ["pwsh", "-Command", "/data/Start-DoneTickNotifier.ps1"] +ENTRYPOINT ["/data/main.sh"] + +EXPOSE 8080 \ No newline at end of file diff --git a/README.md b/README.md index 905bf57..a4942e9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,170 @@ # donetick-notifier +> AI disclaimer: AI was used solely for creating this README, the Gitea pipelines, and the base of the webhook consumer. + +A PowerShell-based notifier and webhook consumer for [Donetick](https://github.com/donetick/donetick) chores. + +The project now has two runtime paths: + +- A scheduled notifier that checks the Donetick external API for chores and sends Apprise-compatible summary notifications for overdue and due-today tasks. +- A webhook consumer that listens on port `8080`, accepts Donetick webhook payloads, and turns them into Apprise notifications. + +## Features + +- Fetches chores from `https://:/eapi/v1/chore`. +- Authenticates to Donetick with the `secretkey` header. +- Sends one notification for overdue chores when any exist. +- Sends one notification for chores due today when any exist. +- Accepts webhook events for `task.completed`, `task.reminder`, and `task.created`. +- Falls back to a generic notification for unknown webhook event types. +- Runs the scheduler and webhook listener together in the Docker container. +- Includes Gitea pipelines for Docker image publishing and security scanning. + +## How It Runs + +The container entrypoint is `main.sh`. + +- `supercronic` runs `Start-DoneTickNotifier.ps1` on the schedule defined by `JOB_SCHEDULE`. +- `Start-DoneTickConsumer.ps1` runs in the foreground and listens on `http://+:8080/`. + +The notifier and consumer both use `AppriseNotification.psm1` to send messages to the configured Apprise webhook. + +## Configuration + +All configuration is provided through environment variables. + +| Variable | Required | Description | +| --- | --- | --- | +| `DONETICKHOST` | Yes | Donetick host name or IP address. Do not include `https://`. | +| `DONETICKPORT` | Yes | Donetick HTTPS port. | +| `DONETICKAPIKEY` | Yes | Donetick external API key. Sent as the `secretkey` header. | +| `APPRISEWEBHOOKURL` | Yes | Apprise webhook URL that accepts notification posts. | +| `APPRISEWEBHOOKTAG` | Yes | Apprise tag value to include with each notification. | +| `JOB_SCHEDULE` | Yes for Docker | Cron expression used by the container scheduler, such as `0 8,17 * * *`. | +| `TZ` | No | Container timezone, such as `America/Chicago`. Recommended so notification hours match your local time. | + +## Docker + +The published image is `docker.io/blinkfink182/donetick-notifier`. + +### Run + +```sh +docker run -d \ + --name donetick-notifier \ + -p 8080:8080 \ + -e DONETICKHOST=host.docker.internal \ + -e DONETICKPORT=8787 \ + -e DONETICKAPIKEY=your-donetick-api-key \ + -e APPRISEWEBHOOKURL=https://apprise.example.com/notify/config \ + -e APPRISEWEBHOOKTAG=all \ + -e JOB_SCHEDULE="0 8,17 * * *" \ + -e TZ=America/Chicago \ + docker.io/blinkfink182/donetick-notifier +``` + +### Docker Compose + +```yaml +services: + donetick-notifier: + container_name: donetick-notifier + image: docker.io/blinkfink182/donetick-notifier + ports: + - 8080:8080 + environment: + - DONETICKHOST=host.docker.internal + - DONETICKPORT=8787 + - DONETICKAPIKEY=your-donetick-api-key + - APPRISEWEBHOOKURL=https://apprise.example.com/notify/config + - APPRISEWEBHOOKTAG=all + - JOB_SCHEDULE=0 8,17 * * * + - TZ=America/Chicago +``` + +Run it with: + +```sh +docker compose up -d +``` + +If Donetick and Apprise are on the same Docker network, use service names instead of `host.docker.internal`. + +```yaml +services: + donetick-notifier: + image: docker.io/blinkfink182/donetick-notifier + ports: + - 8080:8080 + environment: + - DONETICKHOST=donetick + - DONETICKPORT=8787 + - DONETICKAPIKEY=your-donetick-api-key + - APPRISEWEBHOOKURL=http://apprise:8000/notify/config + - APPRISEWEBHOOKTAG=all + - JOB_SCHEDULE=0 8,17 * * * + - TZ=America/Chicago +``` + +## Build Locally + +```sh +docker build -t donetick-notifier . +``` + +```sh +docker run -d \ + --name donetick-notifier \ + -p 8080:8080 \ + -e DONETICKHOST=host.docker.internal \ + -e DONETICKPORT=8787 \ + -e DONETICKAPIKEY=your-donetick-api-key \ + -e APPRISEWEBHOOKURL=https://apprise.example.com/notify/config \ + -e APPRISEWEBHOOKTAG=all \ + -e JOB_SCHEDULE="0 8,17 * * *" \ + -e TZ=America/Chicago \ + donetick-notifier +``` + +## Run Without Docker + +PowerShell 7 or newer is recommended. + +Scheduled notifier: + +```powershell +$env:DONETICKHOST = "donetick.example.com" +$env:DONETICKPORT = "8787" +$env:DONETICKAPIKEY = "your-donetick-api-key" +$env:APPRISEWEBHOOKURL = "https://apprise.example.com/notify/config" +$env:APPRISEWEBHOOKTAG = "all" + +pwsh ./Start-DoneTickNotifier.ps1 +``` + +Webhook consumer: + +```powershell +$env:APPRISEWEBHOOKURL = "https://apprise.example.com/notify/config" +$env:APPRISEWEBHOOKTAG = "all" + +pwsh ./Start-DoneTickConsumer.ps1 +``` + +The consumer listens on port `8080` by default. Stop either script with `Ctrl+C` when running interactively. + +## CI/CD + +This repository includes Gitea workflows for: + +- Building and pushing the Docker image on manual dispatch. +- Tagging images as `latest` for `main`, as the ref name for `v*` tags, and as `test` for everything else. +- Running security checks with Gitleaks, Semgrep, and Trivy. +- Creating Gitea issues for security findings when `GITEA_TOKEN` is configured. +- Sending Apprise notifications for Docker build success or failure. + +The security workflow runs on pushes, pull requests, and manual dispatch. On push, it also builds the image so Trivy can scan the resulting container. + +## API References + +The `Donetick/` directory contains request examples and collection metadata used to document and exercise the Donetick API while developing the notifier. diff --git a/Start-DoneTickConsumer.ps1 b/Start-DoneTickConsumer.ps1 new file mode 100644 index 0000000..85cf29c --- /dev/null +++ b/Start-DoneTickConsumer.ps1 @@ -0,0 +1,152 @@ +[string] $ListenUrl = "http://+:8080/" + +Import-Module ./AppriseNotification.psm1 + +function Write-JsonResponse +{ + param( + [Parameter(Mandatory=$true)] + [System.Net.HttpListenerResponse] + $Response, + + [Parameter(Mandatory=$true)] + [int] + $StatusCode, + + [Parameter(Mandatory=$true)] + [hashtable] + $Body + ) + + $json = $Body | ConvertTo-Json -Depth 10 + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + + $Response.StatusCode = $StatusCode + $Response.ContentType = "application/json" + $Response.ContentEncoding = [System.Text.Encoding]::UTF8 + $Response.ContentLength64 = $bytes.Length + $Response.OutputStream.Write($bytes, 0, $bytes.Length) + $Response.OutputStream.Close() +} + +function Read-RequestBody +{ + param( + [Parameter(Mandatory=$true)] + [System.Net.HttpListenerRequest] + $Request + ) + + $reader = [System.IO.StreamReader]::new($Request.InputStream, $Request.ContentEncoding) + try { + return $reader.ReadToEnd() + } + finally { + $reader.Close() + } +} + +function Receive-Webhook +{ + param( + [Parameter(Mandatory=$true)] + [System.Net.HttpListenerContext] + $Context + ) + + $request = $Context.Request + $response = $Context.Response + + if ($request.HttpMethod -ne "POST") { + Write-JsonResponse -Response $response -StatusCode 405 -Body @{ + ok = $false + error = "Only POST requests are supported." + } + return + } + + $rawBody = Read-RequestBody -Request $request + $payload = $null + + try { + if ($rawBody) { + $payload = $rawBody | ConvertFrom-Json + } + } + catch { + Write-JsonResponse -Response $response -StatusCode 400 -Body @{ + ok = $false + error = "Request body was not valid JSON." + } + return + } + + Write-Host "Webhook received at $(Get-Date -Format o)" + Write-Host "From: $($request.RemoteEndPoint)" + Write-Host "Path: $($request.Url.AbsolutePath)" + + if ($payload) { + Write-Host "Payload:" + Write-Host ($payload | ConvertTo-Json -Depth 20) + } + else { + Write-Host "Payload: " + } + + $notificationTitle = "" + $notificationContent = "" + switch($payload.type) + { + "task.completed" { + $notificationTitle = "Donetick Task Completed" + $notificationContent = "$($payload.data.chore.name) marked completed!" + } + "task.reminder" { + $notificationTitle = "Donetick Task Reminder" + $notificationContent = "$($payload.data.name) is due at $((Get-Date $payload.data.due_date).ToLocalTime())" + } + "task.created" { + $notificationTitle = "Donetick Task Created" + if ($null -eq $payload.data.chore.nextDueDate) + { + $notificationContent = "$($payload.data.chore.name) created" + } + else + { + $notificationContent = "$($payload.data.chore.name) created with due date of $((Get-Date $payload.data.chore.nextDueDate).ToLocalTime())" + } + } + default { + $notificationTitle = "Donetick Notification" + $notificationContent = "Donetick event of type $($payload.type) received" + } + } + Send-AppriseNotification -title $notificationTitle -content $notificationContent + + Write-JsonResponse -Response $response -StatusCode 200 -Body @{ + ok = $true + message = "Webhook accepted." + receivedAt = (Get-Date -Format o) + } +} + +$listener = [System.Net.HttpListener]::new() +$listener.Prefixes.Add($ListenUrl) + +try { + $listener.Start() + Write-Host "Webhook consumer listening on $ListenUrl" + Write-Host "Press Ctrl+C to stop." + + while ($listener.IsListening) { + $context = $listener.GetContext() + Receive-Webhook -Context $context + } +} +finally { + if ($listener.IsListening) { + $listener.Stop() + } + + $listener.Close() +} diff --git a/Start-DoneTickNotifier.ps1 b/Start-DoneTickNotifier.ps1 index 4e8918e..db0f3c7 100644 --- a/Start-DoneTickNotifier.ps1 +++ b/Start-DoneTickNotifier.ps1 @@ -1,40 +1,8 @@ [string] $dtHost = $ENV:DONETICKHOST [string] $dtPort = $ENV:DONETICKPORT [string] $dtAPIKey = $ENV:DONETICKAPIKEY -[string] $appriseWebhookURL = $ENV:APPRISEWEBHOOKURL -[string] $appriseWebhookTag = $ENV:APPRISEWEBHOOKTAG - -function Send-Notification -{ - param( - # Title of the notification - [Parameter(Mandatory=$true)] - [string] - $title, - - # Content of the notification - [Parameter(Mandatory=$true)] - [string] - $content - ) - - $headers = @{ - "Content-Type"="application/json" - } - $body = @{ - title=$title - body=$content - tags="$appriseWebhookTag" - } - $json = $body | ConvertTo-Json - try { - Invoke-WebRequest -URI $appriseWebhookURL -Method post -Body $json -Headers $headers - } - catch { - Write-Host "Notification Error Encountered: $($global:Error[0])" - } -} +Import-Module ./AppriseNotification.psm1 function Get-Chores { @@ -60,7 +28,7 @@ foreach($chore in $chores) { if ($chore.nextDueDate) { - $dueDate = Get-Date $chore.nextDueDate + $dueDate = (Get-Date $chore.nextDueDate).ToLocalTime() if (($dueDate - $today).Days -lt 0) #OVERDUE { write-host "$($chore.name) $dueDate is overdue!" @@ -70,7 +38,6 @@ foreach($chore in $chores) { write-host "$($chore.name) $dueDate is due today!" $todaysTasks += $chore - # Send-Notification -title "TASK DUE TODAY" -content "$($chore.Name) is due today!" } else { @@ -86,9 +53,9 @@ if ($overdueTasks.Count -ne 0) $content = "The following tasks are overdue!`n" foreach($overdueTask in $overdueTasks) { - $content += "$($overdueTask.Name) was due $(Get-Date $overdueTask.nextDueDate -Format "MM/dd/yyyy HH:mm")`n" + $content += "$($overdueTask.Name) was due $((Get-Date $overdueTask.nextDueDate).ToLocalTime())`n" } - Send-Notification -title "OVERDUE TASKS" -content $content + Send-AppriseNotification -title "OVERDUE TASKS" -content $content } if ($todaysTasks.Count -ne 0) @@ -97,7 +64,7 @@ if ($todaysTasks.Count -ne 0) $content = "The following tasks are due today!`n" foreach($task in $todaysTasks) { - $content += "$($task.Name) is due $(Get-Date $task.nextDueDate -Format "MM/dd/yyyy HH:mm")`n" + $content += "$($task.Name) is due $((Get-Date $task.nextDueDate).ToLocalTime())`n" } - Send-Notification -title "TODAY'S TASKS" -content $content + Send-AppriseNotification -title "TODAY'S TASKS" -content $content } \ No newline at end of file diff --git a/crontab b/crontab new file mode 100644 index 0000000..4c6e11a --- /dev/null +++ b/crontab @@ -0,0 +1,2 @@ +# Default schedule (overridden at runtime by JOB_SCHEDULE env var) +0 * * * * pwsh /data/Start-DoneTickNotifier.ps1 \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index ef401c0..609c08f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,10 +3,14 @@ services: donetick-notifier: container_name: donetick-notifier image: docker.io/blinkfink182/donetick-notifier + ports: + - 8080:8080 environment: # BELOW ARE ALL REQUIRED - DONETICKHOST=host.docker.internal # donetick host - DONETICKPORT=8787 # donetick port - DONETICKAPIKEY=adminpass # donetick API key - APPRISEWEBHOOKURL=https://apprisehost/notify/config # apprise notification url - - APPRISEWEBHOOKTAG=all # apprise notification tag \ No newline at end of file + - APPRISEWEBHOOKTAG=all # apprise notification tag + - TZ=America/Chicago # set timezone from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List + - JOB_SCHEDULE=0 8,17 * * * # when to notify about overdue tasks or tasks due today \ No newline at end of file diff --git a/main.sh b/main.sh new file mode 100644 index 0000000..43e3a4f --- /dev/null +++ b/main.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +# Build the crontab dynamically from the env variable +echo "${JOB_SCHEDULE} pwsh /data/Start-DoneTickNotifier.ps1" > /tmp/crontab-runtime + +echo "Cron schedule set to: ${JOB_SCHEDULE}" + +# Start supercronic in background using the generated crontab +supercronic /tmp/crontab-runtime & + +# Start the HTTP listener in foreground +exec pwsh /data/Start-DoneTickConsumer.ps1 \ No newline at end of file