feat(webhook): Send updated entries to webhook on feed refresh#4235
feat(webhook): Send updated entries to webhook on feed refresh#4235igortheclaw wants to merge 2 commits into
Conversation
When Miniflux refreshes a feed and existing entries have their content updated (title, body, author, etc.), those changes are now forwarded to the configured webhook via a new `updated_entries` event type. Previously, `RefreshFeedEntries` only returned newly-created entries to the caller, so `PushEntries` (and therefore the webhook) was never invoked for existing entries that had changed. This caused webhook-based enrichment pipelines to lose their enriched content on every refresh cycle, because Miniflux silently overwrote the enriched fields in the DB but never gave the webhook a chance to re-apply the enrichment. Changes: - storage/entry.go: RefreshFeedEntries now returns two slices: `newEntries` (created for the first time) and `updatedEntries` (pre-existing entries whose content changed). - integration/integration.go: new PushUpdatedEntries() function that notifies integrations about content-changed entries. Notification channels (Telegram, Pushover, Ntfy, Apprise, Discord, Slack, …) are intentionally excluded to avoid re-notifying users about entries they have already seen; only the webhook is invoked. - integration/webhook/webhook.go: new `updated_entries` event type, WebhookUpdatedEntriesEvent struct, and SendUpdatedEntriesWebhookEvent() method, allowing webhook consumers to distinguish between new-entry events and content-update events. - reader/handler/handler.go: RefreshFeed now captures the updatedEntries slice and spawns PushUpdatedEntries in a goroutine alongside the existing PushEntries call.
Tests for PushUpdatedEntries (integration package):
- TestPushUpdatedEntriesLogsWebhookAttempt: verifies the webhook is
called when WebhookEnabled is true.
- TestPushUpdatedEntriesSkipsNotificationIntegrations: verifies that
Telegram, Ntfy, Pushover, Discord, Slack, Matrix and Apprise are not
invoked — only the webhook is triggered for content updates.
- TestPushUpdatedEntriesNoEntriesIsNoop: verifies early exit when the
entries slice is empty.
Tests for SendUpdatedEntriesWebhookEvent (webhook package):
- TestSendUpdatedEntriesWebhookEventType: verifies the JSON payload
contains event_type = "updated_entries".
- TestSendUpdatedEntriesWebhookEventTypeHeader: verifies the
X-Miniflux-Event-Type request header is set correctly.
- TestSendUpdatedEntriesWebhookNoEntriesIsNoop: verifies no HTTP call
is made for empty entries.
- TestSendNewEntriesWebhookEventType: companion test confirming the
existing new_entries path is unaffected.
|
This pull-request is obviously vibe-coded. The main issues are:
|
There was a problem hiding this comment.
Pull request overview
Adds a new webhook event (updated_entries) to notify webhook consumers when existing feed entries are updated during refresh, without re-triggering user-facing notification channels.
Changes:
- Update
Storage.RefreshFeedEntriesto return both newly created entries and updated existing entries. - Add
integration.PushUpdatedEntriesto invoke webhook delivery for updated entries only. - Extend the webhook client with
updated_entriespayload/header support and add unit tests.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/storage/entry.go | Returns updatedEntries alongside newEntries during refresh. |
| internal/reader/handler/handler.go | Triggers PushUpdatedEntries alongside existing PushEntries calls. |
| internal/integration/integration.go | Adds PushUpdatedEntries that targets only the webhook integration. |
| internal/integration/webhook/webhook.go | Adds UpdatedEntriesEventType and webhook sending/payload struct for updated entries. |
| internal/integration/integration_test.go | Adds tests ensuring PushUpdatedEntries only targets webhook + handles empty slices. |
| internal/integration/webhook/webhook_test.go | New tests for updated_entries event type in JSON payload and header. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| err = s.updateEntry(tx, entry) | ||
| if err == nil { |
There was a problem hiding this comment.
updatedEntries is appended for every existing entry whenever updateExistingEntries is true, but updateEntry() currently runs an unconditional UPDATE and returns nil even if the incoming feed data is identical. This makes the new updated_entries webhook fire on every refresh for all existing entries, contradicting the function comment/PR intent (“content changed”). Consider changing the UPDATE to only match when at least one tracked column IS DISTINCT FROM the incoming value (or compare against the stored row first), and only append to updatedEntries when a real change occurred.
| err = s.updateEntry(tx, entry) | |
| if err == nil { | |
| var changed bool | |
| changed, err = s.updateEntry(tx, entry) | |
| if err == nil && changed { |
| err = s.updateEntry(tx, entry) | ||
| if err == nil { | ||
| updatedEntries = append(updatedEntries, entry) | ||
| } |
There was a problem hiding this comment.
The entries appended to updatedEntries are the parsed feed entries; for existing rows updateEntry() only scans id and does not populate persisted fields like status, created_at, changed_at, share_code, or starred. Because these entries are later sent to the webhook, the payload will likely contain zero/empty values for those fields (e.g., blank status, zero timestamps, starred=false). To keep the webhook payload accurate, consider returning/scanning the needed columns in updateEntry() (UPDATE … RETURNING status, created_at, changed_at, starred, share_code, …) or fetching the full entry from the DB before enqueueing it.
| webhookEntries = append(webhookEntries, &WebhookEntry{ | ||
| ID: entry.ID, | ||
| UserID: entry.UserID, | ||
| FeedID: entry.FeedID, | ||
| Status: entry.Status, | ||
| Hash: entry.Hash, | ||
| Title: entry.Title, | ||
| URL: entry.URL, | ||
| CommentsURL: entry.CommentsURL, | ||
| Date: entry.Date, | ||
| CreatedAt: entry.CreatedAt, | ||
| ChangedAt: entry.ChangedAt, | ||
| Content: entry.Content, | ||
| Author: entry.Author, | ||
| ShareCode: entry.ShareCode, | ||
| Starred: entry.Starred, | ||
| ReadingTime: entry.ReadingTime, | ||
| Enclosures: entry.Enclosures, | ||
| Tags: entry.Tags, | ||
| }) |
There was a problem hiding this comment.
SendUpdatedEntriesWebhookEvent() builds the webhook payload using fields like Status, CreatedAt, ChangedAt, ShareCode, and Starred from the provided entries. For updated entries coming from feed parsing/refresh, these fields are typically unset (model.NewEntry leaves them as zero values), so the updated_entries payload can be misleading (blank status, zero timestamps, etc.). Consider ensuring the caller passes fully-hydrated entries from the DB (or adjust the storage refresh/update path to RETURNING/scan these columns) before constructing the webhook payload.
Description
When Miniflux refreshes a feed, existing entries whose content has changed (title, body, author, etc.) are updated in the database but the webhook integration is never invoked for them. Only newly-created entries trigger
PushEntries.This PR adds a
PushUpdatedEntriesfunction that fires the webhook with anupdated_entriesevent type wheneverRefreshFeedEntrieswrites changes to pre-existing entries, giving webhook consumers the opportunity to react to content updates.Motivation
Webhook-based pipelines that enrich entry content (e.g. adding live stats to the title or body) lose their changes on every feed refresh cycle: Miniflux overwrites the enriched fields with the original RSS content, and without this fix the webhook has no chance to re-apply the enrichment.
Only the webhook is triggered for updated entries — notification channels (Telegram, Pushover, Ntfy, Apprise, Discord, Slack, Matrix) are intentionally excluded to avoid re-notifying users about entries they have already seen.
Changes
internal/storage/entry.go—RefreshFeedEntriesnow returns two slices:newEntries(entries created for the first time, unchanged behaviour) andupdatedEntries(pre-existing entries whose content was rewritten). The function has a single caller (RefreshFeed), which has been updated accordingly.internal/integration/integration.go— NewPushUpdatedEntries()function. MirrorsPushEntriesfor the webhook only; notification channels are intentionally skipped.internal/integration/webhook/webhook.go— New constantUpdatedEntriesEventType = "updated_entries",WebhookUpdatedEntriesEventstruct, andSendUpdatedEntriesWebhookEvent()method. Consumers can use theevent_typefield (or theX-Miniflux-Event-Typeheader) to distinguish content-update events from new-entry events.internal/reader/handler/handler.go—RefreshFeedcaptures the newupdatedEntriesreturn value and spawnsPushUpdatedEntriesin a goroutine alongside the existingPushEntriescall.Testing
New unit tests added:
internal/integration/integration_test.goTestPushUpdatedEntriesLogsWebhookAttempt— webhook is called when enabledTestPushUpdatedEntriesSkipsNotificationIntegrations— Telegram/Ntfy/Pushover/Discord/Slack/Matrix/Apprise are not invokedTestPushUpdatedEntriesNoEntriesIsNoop— exits immediately for empty entriesinternal/integration/webhook/webhook_test.go(new file)TestSendUpdatedEntriesWebhookEventType— JSON payload containsevent_type: "updated_entries"TestSendUpdatedEntriesWebhookEventTypeHeader—X-Miniflux-Event-Typeheader is set correctlyTestSendUpdatedEntriesWebhookNoEntriesIsNoop— no HTTP call for empty entriesTestSendNewEntriesWebhookEventType— companion test confirming existingnew_entriespath is unaffectedAll tests pass:
Breaking Changes
None.
RefreshFeedEntriesis an internal function with a single caller that has been updated in this PR. The webhook payload structure fornew_entriesandsave_entryevents is unchanged. The newupdated_entriesevent is additive.Have you followed these guidelines?