fix(slack): channel is not supported in combination with webhooks (#770)

#766
This commit is contained in:
Nico 2026-04-09 22:24:24 +02:00 committed by GitHub
parent 4872029b87
commit 595a29056d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 21 additions and 44 deletions

View file

@ -32,7 +32,14 @@ export const createClient = (config: Config = {}): Client => {
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const beforeRequest = async (options: RequestOptions) => {
const beforeRequest = async <
TData = unknown,
TResponseStyle extends 'data' | 'fields' = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
>(
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>,
) => {
const opts = {
..._config,
...options,
@ -61,13 +68,14 @@ export const createClient = (config: Config = {}): Client => {
opts.headers.delete('Content-Type');
}
const url = buildUrl(opts);
const resolvedOpts = opts as typeof opts &
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
const url = buildUrl(resolvedOpts);
return { opts, url };
return { opts: resolvedOpts, url };
};
const request: Client['request'] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: 'follow',

View file

@ -153,7 +153,7 @@ type SseFn = <
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <

View file

@ -205,7 +205,7 @@ export const mergeHeaders = (
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// assume object headers are meant to be JSON stringified, i.e., their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,

View file

@ -81,7 +81,7 @@ export interface Config {
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects.
* for post-processing data, e.g., converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**

View file

@ -127,7 +127,7 @@ export function getValidRequestBody(options: {
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e. client-axios)
// not all clients implement a serializedBody property (i.e., client-axios)
return options.body !== '' ? options.body : null;
}

View file

@ -3,9 +3,9 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
@ -307,7 +307,7 @@ export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Opti
/**
* Execute a restic command against a repository (dev panel only)
*/
export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError>) => (options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({
export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError, DevPanelExecResponse>) => (options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({
url: '/api/v1/repositories/{shortId}/exec',
...options,
headers: {

View file

@ -3526,7 +3526,6 @@ export type GetScheduleNotificationsResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -3628,7 +3627,6 @@ export type UpdateScheduleNotificationsResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4203,7 +4201,6 @@ export type ListNotificationDestinationsResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4273,7 +4270,6 @@ export type CreateNotificationDestinationData = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4348,7 +4344,6 @@ export type CreateNotificationDestinationResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4467,7 +4462,6 @@ export type GetNotificationDestinationResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4538,7 +4532,6 @@ export type UpdateNotificationDestinationData = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4622,7 +4615,6 @@ export type UpdateNotificationDestinationResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {

View file

@ -75,7 +75,6 @@ const defaultValuesForType = {
slack: {
type: "slack" as const,
webhookUrl: "",
channel: "",
username: "",
iconEmoji: "",
},

View file

@ -27,20 +27,6 @@ export const SlackForm = ({ form }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="#backups" />
</FormControl>
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"

View file

@ -78,7 +78,6 @@ function NotificationConfigRows({ config }: { config: NotificationConfig }) {
return (
<>
<ConfigRow icon={<Globe className="h-4 w-4" />} label="Webhook URL" value={config.webhookUrl} mono />
<ConfigRow icon={<Hash className="h-4 w-4" />} label="Channel" value={config.channel || "—"} />
<ConfigRow icon={<Bot className="h-4 w-4" />} label="Bot Username" value={config.username || "—"} />
<ConfigRow icon={<Smile className="h-4 w-4" />} label="Icon Emoji" value={config.iconEmoji || "—"} />
</>

View file

@ -29,7 +29,6 @@ export const emailNotificationConfigSchema = z.object({
export const slackNotificationConfigSchema = z.object({
type: z.literal("slack"),
webhookUrl: z.string().min(1),
channel: z.string().optional(),
username: z.string().optional(),
iconEmoji: z.string().optional(),
});

View file

@ -152,7 +152,7 @@ describe("notification shoutrrr URL builders", () => {
).toBe("pushover://shoutrrr:api-token@user-key/?devices=iphone%2Cipad&priority=1");
});
test("builds slack URLs and rejects invalid webhook formats", () => {
test("builds upstream-compatible slack webhook URLs and rejects invalid webhook formats", () => {
expect(
buildSlackShoutrrrUrl({
type: "slack",
@ -164,11 +164,10 @@ describe("notification shoutrrr URL builders", () => {
buildSlackShoutrrrUrl({
type: "slack",
webhookUrl: "https://hooks.slack.com/services/T000/B000/XXX",
channel: "#alerts",
username: "Alert Bot",
iconEmoji: ":robot_face:",
}),
).toBe("slack://hook:T000-B000-XXX@webhook?channel=%23alerts&username=Alert+Bot&icon_emoji=%3Arobot_face%3A");
).toBe("slack://hook:T000-B000-XXX@webhook?username=Alert+Bot&icon_emoji=%3Arobot_face%3A");
expect(() =>
buildSlackShoutrrrUrl({

View file

@ -7,7 +7,6 @@ describe("mapNotificationConfigSecrets", () => {
{
type: "slack",
webhookUrl: "https://hooks.slack.test/services/a/b/c",
channel: "#alerts",
username: "zerobyte",
iconEmoji: ":wave:",
},
@ -17,7 +16,6 @@ describe("mapNotificationConfigSecrets", () => {
expect(transformed).toEqual({
type: "slack",
webhookUrl: "sealed:https://hooks.slack.test/services/a/b/c",
channel: "#alerts",
username: "zerobyte",
iconEmoji: ":wave:",
});

View file

@ -14,9 +14,6 @@ export const buildSlackShoutrrrUrl = (config: Extract<NotificationConfig, { type
shoutrrrUrl.password = `${tokenA}-${tokenB}-${tokenC}`;
shoutrrrUrl.hostname = "webhook";
if (config.channel) {
shoutrrrUrl.searchParams.append("channel", config.channel);
}
if (config.username) {
shoutrrrUrl.searchParams.append("username", config.username);
}