mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat(notif): initial custom channel sending
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -50,4 +50,6 @@ slack-import-emojis/target
|
|||||||
|
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
/apps/docs/src/content/docs/typedoc-sdk
|
/apps/docs/src/content/docs/typedoc-sdk
|
||||||
|
|
||||||
|
.codex
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
editStreamInfo,
|
editStreamInfo,
|
||||||
changeUsername,
|
changeUsername,
|
||||||
updateChatModeration,
|
updateChatModeration,
|
||||||
|
updateNotificationChannels,
|
||||||
} from '@/lib/form/actions';
|
} from '@/lib/form/actions';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -80,6 +81,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
|
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
|
||||||
import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
|
import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
interface ChannelSettingsClientProps {
|
interface ChannelSettingsClientProps {
|
||||||
channel: Channel & {
|
channel: Channel & {
|
||||||
@@ -107,7 +109,6 @@ interface ChannelSettingsClientProps {
|
|||||||
export default function ChannelSettingsClient({
|
export default function ChannelSettingsClient({
|
||||||
channel,
|
channel,
|
||||||
isOwner,
|
isOwner,
|
||||||
currentUser,
|
|
||||||
isPersonal,
|
isPersonal,
|
||||||
}: ChannelSettingsClientProps) {
|
}: ChannelSettingsClientProps) {
|
||||||
const confirm = useConfirm();
|
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(
|
const handleUsernameChangeComplete = useCallback(
|
||||||
(result: any) => {
|
(result: any) => {
|
||||||
if (result?.success && result?.newUsername) {
|
if (result?.success && result?.newUsername) {
|
||||||
@@ -276,9 +283,9 @@ export default function ChannelSettingsClient({
|
|||||||
<MessageSquareWarning className="h-4 w-4" />
|
<MessageSquareWarning className="h-4 w-4" />
|
||||||
Moderation
|
Moderation
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="utilities" className="flex items-center gap-2">
|
<TabsTrigger value="integrations" className="flex items-center gap-2">
|
||||||
<Wrench className="size-4" />
|
<Wrench className="size-4" />
|
||||||
Utilities
|
Integrations
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -1053,11 +1060,13 @@ export default function ChannelSettingsClient({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="utilities">
|
<TabsContent value="integrations">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Utilities</CardTitle>
|
<CardTitle>Integrations</CardTitle>
|
||||||
<CardDescription>OBS overlays, APIs... everything in one neat place!</CardDescription>
|
<CardDescription>
|
||||||
|
OBS overlays, Slack... everything in one neat place!
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-4">
|
<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"
|
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => setKeyVisible(!keyVisible)}>
|
<Button
|
||||||
{keyVisible ? 'Hide' : 'Show'}
|
variant="outline"
|
||||||
|
size="smicon"
|
||||||
|
onClick={() => setKeyVisible(!keyVisible)}
|
||||||
|
>
|
||||||
|
{keyVisible ? <EyeOff /> : <Eye />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
streamInfoEditSchema,
|
streamInfoEditSchema,
|
||||||
updateChatModerationSchema,
|
updateChatModerationSchema,
|
||||||
updateChannelSettingsSchema,
|
updateChannelSettingsSchema,
|
||||||
|
updateNotificationChannelsSchema,
|
||||||
} from '@/lib/form/zod';
|
} from '@/lib/form/zod';
|
||||||
|
|
||||||
export const schemaDb = [
|
export const schemaDb = [
|
||||||
@@ -39,6 +40,7 @@ export const schemaDb = [
|
|||||||
{ name: 'editBot', zod: editBotSchema },
|
{ name: 'editBot', zod: editBotSchema },
|
||||||
{ name: 'changeUsername', zod: changeUsernameSchema },
|
{ name: 'changeUsername', zod: changeUsernameSchema },
|
||||||
{ name: 'updateChatModeration', zod: updateChatModerationSchema },
|
{ name: 'updateChatModeration', zod: updateChatModerationSchema },
|
||||||
|
{ name: 'updateNotificationChannels', zod: updateNotificationChannelsSchema },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function UniversalForm<T extends z.ZodType>({
|
export function UniversalForm<T extends z.ZodType>({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
streamInfoEditSchema,
|
streamInfoEditSchema,
|
||||||
updateChatModerationSchema,
|
updateChatModerationSchema,
|
||||||
updateChannelSettingsSchema,
|
updateChannelSettingsSchema,
|
||||||
|
updateNotificationChannelsSchema,
|
||||||
} from './zod';
|
} from './zod';
|
||||||
import { initializeStreamInfo } from '../instrumentation/streamInfo';
|
import { initializeStreamInfo } from '../instrumentation/streamInfo';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
import { can } from '../auth/abac';
|
import { can } from '../auth/abac';
|
||||||
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
|
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
|
||||||
import { generateStreamKey } from '../db/streamKey';
|
import { generateStreamKey } from '../db/streamKey';
|
||||||
|
import slackNotifierClient from '../services/slackNotifier';
|
||||||
|
|
||||||
export async function editStreamInfo(prev: any, formData: FormData) {
|
export async function editStreamInfo(prev: any, formData: FormData) {
|
||||||
const { user } = await validateRequest();
|
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.' };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,3 +58,24 @@ export const changeUsernameSchema = z.object({
|
|||||||
channelId: z.string().min(1),
|
channelId: z.string().min(1),
|
||||||
newUsername: username,
|
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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { ZodType } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
type SuccessResult<T> = {
|
type SuccessResult<T> = {
|
||||||
success: true;
|
success: true;
|
||||||
@@ -14,7 +14,10 @@ type ErrorResult = {
|
|||||||
|
|
||||||
type VerifyResult<T> = SuccessResult<T> | 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;
|
let obj: any = data;
|
||||||
if (data instanceof FormData) {
|
if (data instanceof FormData) {
|
||||||
obj = Object.fromEntries(data.entries());
|
obj = Object.fromEntries(data.entries());
|
||||||
@@ -36,4 +39,4 @@ export default async function zodVerify<T>(schema: ZodType<T>, data: FormData |
|
|||||||
success: true,
|
success: true,
|
||||||
data: zod.data,
|
data: zod.data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Channel" ADD COLUMN "notifChannels" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -57,6 +57,7 @@ model Channel {
|
|||||||
name String @unique
|
name String @unique
|
||||||
description String @default("A hctv channel")
|
description String @default("A hctv channel")
|
||||||
pfpUrl String
|
pfpUrl String
|
||||||
|
notifChannels String[] @default([])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
Reference in New Issue
Block a user