feat(notif): initial custom channel sending

This commit is contained in:
2026-04-04 22:28:10 +02:00
parent 4d63552254
commit f1f6d20d53
8 changed files with 154 additions and 12 deletions

4
.gitignore vendored
View File

@@ -50,4 +50,6 @@ slack-import-emojis/target
.idea
/apps/docs/src/content/docs/typedoc-sdk
/apps/docs/src/content/docs/typedoc-sdk
.codex

View File

@@ -39,6 +39,7 @@ import {
editStreamInfo,
changeUsername,
updateChatModeration,
updateNotificationChannels,
} from '@/lib/form/actions';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
@@ -80,6 +81,7 @@ import {
} from '@/components/ui/select';
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
import { Textarea } from '@/components/ui/textarea';
interface ChannelSettingsClientProps {
channel: Channel & {
@@ -107,7 +109,6 @@ interface ChannelSettingsClientProps {
export default function ChannelSettingsClient({
channel,
isOwner,
currentUser,
isPersonal,
}: ChannelSettingsClientProps) {
const confirm = useConfirm();
@@ -143,6 +144,12 @@ export default function ChannelSettingsClient({
}
}, []);
const handleNotifChannelsActionComplete = useCallback((result: any) => {
if (result?.success) {
toast.success('Notification channels updated');
}
}, []);
const handleUsernameChangeComplete = useCallback(
(result: any) => {
if (result?.success && result?.newUsername) {
@@ -276,9 +283,9 @@ export default function ChannelSettingsClient({
<MessageSquareWarning className="h-4 w-4" />
Moderation
</TabsTrigger>
<TabsTrigger value="utilities" className="flex items-center gap-2">
<TabsTrigger value="integrations" className="flex items-center gap-2">
<Wrench className="size-4" />
Utilities
Integrations
</TabsTrigger>
</TabsList>
@@ -1053,11 +1060,13 @@ export default function ChannelSettingsClient({
</CardContent>
</Card>
</TabsContent>
<TabsContent value="utilities">
<TabsContent value="integrations">
<Card>
<CardHeader>
<CardTitle>Utilities</CardTitle>
<CardDescription>OBS overlays, APIs... everything in one neat place!</CardDescription>
<CardTitle>Integrations</CardTitle>
<CardDescription>
OBS overlays, Slack... everything in one neat place!
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
@@ -1075,11 +1084,58 @@ export default function ChannelSettingsClient({
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-sm"
/>
</div>
<Button variant="outline" size="sm" onClick={() => setKeyVisible(!keyVisible)}>
{keyVisible ? 'Hide' : 'Show'}
<Button
variant="outline"
size="smicon"
onClick={() => setKeyVisible(!keyVisible)}
>
{keyVisible ? <EyeOff /> : <Eye />}
</Button>
</div>
</div>
<UniversalForm
fields={[
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
{
name: 'channels',
label: 'Notification channels',
value: channel.notifChannels.join('\n'),
textArea: true,
textAreaRows: 4,
description:
'One channel ID per line. These channels will receive notifications when you go live.',
component: ({ field }) => (
<div>
<label className="block text-sm font-medium mb-1">
Notification channels
</label>
<Textarea
name={field.name}
ref={field.ref}
value={
typeof field.value === 'string'
? field.value
: Array.isArray(field.value)
? field.value.join('\n')
: ''
}
rows={4}
placeholder="Enter channel IDs, one per line"
onBlur={field.onBlur}
onChange={field.onChange}
/>
<p className="text-xs text-muted-foreground mt-1">
Enter one channel ID per line. You can find channel IDs in their URLs.
</p>
</div>
),
},
]}
schemaName="updateNotificationChannels"
action={updateNotificationChannels}
submitText="Save notification channels"
onActionComplete={handleNotifChannelsActionComplete}
/>
</div>
</CardContent>
</Card>

View File

@@ -28,6 +28,7 @@ import {
streamInfoEditSchema,
updateChatModerationSchema,
updateChannelSettingsSchema,
updateNotificationChannelsSchema,
} from '@/lib/form/zod';
export const schemaDb = [
@@ -39,6 +40,7 @@ export const schemaDb = [
{ name: 'editBot', zod: editBotSchema },
{ name: 'changeUsername', zod: changeUsernameSchema },
{ name: 'updateChatModeration', zod: updateChatModerationSchema },
{ name: 'updateNotificationChannels', zod: updateNotificationChannelsSchema },
] as const;
export function UniversalForm<T extends z.ZodType>({

View File

@@ -13,6 +13,7 @@ import {
streamInfoEditSchema,
updateChatModerationSchema,
updateChannelSettingsSchema,
updateNotificationChannelsSchema,
} from './zod';
import { initializeStreamInfo } from '../instrumentation/streamInfo';
import {
@@ -23,6 +24,7 @@ import {
import { can } from '../auth/abac';
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
import { generateStreamKey } from '../db/streamKey';
import slackNotifierClient from '../services/slackNotifier';
export async function editStreamInfo(prev: any, formData: FormData) {
const { user } = await validateRequest();
@@ -791,3 +793,56 @@ export async function changeUsername(prev: any, formData: FormData) {
return { success: false, error: 'Failed to change username. Please try again.' };
}
}
export async function updateNotificationChannels(prev: any, formData: FormData) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(updateNotificationChannelsSchema, formData);
if (!zod.success) {
return zod;
}
const channel = await prisma.channel.findUnique({
where: { id: zod.data.channelId },
include: {
owner: true,
managers: true,
streamInfo: true,
},
});
if (!channel) {
return { success: false, error: 'Channel not found' };
}
if (!can(user, 'update', 'channel', { channel })) {
return { success: false, error: 'Unauthorized' };
}
const newDifference = zod.data.channels.filter((c: string) => !channel.notifChannels.includes(c));
for (const channelId of newDifference) {
try {
await slackNotifierClient.chat.postMessage({
channel: channelId,
text: `:yay: I'll send livestream notifications for <https://hackclub.tv/${channel.name}|${channel.name}> here from now on!`,
});
} catch (error) {
console.error('Failed to validate Slack notification channel:', error);
return {
success: false,
error: `Failed to send a test notification to ${channelId}. Check that the channel ID is valid and that the bot has access, then try again.`,
};
}
}
await prisma.channel.updateMany({
where: { id: channel.id },
data: { notifChannels: zod.data.channels },
});
revalidatePath(`/settings/channel/${channel.name}`);
return { success: true };
}

View File

@@ -58,3 +58,24 @@ export const changeUsernameSchema = z.object({
channelId: z.string().min(1),
newUsername: username,
});
const notificationChannelsSchema: z.ZodType<string[], z.ZodTypeDef, string | string[]> =
z.preprocess(
(value) => {
if (typeof value === 'string') {
return value
.replace(/\r\n/g, '\n')
.split('\n')
.map((channel) => channel.trim())
.filter(Boolean);
}
return value;
},
z.array(z.string()).max(10)
);
export const updateNotificationChannelsSchema = z.object({
channelId: z.string().min(1),
channels: notificationChannelsSchema,
});

View File

@@ -1,6 +1,6 @@
'use server';
import { ZodType } from 'zod';
import { z } from 'zod';
type SuccessResult<T> = {
success: true;
@@ -14,7 +14,10 @@ type ErrorResult = {
type VerifyResult<T> = SuccessResult<T> | ErrorResult;
export default async function zodVerify<T>(schema: ZodType<T>, data: FormData | Object): Promise<VerifyResult<T>> {
export default async function zodVerify<TSchema extends z.ZodTypeAny>(
schema: TSchema,
data: FormData | Object
): Promise<VerifyResult<z.output<TSchema>>> {
let obj: any = data;
if (data instanceof FormData) {
obj = Object.fromEntries(data.entries());
@@ -36,4 +39,4 @@ export default async function zodVerify<T>(schema: ZodType<T>, data: FormData |
success: true,
data: zod.data,
};
}
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Channel" ADD COLUMN "notifChannels" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -57,6 +57,7 @@ model Channel {
name String @unique
description String @default("A hctv channel")
pfpUrl String
notifChannels String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt