Merge main into feature/pushover-notifications, add Microsoft Teams provider

Both branches added a migration at index 0062. Resolved by keeping
upstream's teams migration at 0062 and promoting pushover to 0063.
Both providers are now included in the enum, types, handlers, and forms.
This commit is contained in:
Peter Crosthwaite
2026-06-19 18:27:45 +10:00
48 changed files with 4180 additions and 2854 deletions
@@ -11,6 +11,7 @@ import {PushoverChannelConfigSchema} from "@/features/channel/notifications/push
import {S3ChannelConfigSchema} from "@/features/channel/storages/s3.schema";
import {GoogleDriveChannelConfigSchema} from "@/features/channel/storages/google-drive/google-drive.schema";
import {LocalChannelConfigSchema} from "@/features/channel/storages/local.schema";
import {TeamsChannelConfigSchema} from "@/features/channel/notifications/teams.schema";
const BaseChannelFormSchema = z.object({
@@ -54,6 +55,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
provider: z.literal("nextcloud"),
config: NextcloudChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("teams"),
config: TeamsChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("pushover"),
config: PushoverChannelConfigSchema,
@@ -29,6 +29,9 @@ import {
import {
notificationProviders,
} from "@/features/channel/channels-notification-helper";
import {
NotifierTeamsForm
} from "@/features/channel/notifications/teams.form";
import {storageProviders} from "@/features/channel/channels-storage-helper";
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
import {LucideProps} from "lucide-react";
@@ -95,6 +98,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
return <NotifierWebhookForm form={form}/>;
case "nextcloud":
return <NotifierNextcloudForm form={form}/>;
case "teams":
return <NotifierTeamsForm form={form}/>;
case "pushover":
return <NotifierPushoverForm form={form}/>;
case "s3":
@@ -14,8 +14,8 @@ export const notificationProviders: ProviderIconTypes[] = [
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
{value: "webhook", label: "Webhook", icon: WebhookIcon},
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
{value: "teams", label: "Microsoft Teams", icon: MSTeamsIcon},
{value: "pushover", label: "Pushover", icon: PushoverIcon},
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
]
@@ -9,6 +9,7 @@ import {sendNtfy} from "@/features/channel/notifications/ntfy";
import {sendWebhook} from "@/features/channel/notifications/webhook";
import {sendNextcloud} from "@/features/channel/notifications/nextcloud";
import {sendPushover} from "@/features/channel/notifications/pushover";
import {sendTeams} from "@/features/channel/notifications/teams"
const handlers: Record<
ProviderKind,
@@ -22,6 +23,7 @@ const handlers: Record<
ntfy: sendNtfy,
webhook: sendWebhook,
nextcloud: sendNextcloud,
teams: sendTeams,
pushover: sendPushover,
};
@@ -0,0 +1,38 @@
import { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierTeamsFormProps = {
form: UseFormReturn<any, any, any>;
};
export const NotifierTeamsForm = ({ form }: NotifierTeamsFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.teamsWebhook"
render={({ field }) => (
<FormItem>
<FormLabel>Teams Webhook URL *</FormLabel>
<FormControl>
<PasswordInput
{...field}
placeholder="e.g. https://xxxxx.logic.azure.com:443/xxxxx"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,5 @@
import {z} from "zod";
export const TeamsChannelConfigSchema = z.object({
teamsWebhook: z.string().url("Must be a valid URL"),
});
@@ -0,0 +1,93 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
export async function sendTeams(
config: { teamsWebhook: string },
payload: EventPayload
): Promise<DispatchResult> {
const { teamsWebhook: webhookUrl } = config;
const levelColor: Record<string, string> = {
critical: 'attention',
warning: 'warning',
info: 'good',
};
const accentColor = levelColor[payload.level] ?? 'default';
const bodyItems: object[] = [
{
type: 'TextBlock',
text: `**[${payload.level.toUpperCase()}] ${payload.title}**`,
wrap: true,
color: accentColor,
size: 'Medium',
weight: 'Bolder',
},
{
type: 'TextBlock',
text: payload.message,
wrap: true,
},
];
if (payload.data) {
bodyItems.push(
{
type: 'TextBlock',
text: '**Data:**',
wrap: true,
weight: 'Bolder',
},
{
type: 'TextBlock',
text: JSON.stringify(payload.data, null, 2).substring(0, 1000),
wrap: true,
fontType: 'Monospace',
}
);
}
const adaptiveCard = {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.2',
body: bodyItems,
};
const body = {
type: 'message',
attachments: [
{
contentType: 'application/vnd.microsoft.card.adaptive',
contentUrl: null,
content: adaptiveCard,
},
],
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
let res: Response;
try {
res = await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
const err = await res.text();
throw new Error(`Teams error: ${res.status} ${err}`);
}
return {
success: true,
provider: 'teams',
message: 'Sent to Microsoft Teams',
response: res.statusText,
};
}
@@ -1,4 +1,4 @@
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud' | 'pushover';
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud' | 'teams' | 'pushover';
export interface DispatchResult {
success: boolean;