forked from Archives/Athou_commafeed
feat: send notification for new entries with Gotify, ntfy or Pushover, configurable per feed.
This commit is contained in:
2
commafeed-client/package-lock.json
generated
2
commafeed-client/package-lock.json
generated
@@ -6310,7 +6310,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const createFeed = (id: number, unread: number): Subscription => ({
|
|||||||
feedUrl: "",
|
feedUrl: "",
|
||||||
feedLink: "",
|
feedLink: "",
|
||||||
iconUrl: "",
|
iconUrl: "",
|
||||||
|
notifyOnNewEntries: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const root = createCategory("root")
|
const root = createCategory("root")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface Subscription {
|
|||||||
newestItemTime?: number
|
newestItemTime?: number
|
||||||
filter?: string
|
filter?: string
|
||||||
filterLegacy?: string
|
filterLegacy?: string
|
||||||
|
notifyOnNewEntries: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@@ -110,6 +111,7 @@ export interface FeedModificationRequest {
|
|||||||
categoryId?: string
|
categoryId?: string
|
||||||
position?: number
|
position?: number
|
||||||
filter?: string
|
filter?: string
|
||||||
|
notifyOnNewEntries?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetEntriesRequest {
|
export interface GetEntriesRequest {
|
||||||
@@ -249,6 +251,17 @@ export interface SharingSettings {
|
|||||||
buffer: boolean
|
buffer: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NotificationService = "disabled" | "ntfy" | "gotify" | "pushover"
|
||||||
|
|
||||||
|
export interface NotificationSettings {
|
||||||
|
enabled: boolean
|
||||||
|
type?: Exclude<NotificationService, "disabled">
|
||||||
|
serverUrl?: string
|
||||||
|
token?: string
|
||||||
|
userKey?: string
|
||||||
|
topic?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
language?: string
|
language?: string
|
||||||
readingMode: ReadingMode
|
readingMode: ReadingMode
|
||||||
@@ -271,6 +284,7 @@ export interface Settings {
|
|||||||
disablePullToRefresh: boolean
|
disablePullToRefresh: boolean
|
||||||
primaryColor?: string
|
primaryColor?: string
|
||||||
sharingSettings: SharingSettings
|
sharingSettings: SharingSettings
|
||||||
|
notificationSettings: NotificationSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalSettings {
|
export interface LocalSettings {
|
||||||
@@ -290,6 +304,7 @@ export interface SubscribeRequest {
|
|||||||
url: string
|
url: string
|
||||||
title: string
|
title: string
|
||||||
categoryId?: string
|
categoryId?: string
|
||||||
|
notifyOnNewEntries: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagRequest {
|
export interface TagRequest {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
changeMarkAllAsReadConfirmation,
|
changeMarkAllAsReadConfirmation,
|
||||||
changeMarkAllAsReadNavigateToUnread,
|
changeMarkAllAsReadNavigateToUnread,
|
||||||
changeMobileFooter,
|
changeMobileFooter,
|
||||||
|
changeNotificationSettings,
|
||||||
changePrimaryColor,
|
changePrimaryColor,
|
||||||
changeReadingMode,
|
changeReadingMode,
|
||||||
changeReadingOrder,
|
changeReadingOrder,
|
||||||
@@ -148,6 +149,13 @@ export const userSlice = createSlice({
|
|||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
||||||
})
|
})
|
||||||
|
builder.addCase(changeNotificationSettings.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.notificationSettings = {
|
||||||
|
...state.settings.notificationSettings,
|
||||||
|
...action.meta.arg,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
builder.addMatcher(
|
builder.addMatcher(
|
||||||
isAnyOf(
|
isAnyOf(
|
||||||
@@ -167,7 +175,8 @@ export const userSlice = createSlice({
|
|||||||
changeUnreadCountFavicon.fulfilled,
|
changeUnreadCountFavicon.fulfilled,
|
||||||
changeDisablePullToRefresh.fulfilled,
|
changeDisablePullToRefresh.fulfilled,
|
||||||
changePrimaryColor.fulfilled,
|
changePrimaryColor.fulfilled,
|
||||||
changeSharingSetting.fulfilled
|
changeSharingSetting.fulfilled,
|
||||||
|
changeNotificationSettings.fulfilled
|
||||||
),
|
),
|
||||||
() => {
|
() => {
|
||||||
showNotification({
|
showNotification({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createAppAsyncThunk } from "@/app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "@/app/client"
|
import { client } from "@/app/client"
|
||||||
import { reloadEntries } from "@/app/entries/thunks"
|
import { reloadEntries } from "@/app/entries/thunks"
|
||||||
import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "@/app/types"
|
import type { IconDisplayMode, NotificationSettings, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "@/app/types"
|
||||||
|
|
||||||
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
|
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
|
||||||
|
|
||||||
@@ -157,3 +157,18 @@ export const changeSharingSetting = createAppAsyncThunk(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const changeNotificationSettings = createAppAsyncThunk(
|
||||||
|
"settings/notificationSettings",
|
||||||
|
(notificationUpdate: Partial<NotificationSettings>, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({
|
||||||
|
...settings,
|
||||||
|
notificationSettings: {
|
||||||
|
...settings.notificationSettings,
|
||||||
|
...notificationUpdate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core"
|
import { Box, Button, Checkbox, Group, Stack, Stepper, TextInput } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
@@ -28,6 +28,7 @@ export function Subscribe() {
|
|||||||
url: "",
|
url: "",
|
||||||
title: "",
|
title: "",
|
||||||
categoryId: Constants.categories.all.id,
|
categoryId: Constants.categories.all.id,
|
||||||
|
notifyOnNewEntries: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,6 +104,10 @@ export function Subscribe() {
|
|||||||
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
||||||
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
|
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
|
||||||
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
|
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
|
||||||
|
<Checkbox
|
||||||
|
label={<Trans>Receive notifications</Trans>}
|
||||||
|
{...step1Form.getInputProps("notifyOnNewEntries", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { Divider, Select, Stack, TextInput } from "@mantine/core"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { NotificationService as NotificationServiceType, NotificationSettings as NotificationSettingsType } from "@/app/types"
|
||||||
|
import { changeNotificationSettings } from "@/app/user/thunks"
|
||||||
|
|
||||||
|
function useDebouncedSave(value: string, settingsKey: string, dispatch: ReturnType<typeof useAppDispatch>) {
|
||||||
|
const [localValue, setLocalValue] = useState(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const onBlur = async () => {
|
||||||
|
if (localValue !== value) {
|
||||||
|
await dispatch(changeNotificationSettings({ [settingsKey]: localValue }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { localValue, setLocalValue, onBlur }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toServiceValue(settings?: NotificationSettingsType): NotificationServiceType {
|
||||||
|
if (settings?.enabled && settings.type) {
|
||||||
|
return settings.type
|
||||||
|
}
|
||||||
|
return "disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationSettings() {
|
||||||
|
const notificationSettings = useAppSelector(state => state.user.settings?.notificationSettings)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const serviceValue = toServiceValue(notificationSettings)
|
||||||
|
|
||||||
|
const serverUrl = useDebouncedSave(notificationSettings?.serverUrl ?? "", "serverUrl", dispatch)
|
||||||
|
const token = useDebouncedSave(notificationSettings?.token ?? "", "token", dispatch)
|
||||||
|
const userKey = useDebouncedSave(notificationSettings?.userKey ?? "", "userKey", dispatch)
|
||||||
|
const topic = useDebouncedSave(notificationSettings?.topic ?? "", "topic", dispatch)
|
||||||
|
|
||||||
|
const onServiceChange = async (value: string | null) => {
|
||||||
|
if (value === "disabled" || !value) {
|
||||||
|
await dispatch(changeNotificationSettings({ enabled: false, type: undefined }))
|
||||||
|
} else {
|
||||||
|
await dispatch(changeNotificationSettings({ enabled: true, type: value as Exclude<NotificationServiceType, "disabled"> }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Divider
|
||||||
|
label={
|
||||||
|
<Trans>
|
||||||
|
<b>Notifications</b>
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Notification service</Trans>}
|
||||||
|
data={[
|
||||||
|
{ value: "disabled", label: "Disabled" },
|
||||||
|
{ value: "ntfy", label: "ntfy" },
|
||||||
|
{ value: "gotify", label: "Gotify" },
|
||||||
|
{ value: "pushover", label: "Pushover" },
|
||||||
|
]}
|
||||||
|
value={serviceValue}
|
||||||
|
onChange={onServiceChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{serviceValue === "ntfy" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Server URL</Trans>}
|
||||||
|
placeholder="https://ntfy.sh"
|
||||||
|
value={serverUrl.localValue}
|
||||||
|
onChange={e => serverUrl.setLocalValue(e.currentTarget.value)}
|
||||||
|
onBlur={serverUrl.onBlur}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Topic</Trans>}
|
||||||
|
placeholder="commafeed"
|
||||||
|
value={topic.localValue}
|
||||||
|
onChange={e => topic.setLocalValue(e.currentTarget.value)}
|
||||||
|
onBlur={topic.onBlur}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Access token (optional)</Trans>}
|
||||||
|
value={token.localValue}
|
||||||
|
onChange={e => token.setLocalValue(e.currentTarget.value)}
|
||||||
|
onBlur={token.onBlur}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{serviceValue === "gotify" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Server URL</Trans>}
|
||||||
|
placeholder="https://gotify.example.com"
|
||||||
|
value={serverUrl.localValue}
|
||||||
|
onChange={e => serverUrl.setLocalValue(e.currentTarget.value)}
|
||||||
|
onBlur={serverUrl.onBlur}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>App token</Trans>}
|
||||||
|
value={token.localValue}
|
||||||
|
onChange={e => token.setLocalValue(e.currentTarget.value)}
|
||||||
|
onBlur={token.onBlur}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{serviceValue === "pushover" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>API token</Trans>}
|
||||||
|
value={token.localValue}
|
||||||
|
onChange={e => token.setLocalValue(e.currentTarget.value)}
|
||||||
|
onBlur={token.onBlur}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>User key</Trans>}
|
||||||
|
value={userKey.localValue}
|
||||||
|
onChange={e => userKey.setLocalValue(e.currentTarget.value)}
|
||||||
|
onBlur={userKey.onBlur}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Code,
|
Code,
|
||||||
Container,
|
Container,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -32,6 +33,38 @@ import { FilteringExpressionEditor } from "@/components/content/edit/FilteringEx
|
|||||||
import { Loader } from "@/components/Loader"
|
import { Loader } from "@/components/Loader"
|
||||||
import { RelativeDate } from "@/components/RelativeDate"
|
import { RelativeDate } from "@/components/RelativeDate"
|
||||||
|
|
||||||
|
function FilteringExpressionDescription() {
|
||||||
|
const example = <Code>url.contains('youtube') or (author eq 'athou' and title.contains('github'))</Code>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Trans>
|
||||||
|
If not empty, an expression evaluating to 'true' or 'false'. If false, new entries for this feed will be marked as read
|
||||||
|
automatically.
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Trans>
|
||||||
|
Available variables are 'title', 'content', 'url' 'author' and 'categories' and their content is converted to lower case
|
||||||
|
to ease string comparison.
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Trans>Example: {example}.</Trans>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Trans>
|
||||||
|
<span>Complete syntax is available </span>
|
||||||
|
<a href="https://commons.apache.org/proper/commons-jexl/reference/syntax.html" target="_blank" rel="noreferrer">
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
<span>.</span>
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function FeedDetailsPage() {
|
export function FeedDetailsPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
if (!id) throw new Error("id required")
|
if (!id) throw new Error("id required")
|
||||||
@@ -163,6 +196,15 @@ export function FeedDetailsPage() {
|
|||||||
<FilteringExpressionEditor initialValue={feed.filter} onChange={value => form.setFieldValue("filter", value)} />
|
<FilteringExpressionEditor initialValue={feed.filter} onChange={value => form.setFieldValue("filter", value)} />
|
||||||
</Box>
|
</Box>
|
||||||
</Input.Wrapper>
|
</Input.Wrapper>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Filtering expression</Trans>}
|
||||||
|
description={<FilteringExpressionDescription />}
|
||||||
|
{...form.getInputProps("filter")}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={<Trans>Receive notifications</Trans>}
|
||||||
|
{...form.getInputProps("notifyOnNewEntries", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Container, Tabs } from "@mantine/core"
|
import { Container, Tabs } from "@mantine/core"
|
||||||
import { TbCode, TbPhoto, TbUser } from "react-icons/tb"
|
import { TbBell, TbCode, TbPhoto, TbUser } from "react-icons/tb"
|
||||||
import { CustomCodeSettings } from "@/components/settings/CustomCodeSettings"
|
import { CustomCodeSettings } from "@/components/settings/CustomCodeSettings"
|
||||||
import { DisplaySettings } from "@/components/settings/DisplaySettings"
|
import { DisplaySettings } from "@/components/settings/DisplaySettings"
|
||||||
|
import { NotificationSettings } from "@/components/settings/NotificationSettings"
|
||||||
import { ProfileSettings } from "@/components/settings/ProfileSettings"
|
import { ProfileSettings } from "@/components/settings/ProfileSettings"
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
@@ -13,6 +14,9 @@ export function SettingsPage() {
|
|||||||
<Tabs.Tab value="display" leftSection={<TbPhoto size={16} />}>
|
<Tabs.Tab value="display" leftSection={<TbPhoto size={16} />}>
|
||||||
<Trans>Display</Trans>
|
<Trans>Display</Trans>
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="notifications" leftSection={<TbBell size={16} />}>
|
||||||
|
<Trans>Notifications</Trans>
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="customCode" leftSection={<TbCode size={16} />}>
|
<Tabs.Tab value="customCode" leftSection={<TbCode size={16} />}>
|
||||||
<Trans>Custom code</Trans>
|
<Trans>Custom code</Trans>
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -25,6 +29,10 @@ export function SettingsPage() {
|
|||||||
<DisplaySettings />
|
<DisplaySettings />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="notifications" pt="xl">
|
||||||
|
<NotificationSettings />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="customCode" pt="xl">
|
<Tabs.Panel value="customCode" pt="xl">
|
||||||
<CustomCodeSettings />
|
<CustomCodeSettings />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.commafeed.backend.feed;
|
package com.commafeed.backend.feed;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -19,14 +20,17 @@ import com.codahale.metrics.MetricRegistry;
|
|||||||
import com.commafeed.backend.Digests;
|
import com.commafeed.backend.Digests;
|
||||||
import com.commafeed.backend.dao.FeedSubscriptionDAO;
|
import com.commafeed.backend.dao.FeedSubscriptionDAO;
|
||||||
import com.commafeed.backend.dao.UnitOfWork;
|
import com.commafeed.backend.dao.UnitOfWork;
|
||||||
|
import com.commafeed.backend.dao.UserSettingsDAO;
|
||||||
import com.commafeed.backend.feed.parser.FeedParserResult.Content;
|
import com.commafeed.backend.feed.parser.FeedParserResult.Content;
|
||||||
import com.commafeed.backend.feed.parser.FeedParserResult.Entry;
|
import com.commafeed.backend.feed.parser.FeedParserResult.Entry;
|
||||||
import com.commafeed.backend.model.Feed;
|
import com.commafeed.backend.model.Feed;
|
||||||
import com.commafeed.backend.model.FeedEntry;
|
import com.commafeed.backend.model.FeedEntry;
|
||||||
import com.commafeed.backend.model.FeedSubscription;
|
import com.commafeed.backend.model.FeedSubscription;
|
||||||
import com.commafeed.backend.model.Models;
|
import com.commafeed.backend.model.Models;
|
||||||
|
import com.commafeed.backend.model.UserSettings;
|
||||||
import com.commafeed.backend.service.FeedEntryService;
|
import com.commafeed.backend.service.FeedEntryService;
|
||||||
import com.commafeed.backend.service.FeedService;
|
import com.commafeed.backend.service.FeedService;
|
||||||
|
import com.commafeed.backend.service.NotificationService;
|
||||||
import com.commafeed.frontend.ws.WebSocketMessageBuilder;
|
import com.commafeed.frontend.ws.WebSocketMessageBuilder;
|
||||||
import com.commafeed.frontend.ws.WebSocketSessions;
|
import com.commafeed.frontend.ws.WebSocketSessions;
|
||||||
import com.google.common.util.concurrent.Striped;
|
import com.google.common.util.concurrent.Striped;
|
||||||
@@ -44,7 +48,9 @@ public class FeedRefreshUpdater {
|
|||||||
private final FeedService feedService;
|
private final FeedService feedService;
|
||||||
private final FeedEntryService feedEntryService;
|
private final FeedEntryService feedEntryService;
|
||||||
private final FeedSubscriptionDAO feedSubscriptionDAO;
|
private final FeedSubscriptionDAO feedSubscriptionDAO;
|
||||||
|
private final UserSettingsDAO userSettingsDAO;
|
||||||
private final WebSocketSessions webSocketSessions;
|
private final WebSocketSessions webSocketSessions;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
private final Striped<Lock> locks;
|
private final Striped<Lock> locks;
|
||||||
|
|
||||||
@@ -52,12 +58,15 @@ public class FeedRefreshUpdater {
|
|||||||
private final Meter entryInserted;
|
private final Meter entryInserted;
|
||||||
|
|
||||||
public FeedRefreshUpdater(UnitOfWork unitOfWork, FeedService feedService, FeedEntryService feedEntryService, MetricRegistry metrics,
|
public FeedRefreshUpdater(UnitOfWork unitOfWork, FeedService feedService, FeedEntryService feedEntryService, MetricRegistry metrics,
|
||||||
FeedSubscriptionDAO feedSubscriptionDAO, WebSocketSessions webSocketSessions) {
|
FeedSubscriptionDAO feedSubscriptionDAO, UserSettingsDAO userSettingsDAO, WebSocketSessions webSocketSessions,
|
||||||
|
NotificationService notificationService) {
|
||||||
this.unitOfWork = unitOfWork;
|
this.unitOfWork = unitOfWork;
|
||||||
this.feedService = feedService;
|
this.feedService = feedService;
|
||||||
this.feedEntryService = feedEntryService;
|
this.feedEntryService = feedEntryService;
|
||||||
this.feedSubscriptionDAO = feedSubscriptionDAO;
|
this.feedSubscriptionDAO = feedSubscriptionDAO;
|
||||||
|
this.userSettingsDAO = userSettingsDAO;
|
||||||
this.webSocketSessions = webSocketSessions;
|
this.webSocketSessions = webSocketSessions;
|
||||||
|
this.notificationService = notificationService;
|
||||||
|
|
||||||
locks = Striped.lazyWeakLock(100000);
|
locks = Striped.lazyWeakLock(100000);
|
||||||
|
|
||||||
@@ -67,7 +76,7 @@ public class FeedRefreshUpdater {
|
|||||||
|
|
||||||
private AddEntryResult addEntry(final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
|
private AddEntryResult addEntry(final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
|
||||||
boolean processed = false;
|
boolean processed = false;
|
||||||
boolean inserted = false;
|
FeedEntry insertedEntry = null;
|
||||||
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread = new HashSet<>();
|
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread = new HashSet<>();
|
||||||
|
|
||||||
// lock on feed, make sure we are not updating the same feed twice at
|
// lock on feed, make sure we are not updating the same feed twice at
|
||||||
@@ -90,14 +99,10 @@ public class FeedRefreshUpdater {
|
|||||||
locked2 = lock2.tryLock(1, TimeUnit.MINUTES);
|
locked2 = lock2.tryLock(1, TimeUnit.MINUTES);
|
||||||
if (locked1 && locked2) {
|
if (locked1 && locked2) {
|
||||||
processed = true;
|
processed = true;
|
||||||
inserted = unitOfWork.call(() -> {
|
insertedEntry = unitOfWork.call(() -> {
|
||||||
boolean newEntry = false;
|
|
||||||
FeedEntry feedEntry = feedEntryService.find(feed, entry);
|
FeedEntry feedEntry = feedEntryService.find(feed, entry);
|
||||||
if (feedEntry == null) {
|
if (feedEntry == null) {
|
||||||
feedEntry = feedEntryService.create(feed, entry);
|
feedEntry = feedEntryService.create(feed, entry);
|
||||||
newEntry = true;
|
|
||||||
}
|
|
||||||
if (newEntry) {
|
|
||||||
entryInserted.mark();
|
entryInserted.mark();
|
||||||
for (FeedSubscription sub : subscriptions) {
|
for (FeedSubscription sub : subscriptions) {
|
||||||
boolean unread = feedEntryService.applyFilter(sub, feedEntry);
|
boolean unread = feedEntryService.applyFilter(sub, feedEntry);
|
||||||
@@ -105,8 +110,9 @@ public class FeedRefreshUpdater {
|
|||||||
subscriptionsForWhichEntryIsUnread.add(sub);
|
subscriptionsForWhichEntryIsUnread.add(sub);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return feedEntry;
|
||||||
}
|
}
|
||||||
return newEntry;
|
return null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
log.error("lock timeout for {} - {}", feed.getUrl(), key1);
|
log.error("lock timeout for {} - {}", feed.getUrl(), key1);
|
||||||
@@ -122,13 +128,14 @@ public class FeedRefreshUpdater {
|
|||||||
lock2.unlock();
|
lock2.unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new AddEntryResult(processed, inserted, subscriptionsForWhichEntryIsUnread);
|
return new AddEntryResult(processed, insertedEntry, subscriptionsForWhichEntryIsUnread);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean update(Feed feed, List<Entry> entries) {
|
public boolean update(Feed feed, List<Entry> entries) {
|
||||||
boolean processed = true;
|
boolean processed = true;
|
||||||
long inserted = 0;
|
long inserted = 0;
|
||||||
Map<FeedSubscription, Long> unreadCountBySubscription = new HashMap<>();
|
Map<FeedSubscription, Long> unreadCountBySubscription = new HashMap<>();
|
||||||
|
Map<FeedSubscription, List<FeedEntry>> insertedEntriesBySubscription = new HashMap<>();
|
||||||
|
|
||||||
if (!entries.isEmpty()) {
|
if (!entries.isEmpty()) {
|
||||||
List<FeedSubscription> subscriptions = null;
|
List<FeedSubscription> subscriptions = null;
|
||||||
@@ -138,8 +145,13 @@ public class FeedRefreshUpdater {
|
|||||||
}
|
}
|
||||||
AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions);
|
AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions);
|
||||||
processed &= addEntryResult.processed;
|
processed &= addEntryResult.processed;
|
||||||
inserted += addEntryResult.inserted ? 1 : 0;
|
inserted += addEntryResult.insertedEntry != null ? 1 : 0;
|
||||||
addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> unreadCountBySubscription.merge(sub, 1L, Long::sum));
|
addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> {
|
||||||
|
unreadCountBySubscription.merge(sub, 1L, Long::sum);
|
||||||
|
if (addEntryResult.insertedEntry != null) {
|
||||||
|
insertedEntriesBySubscription.computeIfAbsent(sub, k -> new ArrayList<>()).add(addEntryResult.insertedEntry);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inserted == 0) {
|
if (inserted == 0) {
|
||||||
@@ -161,6 +173,7 @@ public class FeedRefreshUpdater {
|
|||||||
unitOfWork.run(() -> feedService.update(feed));
|
unitOfWork.run(() -> feedService.update(feed));
|
||||||
|
|
||||||
notifyOverWebsocket(unreadCountBySubscription);
|
notifyOverWebsocket(unreadCountBySubscription);
|
||||||
|
sendNotifications(insertedEntriesBySubscription);
|
||||||
|
|
||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
@@ -170,7 +183,25 @@ public class FeedRefreshUpdater {
|
|||||||
WebSocketMessageBuilder.newFeedEntries(sub, unreadCount)));
|
WebSocketMessageBuilder.newFeedEntries(sub, unreadCount)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private record AddEntryResult(boolean processed, boolean inserted, Set<FeedSubscription> subscriptionsForWhichEntryIsUnread) {
|
private void sendNotifications(Map<FeedSubscription, List<FeedEntry>> insertedEntriesBySubscription) {
|
||||||
|
insertedEntriesBySubscription.forEach((sub, feedEntries) -> {
|
||||||
|
if (!sub.isNotifyOnNewEntries()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
UserSettings settings = unitOfWork.call(() -> userSettingsDAO.findByUser(sub.getUser()));
|
||||||
|
if (settings != null && settings.isNotificationEnabled()) {
|
||||||
|
for (FeedEntry feedEntry : feedEntries) {
|
||||||
|
notificationService.notify(settings, sub, feedEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("error sending push notification for subscription {}", sub.getId(), e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private record AddEntryResult(boolean processed, FeedEntry insertedEntry, Set<FeedSubscription> subscriptionsForWhichEntryIsUnread) {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,4 +46,7 @@ public class FeedSubscription extends AbstractModel {
|
|||||||
@Column(name = "filtering_expression_legacy", length = 4096)
|
@Column(name = "filtering_expression_legacy", length = 4096)
|
||||||
private String filterLegacy;
|
private String filterLegacy;
|
||||||
|
|
||||||
|
@Column(name = "notify_on_new_entries", length = 4096)
|
||||||
|
private boolean notifyOnNewEntries = true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ public class UserSettings extends AbstractModel {
|
|||||||
ON_MOBILE
|
ON_MOBILE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum NotificationType {
|
||||||
|
@JsonProperty("ntfy")
|
||||||
|
NTFY,
|
||||||
|
|
||||||
|
@JsonProperty("gotify")
|
||||||
|
GOTIFY,
|
||||||
|
|
||||||
|
@JsonProperty("pushover")
|
||||||
|
PUSHOVER
|
||||||
|
}
|
||||||
|
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "user_id", nullable = false, unique = true)
|
@JoinColumn(name = "user_id", nullable = false, unique = true)
|
||||||
private User user;
|
private User user;
|
||||||
@@ -133,6 +144,24 @@ public class UserSettings extends AbstractModel {
|
|||||||
private boolean unreadCountFavicon;
|
private boolean unreadCountFavicon;
|
||||||
private boolean disablePullToRefresh;
|
private boolean disablePullToRefresh;
|
||||||
|
|
||||||
|
private boolean notificationEnabled;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "notification_type", length = 16)
|
||||||
|
private NotificationType notificationType;
|
||||||
|
|
||||||
|
@Column(name = "notification_server_url", length = 1024)
|
||||||
|
private String notificationServerUrl;
|
||||||
|
|
||||||
|
@Column(name = "notification_token", length = 512)
|
||||||
|
private String notificationToken;
|
||||||
|
|
||||||
|
@Column(name = "notification_user_key", length = 512)
|
||||||
|
private String notificationUserKey;
|
||||||
|
|
||||||
|
@Column(name = "notification_topic", length = 256)
|
||||||
|
private String notificationTopic;
|
||||||
|
|
||||||
private boolean email;
|
private boolean email;
|
||||||
private boolean gmail;
|
private boolean gmail;
|
||||||
private boolean facebook;
|
private boolean facebook;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class OPMLImporter {
|
|||||||
}
|
}
|
||||||
// make sure we continue with the import process even if a feed failed
|
// make sure we continue with the import process even if a feed failed
|
||||||
try {
|
try {
|
||||||
feedSubscriptionService.subscribe(user, outline.getXmlUrl(), name, parent, position);
|
feedSubscriptionService.subscribe(user, outline.getXmlUrl(), name, parent, position, true);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage());
|
log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,15 +49,7 @@ public class FeedSubscriptionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public long subscribe(User user, String url, String title) {
|
public long subscribe(User user, String url, String title, FeedCategory category, int position, boolean notifyOnNewEntries) {
|
||||||
return subscribe(user, url, title, null, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public long subscribe(User user, String url, String title, FeedCategory parent) {
|
|
||||||
return subscribe(user, url, title, parent, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public long subscribe(User user, String url, String title, FeedCategory category, int position) {
|
|
||||||
Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser();
|
Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser();
|
||||||
if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) {
|
if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) {
|
||||||
String message = String.format("You cannot subscribe to more feeds on this CommaFeed instance (max %s feeds per user)",
|
String message = String.format("You cannot subscribe to more feeds on this CommaFeed instance (max %s feeds per user)",
|
||||||
@@ -81,6 +73,7 @@ public class FeedSubscriptionService {
|
|||||||
sub.setCategory(category);
|
sub.setCategory(category);
|
||||||
sub.setPosition(position);
|
sub.setPosition(position);
|
||||||
sub.setTitle(FeedUtils.truncate(title, 128));
|
sub.setTitle(FeedUtils.truncate(title, 128));
|
||||||
|
sub.setNotifyOnNewEntries(notifyOnNewEntries);
|
||||||
return feedSubscriptionDAO.merge(sub).getId();
|
return feedSubscriptionDAO.merge(sub).getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package com.commafeed.backend.service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpRequest.BodyPublishers;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import com.commafeed.backend.model.FeedEntry;
|
||||||
|
import com.commafeed.backend.model.FeedSubscription;
|
||||||
|
import com.commafeed.backend.model.UserSettings;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Slf4j
|
||||||
|
public class NotificationService {
|
||||||
|
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
public NotificationService() {
|
||||||
|
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotificationService(HttpClient httpClient) {
|
||||||
|
this.httpClient = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void notify(UserSettings settings, FeedSubscription subscription, FeedEntry entry) {
|
||||||
|
if (!settings.isNotificationEnabled() || settings.getNotificationType() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String entryTitle = entry.getContent() != null ? entry.getContent().getTitle() : null;
|
||||||
|
String entryUrl = entry.getUrl();
|
||||||
|
String feedTitle = subscription.getTitle();
|
||||||
|
|
||||||
|
if (StringUtils.isBlank(entryTitle)) {
|
||||||
|
entryTitle = "New entry";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (settings.getNotificationType()) {
|
||||||
|
case NTFY -> sendNtfy(settings, feedTitle, entryTitle, entryUrl);
|
||||||
|
case GOTIFY -> sendGotify(settings, feedTitle, entryTitle, entryUrl);
|
||||||
|
case PUSHOVER -> sendPushover(settings, feedTitle, entryTitle, entryUrl);
|
||||||
|
default -> log.warn("unknown notification type: {}", settings.getNotificationType());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("failed to send {} notification for entry '{}' in feed '{}'", settings.getNotificationType(), entryTitle, feedTitle,
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendNtfy(UserSettings settings, String feedTitle, String entryTitle, String entryUrl) throws Exception {
|
||||||
|
String serverUrl = stripTrailingSlash(settings.getNotificationServerUrl());
|
||||||
|
String topic = settings.getNotificationTopic();
|
||||||
|
|
||||||
|
if (StringUtils.isBlank(serverUrl) || StringUtils.isBlank(topic)) {
|
||||||
|
log.warn("ntfy notification skipped: missing server URL or topic");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(serverUrl + "/" + topic))
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
.header("Title", feedTitle + ": " + entryTitle)
|
||||||
|
.POST(BodyPublishers.ofString(entryTitle));
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(entryUrl)) {
|
||||||
|
builder.header("Click", entryUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(settings.getNotificationToken())) {
|
||||||
|
builder.header("Authorization", "Bearer " + settings.getNotificationToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
log.error("ntfy notification failed with status {}: {}", response.statusCode(), response.body());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendGotify(UserSettings settings, String feedTitle, String entryTitle, String entryUrl) throws Exception {
|
||||||
|
String serverUrl = stripTrailingSlash(settings.getNotificationServerUrl());
|
||||||
|
String token = settings.getNotificationToken();
|
||||||
|
|
||||||
|
if (StringUtils.isBlank(serverUrl) || StringUtils.isBlank(token)) {
|
||||||
|
log.warn("gotify notification skipped: missing server URL or token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String message = entryTitle;
|
||||||
|
if (StringUtils.isNotBlank(entryUrl)) {
|
||||||
|
message += "\n" + entryUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
String json = """
|
||||||
|
{"title":"%s","message":"%s","priority":5,"extras":{"client::notification":{"click":{"url":"%s"}}}}"""
|
||||||
|
.formatted(escapeJson(feedTitle), escapeJson(message), escapeJson(StringUtils.defaultString(entryUrl)));
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(serverUrl + "/message"))
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Gotify-Key", token)
|
||||||
|
.POST(BodyPublishers.ofString(json))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
log.error("gotify notification failed with status {}: {}", response.statusCode(), response.body());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendPushover(UserSettings settings, String feedTitle, String entryTitle, String entryUrl) throws Exception {
|
||||||
|
String token = settings.getNotificationToken();
|
||||||
|
String userKey = settings.getNotificationUserKey();
|
||||||
|
|
||||||
|
if (StringUtils.isBlank(token) || StringUtils.isBlank(userKey)) {
|
||||||
|
log.warn("pushover notification skipped: missing token or user key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder body = new StringBuilder();
|
||||||
|
body.append("token=").append(urlEncode(token));
|
||||||
|
body.append("&user=").append(urlEncode(userKey));
|
||||||
|
body.append("&title=").append(urlEncode(feedTitle));
|
||||||
|
body.append("&message=").append(urlEncode(entryTitle));
|
||||||
|
if (StringUtils.isNotBlank(entryUrl)) {
|
||||||
|
body.append("&url=").append(urlEncode(entryUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("https://api.pushover.net/1/messages.json"))
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.POST(BodyPublishers.ofString(body.toString()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
log.error("pushover notification failed with status {}: {}", response.statusCode(), response.body());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripTrailingSlash(String url) {
|
||||||
|
if (url != null && url.endsWith("/")) {
|
||||||
|
return url.substring(0, url.length() - 1);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String urlEncode(String value) {
|
||||||
|
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeJson(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,31 @@ public class Settings implements Serializable {
|
|||||||
@Schema(description = "sharing settings", required = true)
|
@Schema(description = "sharing settings", required = true)
|
||||||
private SharingSettings sharingSettings = new SharingSettings();
|
private SharingSettings sharingSettings = new SharingSettings();
|
||||||
|
|
||||||
|
@Schema(description = "notification settings", required = true)
|
||||||
|
private NotificationSettings notificationSettings = new NotificationSettings();
|
||||||
|
|
||||||
|
@Schema(description = "User notification settings")
|
||||||
|
@Data
|
||||||
|
public static class NotificationSettings implements Serializable {
|
||||||
|
@Schema(required = true)
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Schema(description = "notification provider type: ntfy, gotify, or pushover")
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@Schema(description = "server URL for ntfy or gotify")
|
||||||
|
private String serverUrl;
|
||||||
|
|
||||||
|
@Schema(description = "API token for gotify or pushover")
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
@Schema(description = "user key for pushover")
|
||||||
|
private String userKey;
|
||||||
|
|
||||||
|
@Schema(description = "topic for ntfy")
|
||||||
|
private String topic;
|
||||||
|
}
|
||||||
|
|
||||||
@Schema(description = "User sharing settings")
|
@Schema(description = "User sharing settings")
|
||||||
@Data
|
@Data
|
||||||
public static class SharingSettings implements Serializable {
|
public static class SharingSettings implements Serializable {
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ public class Subscription implements Serializable {
|
|||||||
@Schema(description = "JEXL legacy filter")
|
@Schema(description = "JEXL legacy filter")
|
||||||
private String filterLegacy;
|
private String filterLegacy;
|
||||||
|
|
||||||
|
@Schema(description = "whether to send notifications for new entries of this feed", required = true)
|
||||||
|
private boolean notifyOnNewEntries;
|
||||||
|
|
||||||
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {
|
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {
|
||||||
FeedCategory category = subscription.getCategory();
|
FeedCategory category = subscription.getCategory();
|
||||||
Feed feed = subscription.getFeed();
|
Feed feed = subscription.getFeed();
|
||||||
@@ -85,6 +88,7 @@ public class Subscription implements Serializable {
|
|||||||
sub.setCategoryId(category == null ? null : String.valueOf(category.getId()));
|
sub.setCategoryId(category == null ? null : String.valueOf(category.getId()));
|
||||||
sub.setFilter(subscription.getFilter());
|
sub.setFilter(subscription.getFilter());
|
||||||
sub.setFilterLegacy(subscription.getFilterLegacy());
|
sub.setFilterLegacy(subscription.getFilterLegacy());
|
||||||
|
sub.setNotifyOnNewEntries(subscription.isNotifyOnNewEntries());
|
||||||
return sub;
|
return sub;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,4 +31,7 @@ public class FeedModificationRequest implements Serializable {
|
|||||||
@Size(max = 4096)
|
@Size(max = 4096)
|
||||||
private String filter;
|
private String filter;
|
||||||
|
|
||||||
|
@Schema(description = "whether to send notifications for new entries of this feed")
|
||||||
|
private Boolean notifyOnNewEntries;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,7 @@ public class SubscribeRequest implements Serializable {
|
|||||||
@Size(max = 128)
|
@Size(max = 128)
|
||||||
private String categoryId;
|
private String categoryId;
|
||||||
|
|
||||||
|
@Schema(description = "whether to send notifications for new entries of this feed")
|
||||||
|
private boolean notifyOnNewEntries = true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,7 +365,8 @@ public class FeedREST {
|
|||||||
|
|
||||||
FeedInfo info = fetchFeedInternal(prependHttp(req.getUrl()));
|
FeedInfo info = fetchFeedInternal(prependHttp(req.getUrl()));
|
||||||
User user = authenticationContext.getCurrentUser();
|
User user = authenticationContext.getCurrentUser();
|
||||||
long subscriptionId = feedSubscriptionService.subscribe(user, info.getUrl(), req.getTitle(), category);
|
long subscriptionId = feedSubscriptionService.subscribe(user, info.getUrl(), req.getTitle(), category, 0,
|
||||||
|
req.isNotifyOnNewEntries());
|
||||||
return Response.ok(subscriptionId).build();
|
return Response.ok(subscriptionId).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to subscribe to URL {}: {}", req.getUrl(), e.getMessage(), e);
|
log.error("Failed to subscribe to URL {}: {}", req.getUrl(), e.getMessage(), e);
|
||||||
@@ -384,7 +385,7 @@ public class FeedREST {
|
|||||||
Preconditions.checkNotNull(url);
|
Preconditions.checkNotNull(url);
|
||||||
FeedInfo info = fetchFeedInternal(prependHttp(url));
|
FeedInfo info = fetchFeedInternal(prependHttp(url));
|
||||||
User user = authenticationContext.getCurrentUser();
|
User user = authenticationContext.getCurrentUser();
|
||||||
feedSubscriptionService.subscribe(user, info.getUrl(), info.getTitle());
|
feedSubscriptionService.subscribe(user, info.getUrl(), info.getTitle(), null, 0, true);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.info("Could not subscribe to url {} : {}", url, e.getMessage());
|
log.info("Could not subscribe to url {} : {}", url, e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -438,6 +439,10 @@ public class FeedREST {
|
|||||||
subscription.setFilterLegacy(null);
|
subscription.setFilterLegacy(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.getNotifyOnNewEntries() != null) {
|
||||||
|
subscription.setNotifyOnNewEntries(req.getNotifyOnNewEntries());
|
||||||
|
}
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(req.getName())) {
|
if (StringUtils.isNotBlank(req.getName())) {
|
||||||
subscription.setTitle(req.getName());
|
subscription.setTitle(req.getName());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import com.commafeed.backend.model.UserRole;
|
|||||||
import com.commafeed.backend.model.UserRole.Role;
|
import com.commafeed.backend.model.UserRole.Role;
|
||||||
import com.commafeed.backend.model.UserSettings;
|
import com.commafeed.backend.model.UserSettings;
|
||||||
import com.commafeed.backend.model.UserSettings.IconDisplayMode;
|
import com.commafeed.backend.model.UserSettings.IconDisplayMode;
|
||||||
|
import com.commafeed.backend.model.UserSettings.NotificationType;
|
||||||
import com.commafeed.backend.model.UserSettings.ReadingMode;
|
import com.commafeed.backend.model.UserSettings.ReadingMode;
|
||||||
import com.commafeed.backend.model.UserSettings.ReadingOrder;
|
import com.commafeed.backend.model.UserSettings.ReadingOrder;
|
||||||
import com.commafeed.backend.model.UserSettings.ScrollMode;
|
import com.commafeed.backend.model.UserSettings.ScrollMode;
|
||||||
@@ -125,6 +126,15 @@ public class UserREST {
|
|||||||
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
|
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
|
||||||
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
|
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
|
||||||
s.setPrimaryColor(settings.getPrimaryColor());
|
s.setPrimaryColor(settings.getPrimaryColor());
|
||||||
|
|
||||||
|
s.getNotificationSettings().setEnabled(settings.isNotificationEnabled());
|
||||||
|
if (settings.getNotificationType() != null) {
|
||||||
|
s.getNotificationSettings().setType(settings.getNotificationType().name().toLowerCase());
|
||||||
|
}
|
||||||
|
s.getNotificationSettings().setServerUrl(settings.getNotificationServerUrl());
|
||||||
|
s.getNotificationSettings().setToken(settings.getNotificationToken());
|
||||||
|
s.getNotificationSettings().setUserKey(settings.getNotificationUserKey());
|
||||||
|
s.getNotificationSettings().setTopic(settings.getNotificationTopic());
|
||||||
} else {
|
} else {
|
||||||
s.setReadingMode(ReadingMode.UNREAD);
|
s.setReadingMode(ReadingMode.UNREAD);
|
||||||
s.setReadingOrder(ReadingOrder.DESC);
|
s.setReadingOrder(ReadingOrder.DESC);
|
||||||
@@ -190,6 +200,17 @@ public class UserREST {
|
|||||||
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
|
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
|
||||||
s.setPrimaryColor(settings.getPrimaryColor());
|
s.setPrimaryColor(settings.getPrimaryColor());
|
||||||
|
|
||||||
|
s.setNotificationEnabled(settings.getNotificationSettings().isEnabled());
|
||||||
|
if (settings.getNotificationSettings().getType() != null) {
|
||||||
|
s.setNotificationType(NotificationType.valueOf(settings.getNotificationSettings().getType().toUpperCase()));
|
||||||
|
} else {
|
||||||
|
s.setNotificationType(null);
|
||||||
|
}
|
||||||
|
s.setNotificationServerUrl(settings.getNotificationSettings().getServerUrl());
|
||||||
|
s.setNotificationToken(settings.getNotificationSettings().getToken());
|
||||||
|
s.setNotificationUserKey(settings.getNotificationSettings().getUserKey());
|
||||||
|
s.setNotificationTopic(settings.getNotificationSettings().getTopic());
|
||||||
|
|
||||||
s.setEmail(settings.getSharingSettings().isEmail());
|
s.setEmail(settings.getSharingSettings().isEmail());
|
||||||
s.setGmail(settings.getSharingSettings().isGmail());
|
s.setGmail(settings.getSharingSettings().isGmail());
|
||||||
s.setFacebook(settings.getSharingSettings().isFacebook());
|
s.setFacebook(settings.getSharingSettings().isFacebook());
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="add-subscription-notify-on-new-entries" author="commafeed">
|
||||||
|
<addColumn tableName="FEEDSUBSCRIPTIONS">
|
||||||
|
<column name="notify_on_new_entries" type="BOOLEAN" valueBoolean="true">
|
||||||
|
<constraints nullable="false" />
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="add-notification-settings" author="commafeed">
|
||||||
|
<addColumn tableName="USERSETTINGS">
|
||||||
|
<column name="notificationEnabled" type="BOOLEAN" valueBoolean="false">
|
||||||
|
<constraints nullable="false" />
|
||||||
|
</column>
|
||||||
|
<column name="notification_type" type="VARCHAR(16)" />
|
||||||
|
<column name="notification_server_url" type="VARCHAR(1024)" />
|
||||||
|
<column name="notification_token" type="VARCHAR(512)" />
|
||||||
|
<column name="notification_user_key" type="VARCHAR(512)" />
|
||||||
|
<column name="notification_topic" type="VARCHAR(256)" />
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
<include file="changelogs/db.changelog-5.8.xml" />
|
<include file="changelogs/db.changelog-5.8.xml" />
|
||||||
<include file="changelogs/db.changelog-5.11.xml" />
|
<include file="changelogs/db.changelog-5.11.xml" />
|
||||||
<include file="changelogs/db.changelog-5.12.xml" />
|
<include file="changelogs/db.changelog-5.12.xml" />
|
||||||
|
<include file="changelogs/db.changelog-6.1.xml" />
|
||||||
<include file="changelogs/db.changelog-7.0.xml" />
|
<include file="changelogs/db.changelog-7.0.xml" />
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package com.commafeed.backend.feed;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import com.codahale.metrics.MetricRegistry;
|
||||||
|
import com.commafeed.backend.dao.FeedSubscriptionDAO;
|
||||||
|
import com.commafeed.backend.dao.UnitOfWork;
|
||||||
|
import com.commafeed.backend.dao.UserSettingsDAO;
|
||||||
|
import com.commafeed.backend.feed.parser.FeedParserResult.Content;
|
||||||
|
import com.commafeed.backend.feed.parser.FeedParserResult.Entry;
|
||||||
|
import com.commafeed.backend.model.Feed;
|
||||||
|
import com.commafeed.backend.model.FeedEntry;
|
||||||
|
import com.commafeed.backend.model.FeedSubscription;
|
||||||
|
import com.commafeed.backend.model.User;
|
||||||
|
import com.commafeed.backend.model.UserSettings;
|
||||||
|
import com.commafeed.backend.service.FeedEntryService;
|
||||||
|
import com.commafeed.backend.service.FeedService;
|
||||||
|
import com.commafeed.backend.service.NotificationService;
|
||||||
|
import com.commafeed.frontend.ws.WebSocketSessions;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class FeedRefreshUpdaterTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UnitOfWork unitOfWork;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FeedService feedService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FeedEntryService feedEntryService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FeedSubscriptionDAO feedSubscriptionDAO;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserSettingsDAO userSettingsDAO;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private WebSocketSessions webSocketSessions;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private NotificationService notificationService;
|
||||||
|
|
||||||
|
private FeedRefreshUpdater updater;
|
||||||
|
|
||||||
|
private Feed feed;
|
||||||
|
private User user;
|
||||||
|
private FeedSubscription subscription;
|
||||||
|
private Entry entry;
|
||||||
|
private FeedEntry feedEntry;
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
MetricRegistry metrics = new MetricRegistry();
|
||||||
|
updater = new FeedRefreshUpdater(unitOfWork, feedService, feedEntryService, metrics, feedSubscriptionDAO, userSettingsDAO,
|
||||||
|
webSocketSessions, notificationService);
|
||||||
|
|
||||||
|
// UnitOfWork passthrough: execute callables and runnables directly
|
||||||
|
Mockito.when(unitOfWork.call(Mockito.any())).thenAnswer(inv -> inv.getArgument(0, Callable.class).call());
|
||||||
|
Mockito.doAnswer(inv -> {
|
||||||
|
inv.getArgument(0, Runnable.class).run();
|
||||||
|
return null;
|
||||||
|
}).when(unitOfWork).run(Mockito.any());
|
||||||
|
|
||||||
|
user = new User();
|
||||||
|
user.setId(1L);
|
||||||
|
|
||||||
|
feed = new Feed();
|
||||||
|
feed.setId(1L);
|
||||||
|
feed.setUrl("https://example.com/feed.xml");
|
||||||
|
|
||||||
|
subscription = new FeedSubscription();
|
||||||
|
subscription.setId(1L);
|
||||||
|
subscription.setTitle("My Feed");
|
||||||
|
subscription.setUser(user);
|
||||||
|
subscription.setNotifyOnNewEntries(true);
|
||||||
|
|
||||||
|
Content content = new Content("Article Title", "content", "author", null, null, null);
|
||||||
|
entry = new Entry("guid-1", "https://example.com/article", Instant.now(), content);
|
||||||
|
|
||||||
|
feedEntry = new FeedEntry();
|
||||||
|
feedEntry.setUrl("https://example.com/article");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateSendsNotificationsForNewEntries() {
|
||||||
|
Mockito.when(feedSubscriptionDAO.findByFeed(feed)).thenReturn(List.of(subscription));
|
||||||
|
Mockito.when(feedEntryService.find(feed, entry)).thenReturn(null);
|
||||||
|
Mockito.when(feedEntryService.create(feed, entry)).thenReturn(feedEntry);
|
||||||
|
Mockito.when(feedEntryService.applyFilter(subscription, feedEntry)).thenReturn(true);
|
||||||
|
|
||||||
|
UserSettings settings = new UserSettings();
|
||||||
|
settings.setNotificationEnabled(true);
|
||||||
|
Mockito.when(userSettingsDAO.findByUser(user)).thenReturn(settings);
|
||||||
|
|
||||||
|
updater.update(feed, List.of(entry));
|
||||||
|
|
||||||
|
Mockito.verify(notificationService).notify(settings, subscription, feedEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDoesNotNotifyWhenSubscriptionNotifyDisabled() {
|
||||||
|
subscription.setNotifyOnNewEntries(false);
|
||||||
|
|
||||||
|
Mockito.when(feedSubscriptionDAO.findByFeed(feed)).thenReturn(List.of(subscription));
|
||||||
|
Mockito.when(feedEntryService.find(feed, entry)).thenReturn(null);
|
||||||
|
Mockito.when(feedEntryService.create(feed, entry)).thenReturn(feedEntry);
|
||||||
|
Mockito.when(feedEntryService.applyFilter(subscription, feedEntry)).thenReturn(true);
|
||||||
|
|
||||||
|
updater.update(feed, List.of(entry));
|
||||||
|
|
||||||
|
Mockito.verify(notificationService, Mockito.never()).notify(Mockito.any(), Mockito.any(), Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDoesNotNotifyWhenUserNotificationsDisabled() {
|
||||||
|
Mockito.when(feedSubscriptionDAO.findByFeed(feed)).thenReturn(List.of(subscription));
|
||||||
|
Mockito.when(feedEntryService.find(feed, entry)).thenReturn(null);
|
||||||
|
Mockito.when(feedEntryService.create(feed, entry)).thenReturn(feedEntry);
|
||||||
|
Mockito.when(feedEntryService.applyFilter(subscription, feedEntry)).thenReturn(true);
|
||||||
|
|
||||||
|
UserSettings settings = new UserSettings();
|
||||||
|
settings.setNotificationEnabled(false);
|
||||||
|
Mockito.when(userSettingsDAO.findByUser(user)).thenReturn(settings);
|
||||||
|
|
||||||
|
updater.update(feed, List.of(entry));
|
||||||
|
|
||||||
|
Mockito.verify(notificationService, Mockito.never()).notify(Mockito.any(), Mockito.any(), Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDoesNotNotifyWhenNoUserSettings() {
|
||||||
|
Mockito.when(feedSubscriptionDAO.findByFeed(feed)).thenReturn(List.of(subscription));
|
||||||
|
Mockito.when(feedEntryService.find(feed, entry)).thenReturn(null);
|
||||||
|
Mockito.when(feedEntryService.create(feed, entry)).thenReturn(feedEntry);
|
||||||
|
Mockito.when(feedEntryService.applyFilter(subscription, feedEntry)).thenReturn(true);
|
||||||
|
|
||||||
|
Mockito.when(userSettingsDAO.findByUser(user)).thenReturn(null);
|
||||||
|
|
||||||
|
updater.update(feed, List.of(entry));
|
||||||
|
|
||||||
|
Mockito.verify(notificationService, Mockito.never()).notify(Mockito.any(), Mockito.any(), Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDoesNotNotifyForExistingEntries() {
|
||||||
|
Mockito.when(feedSubscriptionDAO.findByFeed(feed)).thenReturn(List.of(subscription));
|
||||||
|
Mockito.when(feedEntryService.find(feed, entry)).thenReturn(feedEntry);
|
||||||
|
|
||||||
|
updater.update(feed, List.of(entry));
|
||||||
|
|
||||||
|
Mockito.verify(notificationService, Mockito.never()).notify(Mockito.any(), Mockito.any(), Mockito.any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,8 @@ class OPMLImporterTest {
|
|||||||
importer.importOpml(user, xml);
|
importer.importOpml(user, xml);
|
||||||
|
|
||||||
Mockito.verify(feedSubscriptionService)
|
Mockito.verify(feedSubscriptionService)
|
||||||
.subscribe(Mockito.eq(user), Mockito.anyString(), Mockito.anyString(), Mockito.any(FeedCategory.class), Mockito.anyInt());
|
.subscribe(Mockito.eq(user), Mockito.anyString(), Mockito.anyString(), Mockito.any(FeedCategory.class), Mockito.anyInt(),
|
||||||
|
Mockito.anyBoolean());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.commafeed.backend.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.net.http.HttpResponse.BodyHandler;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import com.commafeed.backend.model.FeedEntry;
|
||||||
|
import com.commafeed.backend.model.FeedEntryContent;
|
||||||
|
import com.commafeed.backend.model.FeedSubscription;
|
||||||
|
import com.commafeed.backend.model.UserSettings;
|
||||||
|
import com.commafeed.backend.model.UserSettings.NotificationType;
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class NotificationServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private HttpClient httpClient;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private HttpResponse<String> httpResponse;
|
||||||
|
|
||||||
|
private NotificationService notificationService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
notificationService = new NotificationService(httpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubHttpClient() throws Exception {
|
||||||
|
Mockito.when(httpResponse.statusCode()).thenReturn(200);
|
||||||
|
Mockito.when(httpClient.send(Mockito.any(HttpRequest.class), Mockito.<BodyHandler<String>> any())).thenReturn(httpResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequest captureRequest() throws Exception {
|
||||||
|
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||||
|
Mockito.verify(httpClient).send(captor.capture(), Mockito.<BodyHandler<String>> any());
|
||||||
|
return captor.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendNtfyBuildsCorrectRequest() throws Exception {
|
||||||
|
stubHttpClient();
|
||||||
|
|
||||||
|
UserSettings settings = newSettings(NotificationType.NTFY);
|
||||||
|
settings.setNotificationServerUrl("https://ntfy.example.com");
|
||||||
|
settings.setNotificationTopic("my-topic");
|
||||||
|
settings.setNotificationToken("my-token");
|
||||||
|
|
||||||
|
FeedSubscription sub = newSubscription("My Feed");
|
||||||
|
FeedEntry entry = newEntry("New Article", "https://example.com/article");
|
||||||
|
|
||||||
|
notificationService.notify(settings, sub, entry);
|
||||||
|
|
||||||
|
HttpRequest request = captureRequest();
|
||||||
|
Assertions.assertEquals("https://ntfy.example.com/my-topic", request.uri().toString());
|
||||||
|
Assertions.assertEquals("My Feed: New Article", request.headers().firstValue("Title").orElse(null));
|
||||||
|
Assertions.assertEquals("https://example.com/article", request.headers().firstValue("Click").orElse(null));
|
||||||
|
Assertions.assertEquals("Bearer my-token", request.headers().firstValue("Authorization").orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendNtfyOmitsOptionalHeaders() throws Exception {
|
||||||
|
stubHttpClient();
|
||||||
|
|
||||||
|
UserSettings settings = newSettings(NotificationType.NTFY);
|
||||||
|
settings.setNotificationServerUrl("https://ntfy.example.com");
|
||||||
|
settings.setNotificationTopic("my-topic");
|
||||||
|
|
||||||
|
FeedSubscription sub = newSubscription("My Feed");
|
||||||
|
FeedEntry entry = newEntry("Title", "");
|
||||||
|
|
||||||
|
notificationService.notify(settings, sub, entry);
|
||||||
|
|
||||||
|
HttpRequest request = captureRequest();
|
||||||
|
Assertions.assertTrue(request.headers().firstValue("Click").isEmpty());
|
||||||
|
Assertions.assertTrue(request.headers().firstValue("Authorization").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendNtfySkipsWhenMissingConfig() throws Exception {
|
||||||
|
UserSettings settings = newSettings(NotificationType.NTFY);
|
||||||
|
settings.setNotificationTopic("topic");
|
||||||
|
notificationService.notify(settings, newSubscription("F"), newEntry("T", "U"));
|
||||||
|
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||||
|
|
||||||
|
UserSettings settings2 = newSettings(NotificationType.NTFY);
|
||||||
|
settings2.setNotificationServerUrl("https://ntfy.example.com");
|
||||||
|
notificationService.notify(settings2, newSubscription("F"), newEntry("T", "U"));
|
||||||
|
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendGotifyBuildsCorrectRequest() throws Exception {
|
||||||
|
stubHttpClient();
|
||||||
|
|
||||||
|
UserSettings settings = newSettings(NotificationType.GOTIFY);
|
||||||
|
settings.setNotificationServerUrl("https://gotify.example.com/");
|
||||||
|
settings.setNotificationToken("app-token");
|
||||||
|
|
||||||
|
FeedSubscription sub = newSubscription("My Feed");
|
||||||
|
FeedEntry entry = newEntry("New Article", "https://example.com/article");
|
||||||
|
|
||||||
|
notificationService.notify(settings, sub, entry);
|
||||||
|
|
||||||
|
HttpRequest request = captureRequest();
|
||||||
|
Assertions.assertEquals("https://gotify.example.com/message", request.uri().toString());
|
||||||
|
Assertions.assertEquals("app-token", request.headers().firstValue("X-Gotify-Key").orElse(null));
|
||||||
|
Assertions.assertEquals("application/json", request.headers().firstValue("Content-Type").orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendGotifySkipsWhenMissingConfig() throws Exception {
|
||||||
|
UserSettings settings = newSettings(NotificationType.GOTIFY);
|
||||||
|
settings.setNotificationToken("token");
|
||||||
|
notificationService.notify(settings, newSubscription("F"), newEntry("T", "U"));
|
||||||
|
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||||
|
|
||||||
|
UserSettings settings2 = newSettings(NotificationType.GOTIFY);
|
||||||
|
settings2.setNotificationServerUrl("https://gotify.example.com");
|
||||||
|
notificationService.notify(settings2, newSubscription("F"), newEntry("T", "U"));
|
||||||
|
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendPushoverBuildsCorrectRequest() throws Exception {
|
||||||
|
stubHttpClient();
|
||||||
|
|
||||||
|
UserSettings settings = newSettings(NotificationType.PUSHOVER);
|
||||||
|
settings.setNotificationToken("po-token");
|
||||||
|
settings.setNotificationUserKey("po-user");
|
||||||
|
|
||||||
|
FeedSubscription sub = newSubscription("My Feed");
|
||||||
|
FeedEntry entry = newEntry("New Article", "https://example.com/article");
|
||||||
|
|
||||||
|
notificationService.notify(settings, sub, entry);
|
||||||
|
|
||||||
|
HttpRequest request = captureRequest();
|
||||||
|
Assertions.assertEquals("https://api.pushover.net/1/messages.json", request.uri().toString());
|
||||||
|
Assertions.assertEquals("application/x-www-form-urlencoded", request.headers().firstValue("Content-Type").orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendPushoverOmitsUrlWhenBlank() throws Exception {
|
||||||
|
stubHttpClient();
|
||||||
|
|
||||||
|
UserSettings settings = newSettings(NotificationType.PUSHOVER);
|
||||||
|
settings.setNotificationToken("po-token");
|
||||||
|
settings.setNotificationUserKey("po-user");
|
||||||
|
|
||||||
|
FeedSubscription sub = newSubscription("My Feed");
|
||||||
|
FeedEntry entry = newEntry("Title", "");
|
||||||
|
|
||||||
|
notificationService.notify(settings, sub, entry);
|
||||||
|
|
||||||
|
Mockito.verify(httpClient).send(Mockito.any(HttpRequest.class), Mockito.<BodyHandler<String>> any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendPushoverSkipsWhenMissingConfig() throws Exception {
|
||||||
|
UserSettings settings = newSettings(NotificationType.PUSHOVER);
|
||||||
|
settings.setNotificationUserKey("user");
|
||||||
|
notificationService.notify(settings, newSubscription("F"), newEntry("T", "U"));
|
||||||
|
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||||
|
|
||||||
|
UserSettings settings2 = newSettings(NotificationType.PUSHOVER);
|
||||||
|
settings2.setNotificationToken("token");
|
||||||
|
notificationService.notify(settings2, newSubscription("F"), newEntry("T", "U"));
|
||||||
|
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyDoesNotPropagateExceptions() throws Exception {
|
||||||
|
Mockito.when(httpClient.send(Mockito.any(HttpRequest.class), Mockito.<BodyHandler<String>> any()))
|
||||||
|
.thenThrow(new IOException("connection failed"));
|
||||||
|
|
||||||
|
UserSettings settings = newSettings(NotificationType.NTFY);
|
||||||
|
settings.setNotificationServerUrl("https://ntfy.example.com");
|
||||||
|
settings.setNotificationTopic("topic");
|
||||||
|
|
||||||
|
Assertions.assertDoesNotThrow(() -> notificationService.notify(settings, newSubscription("Feed"), newEntry("Title", "url")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyUsesNewEntryAsFallbackTitle() throws Exception {
|
||||||
|
stubHttpClient();
|
||||||
|
|
||||||
|
UserSettings settings = newSettings(NotificationType.NTFY);
|
||||||
|
settings.setNotificationServerUrl("https://ntfy.example.com");
|
||||||
|
settings.setNotificationTopic("topic");
|
||||||
|
|
||||||
|
FeedSubscription sub = newSubscription("Feed");
|
||||||
|
|
||||||
|
FeedEntry entryNoContent = new FeedEntry();
|
||||||
|
entryNoContent.setUrl("https://example.com");
|
||||||
|
notificationService.notify(settings, sub, entryNoContent);
|
||||||
|
|
||||||
|
HttpRequest request = captureRequest();
|
||||||
|
Assertions.assertEquals("Feed: New entry", request.headers().firstValue("Title").orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserSettings newSettings(NotificationType type) {
|
||||||
|
UserSettings settings = new UserSettings();
|
||||||
|
settings.setNotificationEnabled(true);
|
||||||
|
settings.setNotificationType(type);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FeedSubscription newSubscription(String title) {
|
||||||
|
FeedSubscription sub = new FeedSubscription();
|
||||||
|
sub.setTitle(title);
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FeedEntry newEntry(String title, String url) {
|
||||||
|
FeedEntryContent content = new FeedEntryContent();
|
||||||
|
content.setTitle(title);
|
||||||
|
FeedEntry entry = new FeedEntry();
|
||||||
|
entry.setContent(content);
|
||||||
|
entry.setUrl(url);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user