feat(chat): chat moderation

This commit is contained in:
2026-02-20 21:42:18 +01:00
parent a75d9e3795
commit 107982dbec
18 changed files with 1810 additions and 103 deletions

View File

@@ -5,16 +5,238 @@ import { readFile } from 'node:fs/promises';
import { lucia } from '@hctv/auth';
import { getCookie } from 'hono/cookie';
import { getPersonalChannel } from './utils/personalChannel.js';
import { getRedisConnection, prisma, type BotAccount, type BotApiKey, type User } from '@hctv/db';
import { ChatModerationAction, getRedisConnection, prisma } from '@hctv/db';
import uFuzzy from '@leeoniya/ufuzzy';
import { randomString } from './utils/randomString.js';
const redis = getRedisConnection();
const MESSAGE_HISTORY_SIZE = 15;
const MESSAGE_HISTORY_SIZE = 100;
const MESSAGE_TTL = 60 * 60 * 24;
const MODERATION_SETTINGS_CACHE_TTL_SECONDS = 30;
const threed = await readFile('./src/3d.txt', 'utf-8');
const uf = new uFuzzy();
const DEFAULT_MODERATION_SETTINGS: ChatModerationSettingsShape = {
blockedTerms: [],
slowModeSeconds: 0,
maxMessageLength: 400,
rateLimitCount: 8,
rateLimitWindowSeconds: 10,
};
function normalizeModerationSettings(
settings?: Partial<ChatModerationSettingsShape> | null
): ChatModerationSettingsShape {
return {
blockedTerms:
settings?.blockedTerms
?.map((term) => term.trim().toLowerCase())
.filter((term) => term.length >= 2)
.slice(0, 200) ?? [],
slowModeSeconds: Math.min(Math.max(settings?.slowModeSeconds ?? 0, 0), 120),
maxMessageLength: Math.min(Math.max(settings?.maxMessageLength ?? 50, 50), 2000),
rateLimitCount: Math.min(Math.max(settings?.rateLimitCount ?? 8, 3), 30),
rateLimitWindowSeconds: Math.min(Math.max(settings?.rateLimitWindowSeconds ?? 10, 5), 60),
};
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function containsBlockedTerm(message: string, blockedTerms: string[]): string | null {
const normalizedMessage = message.toLowerCase();
for (const term of blockedTerms) {
const regex = new RegExp(`(^|\\W)${escapeRegExp(term)}($|\\W)`, 'i');
if (regex.test(normalizedMessage)) {
return term;
}
}
return null;
}
async function getCachedModerationSettings(
channelId: string
): Promise<ChatModerationSettingsShape> {
const cacheKey = `chat:moderation:settings:${channelId}`;
const cachedSettings = await redis.get(cacheKey);
if (cachedSettings) {
try {
return normalizeModerationSettings(JSON.parse(cachedSettings));
} catch {
await redis.del(cacheKey);
}
}
const dbSettings = await prisma.chatModerationSettings.findUnique({
where: { channelId },
select: {
blockedTerms: true,
slowModeSeconds: true,
maxMessageLength: true,
rateLimitCount: true,
rateLimitWindowSeconds: true,
},
});
const normalized = normalizeModerationSettings(dbSettings ?? DEFAULT_MODERATION_SETTINGS);
await redis.setex(cacheKey, MODERATION_SETTINGS_CACHE_TTL_SECONDS, JSON.stringify(normalized));
return normalized;
}
function resolveSocketState(socket: ChatSocket): ChatSocket {
return (socket.raw as unknown as ChatSocket | undefined) ?? socket;
}
function broadcastToChannel(
targetUsername: string,
ws: ChatSocket,
payload: Record<string, unknown>
) {
ws.wss.clients.forEach((clientSocket: unknown) => {
const client = clientSocket as ChatSocket;
const clientState = resolveSocketState(client);
if (client.readyState === client.OPEN && clientState.targetUsername === targetUsername) {
client.send(JSON.stringify(payload));
}
});
}
async function getActiveRestriction(
channelId: string,
userId: string
): Promise<ChatRestrictionState | null> {
const activeBan = await prisma.chatUserBan.findUnique({
where: {
channelId_userId: {
channelId,
userId,
},
},
select: {
reason: true,
expiresAt: true,
},
});
if (!activeBan) {
return null;
}
if (activeBan.expiresAt && activeBan.expiresAt < new Date()) {
await prisma.chatUserBan.delete({
where: {
channelId_userId: {
channelId,
userId,
},
},
});
return null;
}
return {
type: activeBan.expiresAt ? 'timeout' : 'ban',
reason: activeBan.reason,
expiresAt: activeBan.expiresAt,
};
}
async function sendChatAccessState(socket: ChatSocket, channelId: string, userId: string) {
const restriction = await getActiveRestriction(channelId, userId);
socket.send(
JSON.stringify({
type: 'chatAccess',
canSend: !restriction,
restriction,
})
);
}
async function broadcastRestrictionStateToUser(
targetUsername: string,
targetUserId: string,
channelId: string,
ws: ChatSocket
) {
const restriction = await getActiveRestriction(channelId, targetUserId);
ws.wss.clients.forEach((clientSocket: unknown) => {
const client = clientSocket as ChatSocket;
const clientState = resolveSocketState(client);
if (
client.readyState === client.OPEN &&
clientState.targetUsername === targetUsername &&
clientState.chatUser?.id === targetUserId
) {
client.send(
JSON.stringify({
type: 'chatAccess',
canSend: !restriction,
restriction,
})
);
}
});
}
async function isRateLimited(
channelId: string,
userId: string,
count: number,
windowSeconds: number
): Promise<boolean> {
const key = `chat:ratelimit:${channelId}:${userId}`;
const currentCount = await redis.incr(key);
if (currentCount === 1) {
await redis.expire(key, windowSeconds);
}
return currentCount > count;
}
async function logModerationEvent(payload: {
action: ChatModerationAction;
channelId: string;
moderatorId: string;
targetUserId?: string;
reason?: string;
details?: Record<string, unknown>;
}) {
await prisma.chatModerationEvent.create({
data: {
action: payload.action,
channelId: payload.channelId,
moderatorId: payload.moderatorId,
targetUserId: payload.targetUserId,
reason: payload.reason,
details: payload.details as any,
},
});
}
async function deleteMessageFromHistory(targetUsername: string, msgId: string): Promise<boolean> {
const channelKey = `chat:history:${targetUsername}`;
const history = await redis.zrange(channelKey, 0, -1);
for (const entry of history) {
try {
const parsed = JSON.parse(entry) as { msgId?: string };
if (parsed.msgId === msgId) {
await redis.zrem(channelKey, entry);
return true;
}
} catch {
continue;
}
}
return false;
}
const app = new Hono();
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
@@ -111,28 +333,79 @@ app.get(
return;
}
if (await prisma.channel.count({ where: { name: username } }) === 0) {
// channel doesn't exist
const channel = await prisma.channel.findUnique({
where: { name: username },
select: {
id: true,
ownerId: true,
managers: {
select: {
id: true,
},
},
},
});
if (!channel) {
ws.close();
return;
}
ws.targetUsername = username;
ws.chatUser = chatUser;
ws.personalChannel = personalChannel;
ws.viewerId = randomString(10);
const isModerator = Boolean(
chatUser &&
!chatUser.isBot &&
(channel.ownerId === chatUser.id ||
channel.managers.some((manager) => manager.id === chatUser.id))
);
if (ws.raw) {
ws.raw.targetUsername = username;
ws.raw.chatUser = chatUser;
ws.raw.personalChannel = personalChannel;
const moderationSettings = await getCachedModerationSettings(channel.id);
const socket = ws as unknown as ChatSocket;
const socketState = resolveSocketState(socket);
socket.targetUsername = username;
socket.channelId = channel.id;
socket.chatUser = chatUser;
socket.personalChannel = personalChannel;
socket.viewerId = randomString(10);
socket.isModerator = isModerator;
socketState.targetUsername = username;
socketState.channelId = channel.id;
socketState.chatUser = chatUser;
socketState.personalChannel = personalChannel;
socketState.viewerId = socket.viewerId;
socketState.isModerator = isModerator;
socket.send(
JSON.stringify({
type: 'session',
viewer: chatUser
? {
id: chatUser.id,
username: chatUser.username,
}
: null,
permissions: {
canModerate: isModerator,
},
moderation: {
hasBlockedTerms: moderationSettings.blockedTerms.length > 0,
slowModeSeconds: moderationSettings.slowModeSeconds,
maxMessageLength: moderationSettings.maxMessageLength,
},
})
);
if (chatUser && !chatUser.isBot) {
await sendChatAccessState(socket, channel.id, chatUser.id);
}
const channelKey = `chat:history:${username}`;
const messages = await redis.zrange(channelKey, 0, MESSAGE_HISTORY_SIZE - 1);
if (messages.length > 0) {
ws.send(
socket.send(
JSON.stringify({
type: 'history',
messages: messages.map((msg) => JSON.parse(msg)),
@@ -141,13 +414,15 @@ app.get(
}
},
async onClose(evt, ws) {
const socket = ws as unknown as ChatSocket;
const socketState = resolveSocketState(socket);
// if prematurely exiting due to authentication issues
console.log('client disconnected');
if (!ws.targetUsername) return;
if (!socketState.targetUsername) return;
const streamInfo = await prisma.streamInfo.findUnique({
where: {
username: ws.targetUsername,
username: socketState.targetUsername,
},
select: {
viewers: true,
@@ -156,55 +431,340 @@ app.get(
if (!streamInfo) return;
await redis.del(`viewer:${ws.targetUsername}:${ws.viewerId}`);
await redis.del(`viewer:${socketState.targetUsername}:${socketState.viewerId}`);
},
async onMessage(evt, ws) {
try {
const socket = ws as unknown as ChatSocket;
const socketState = resolveSocketState(socket);
const msg = JSON.parse(evt.data.toString());
if (msg.type === 'ping') {
await redis.setex(`viewer:${ws.targetUsername}:${ws.viewerId}`, 30, '1');
ws.send(JSON.stringify({ type: 'pong' }));
await redis.setex(
`viewer:${socketState.targetUsername}:${socketState.viewerId}`,
30,
'1'
);
socket.send(JSON.stringify({ type: 'pong' }));
return;
}
if (msg.type === 'mod:deleteMessage') {
if (
!socketState.isModerator ||
!socketState.chatUser ||
!socketState.targetUsername ||
!socketState.channelId
) {
return;
}
const msgId = typeof msg.msgId === 'string' ? msg.msgId : '';
if (!msgId) {
socket.send(
JSON.stringify({
type: 'moderationError',
code: 'INVALID_REQUEST',
message: 'Invalid message id.',
})
);
return;
}
const deleted = await deleteMessageFromHistory(socketState.targetUsername, msgId);
if (!deleted) {
socket.send(
JSON.stringify({
type: 'moderationError',
code: 'NOT_FOUND',
message: 'Message not found.',
})
);
return;
}
await logModerationEvent({
action: ChatModerationAction.MESSAGE_DELETED,
channelId: socketState.channelId,
moderatorId: socketState.chatUser.id,
reason: 'Message deleted by moderator',
details: { msgId },
});
broadcastToChannel(socketState.targetUsername, socket, { type: 'messageDeleted', msgId });
return;
}
if (
msg.type === 'mod:timeoutUser' ||
msg.type === 'mod:banUser' ||
msg.type === 'mod:unbanUser' ||
msg.type === 'mod:liftTimeout'
) {
if (
!socketState.isModerator ||
!socketState.chatUser ||
!socketState.targetUsername ||
!socketState.channelId
) {
return;
}
const targetUserId = typeof msg.targetUserId === 'string' ? msg.targetUserId : '';
const targetUsername =
typeof msg.targetUsername === 'string' ? msg.targetUsername : 'that user';
if (!targetUserId || targetUserId === socketState.chatUser.id) {
socket.send(
JSON.stringify({
type: 'moderationError',
code: 'INVALID_TARGET',
message: 'Invalid moderation target.',
})
);
return;
}
const targetUserExists = await prisma.user.count({ where: { id: targetUserId } });
if (targetUserExists === 0) {
socket.send(
JSON.stringify({
type: 'moderationError',
code: 'INVALID_TARGET',
message: 'Target user no longer exists.',
})
);
return;
}
if (msg.type === 'mod:unbanUser' || msg.type === 'mod:liftTimeout') {
await prisma.chatUserBan.deleteMany({
where: {
channelId: socketState.channelId,
userId: targetUserId,
},
});
await logModerationEvent({
action: ChatModerationAction.USER_UNBANNED,
channelId: socketState.channelId,
moderatorId: socketState.chatUser.id,
targetUserId,
reason: 'User unbanned in chat',
});
await broadcastRestrictionStateToUser(
socketState.targetUsername,
targetUserId,
socketState.channelId,
socket
);
broadcastToChannel(socketState.targetUsername, socket, {
type: 'systemMsg',
message: `${targetUsername} can chat again.`,
});
return;
}
const reason =
typeof msg.reason === 'string' && msg.reason.trim().length > 0
? msg.reason.trim().slice(0, 250)
: msg.type === 'mod:timeoutUser'
? 'Timed out by moderator'
: 'Banned by moderator';
const durationSeconds =
msg.type === 'mod:timeoutUser'
? Math.min(Math.max(Number(msg.durationSeconds) || 300, 10), 60 * 60 * 24)
: null;
const expiresAt = durationSeconds ? new Date(Date.now() + durationSeconds * 1000) : null;
await prisma.chatUserBan.upsert({
where: {
channelId_userId: {
channelId: socketState.channelId,
userId: targetUserId,
},
},
create: {
channelId: socketState.channelId,
userId: targetUserId,
bannedById: socketState.chatUser.id,
reason,
expiresAt,
},
update: {
bannedById: socketState.chatUser.id,
reason,
expiresAt,
},
});
await logModerationEvent({
action:
msg.type === 'mod:timeoutUser'
? ChatModerationAction.USER_TIMEOUT
: ChatModerationAction.USER_BANNED,
channelId: socketState.channelId,
moderatorId: socketState.chatUser.id,
targetUserId,
reason,
details: durationSeconds ? { durationSeconds } : undefined,
});
await broadcastRestrictionStateToUser(
socketState.targetUsername,
targetUserId,
socketState.channelId,
socket
);
broadcastToChannel(socketState.targetUsername, socket, {
type: 'systemMsg',
message:
msg.type === 'mod:timeoutUser'
? `${targetUsername} was timed out for ${durationSeconds}s.`
: `${targetUsername} was banned.`,
});
return;
}
if (msg.type === 'message') {
if (!ws.chatUser || !ws.personalChannel) return;
if (
!socketState.chatUser ||
!socketState.personalChannel ||
!socketState.channelId ||
!socketState.targetUsername
) {
return;
}
const chatUser = socketState.chatUser;
const channelId = socketState.channelId;
const targetUsername = socketState.targetUsername;
const isModerator = Boolean(socketState.isModerator);
if (!chatUser || !channelId || !targetUsername) {
return;
}
const moderationSettings = await getCachedModerationSettings(channelId);
const restriction = await getActiveRestriction(channelId, chatUser.id);
if (restriction) {
socket.send(
JSON.stringify({
type: 'moderationError',
code: restriction.type === 'timeout' ? 'TIMED_OUT' : 'BANNED',
message:
restriction.type === 'timeout'
? 'You are currently timed out in this chat.'
: 'You are currently banned from this chat.',
restriction,
})
);
await sendChatAccessState(socket, channelId, chatUser.id);
return;
}
if (
!isModerator &&
(await isRateLimited(
channelId,
chatUser.id,
moderationSettings.rateLimitCount,
moderationSettings.rateLimitWindowSeconds
))
) {
socket.send(
JSON.stringify({
type: 'moderationError',
code: 'RATE_LIMIT',
message: 'You are sending messages too fast.',
})
);
return;
}
if (!isModerator && moderationSettings.slowModeSeconds > 0) {
const slowModeKey = `chat:slowmode:${channelId}:${chatUser.id}`;
const timeRemaining = await redis.ttl(slowModeKey);
if (timeRemaining > 0) {
socket.send(
JSON.stringify({
type: 'moderationError',
code: 'SLOW_MODE',
message: `Slow mode is on. Wait ${timeRemaining}s.`,
})
);
return;
}
await redis.setex(slowModeKey, moderationSettings.slowModeSeconds, '1');
}
const message = (msg.message as string).trim();
if (!message) {
return;
}
if (message.length > moderationSettings.maxMessageLength) {
socket.send(
JSON.stringify({
type: 'moderationError',
code: 'MESSAGE_TOO_LONG',
message: `Message exceeds ${moderationSettings.maxMessageLength} characters.`,
})
);
return;
}
const blockedTerm = containsBlockedTerm(message, moderationSettings.blockedTerms);
if (blockedTerm) {
if (!chatUser.isBot) {
await logModerationEvent({
action: ChatModerationAction.MESSAGE_BLOCKED,
channelId,
moderatorId: chatUser.id,
targetUserId: chatUser.id,
reason: 'Blocked term matched',
details: { blockedTerm },
});
}
socket.send(
JSON.stringify({
type: 'moderationError',
code: 'BLOCKED_TERM',
message: 'Message blocked by channel moderation.',
})
);
return;
}
const msgId = crypto.randomUUID();
const msgObj = {
user: {
id: ws.chatUser.id,
username: ws.chatUser.username,
pfpUrl: ws.chatUser.pfpUrl,
displayName: ws.chatUser.displayName,
isBot: ws.chatUser.isBot || false,
id: chatUser.id,
username: chatUser.username,
pfpUrl: chatUser.pfpUrl,
displayName: chatUser.displayName,
isBot: chatUser.isBot || false,
},
message,
msgId: `${crypto.randomUUID()}`
};
const redisObj = {
user: msgObj.user,
message: msgObj.message,
msgId,
type: 'message',
msgId: `${crypto.randomUUID()}`,
};
const redisStr = JSON.stringify(redisObj);
const redisStr = JSON.stringify(msgObj);
const msgStr = JSON.stringify(msgObj);
const channelKey = `chat:history:${ws.targetUsername}`;
const channelKey = `chat:history:${targetUsername}`;
await redis.zadd(channelKey, Date.now(), redisStr);
await redis.zremrangebyrank(channelKey, 0, -MESSAGE_HISTORY_SIZE - 1);
await redis.expire(channelKey, MESSAGE_TTL);
ws.wss.clients.forEach((c) => {
const client = c as ModifiedWebSocket;
if (client.readyState === client.OPEN && client.targetUsername === ws.targetUsername) {
c.send(msgStr);
}
});
broadcastToChannel(targetUsername, socket, msgObj as unknown as Record<string, unknown>);
}
if (msg.type === 'emojiMsg') {
const emojis = msg.emojis as string[];
@@ -297,3 +857,42 @@ interface ChatUser {
displayName?: string;
isBot: boolean;
}
interface ChatModerationSettingsShape {
blockedTerms: string[];
slowModeSeconds: number;
maxMessageLength: number;
rateLimitCount: number;
rateLimitWindowSeconds: number;
}
interface ChatRestrictionState {
type: 'timeout' | 'ban';
reason: string;
expiresAt: Date | null;
}
interface ChatSocket {
readyState: number;
OPEN: number;
send: (data: string) => void;
close: () => void;
wss: {
clients: Set<unknown>;
};
targetUsername?: string;
channelId?: string;
chatUser?: ChatUser | null;
personalChannel?: any;
viewerId?: string;
isModerator?: boolean;
raw?:
| (ModifiedWebSocket & {
targetUsername?: string;
channelId?: string;
chatUser?: ChatUser | null;
personalChannel?: any;
isModerator?: boolean;
})
| null;
}

View File

@@ -21,6 +21,7 @@ import {
ShieldCheck,
ShieldMinus,
X,
ClipboardList,
} from 'lucide-react';
import {
Dialog,
@@ -49,6 +50,8 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
const [channels, setChannels] = useState<ChannelWithRestriction[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
const [channelsLoading, setChannelsLoading] = useState(false);
const [auditLoading, setAuditLoading] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [banDialogOpen, setBanDialogOpen] = useState(false);
const [restrictDialogOpen, setRestrictDialogOpen] = useState(false);
@@ -85,10 +88,25 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
}
}, []);
const fetchAuditLogs = useCallback(async () => {
setAuditLoading(true);
try {
const res = await fetch('/api/admin/audit?take=200');
if (res.ok) {
setAuditLogs(await res.json());
}
} catch {
toast.error('Failed to fetch audit logs');
} finally {
setAuditLoading(false);
}
}, []);
useEffect(() => {
fetchUsers('');
fetchChannels('');
}, [fetchUsers, fetchChannels]);
fetchAuditLogs();
}, [fetchUsers, fetchChannels, fetchAuditLogs]);
useEffect(() => {
const timer = setTimeout(() => {
@@ -132,6 +150,7 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
if (res.ok) {
toast.success('User banned successfully');
fetchUsers(userSearch);
fetchAuditLogs();
setBanDialogOpen(false);
resetDialogState();
} else {
@@ -157,6 +176,7 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
if (res.ok) {
toast.success('User unbanned successfully');
fetchUsers(userSearch);
fetchAuditLogs();
} else {
toast.error('Failed to unban user');
}
@@ -186,6 +206,7 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
if (res.ok) {
toast.success('Channel restricted successfully');
fetchChannels(channelSearch);
fetchAuditLogs();
setRestrictDialogOpen(false);
resetDialogState();
} else {
@@ -211,6 +232,7 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
if (res.ok) {
toast.success('Channel unrestricted successfully');
fetchChannels(channelSearch);
fetchAuditLogs();
} else {
toast.error('Failed to unrestrict channel');
}
@@ -233,6 +255,7 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
if (res.ok) {
toast.success('User promoted to admin');
fetchUsers(userSearch);
fetchAuditLogs();
} else {
const err = await res.text();
toast.error(err || 'Failed to promote user');
@@ -256,6 +279,7 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
if (res.ok) {
toast.success('User demoted from admin');
fetchUsers(userSearch);
fetchAuditLogs();
} else {
const err = await res.text();
toast.error(err || 'Failed to demote user');
@@ -268,14 +292,12 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
return (
<div className="container max-w-6xl mx-auto py-6 px-4">
<div className="mb-6">
<h1 className="text-3xl font-bold flex items-center gap-2">
Admin Panel
</h1>
<h1 className="text-3xl font-bold flex items-center gap-2">Admin Panel</h1>
<p className="text-muted-foreground">Manage users and channels on the platform</p>
</div>
<Tabs defaultValue="users" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4" />
Users
@@ -284,6 +306,10 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
<Tv className="h-4 w-4" />
Channels
</TabsTrigger>
<TabsTrigger value="audit" className="flex items-center gap-2">
<ClipboardList className="h-4 w-4" />
Audit Log
</TabsTrigger>
</TabsList>
<TabsContent value="users">
@@ -340,18 +366,14 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">
{user.personalChannel?.name}
</p>
<p className="font-medium">{user.personalChannel?.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{user.isAdmin && (
<Badge variant="default">Admin</Badge>
)}
{user.isAdmin && <Badge variant="default">Admin</Badge>}
{user.ban ? (
<Badge variant="destructive" className="flex items-center gap-1">
<Ban className="h-3 w-3" />
@@ -478,14 +500,14 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={channel.pfpUrl} />
<AvatarFallback>
{channel.name[0]?.toUpperCase()}
</AvatarFallback>
<AvatarFallback>{channel.name[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{channel.name}</p>
{channel.personalFor && (
<Badge variant="outline" className="text-xs">Personal</Badge>
<Badge variant="outline" className="text-xs">
Personal
</Badge>
)}
</div>
</div>
@@ -512,7 +534,9 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
<div className="text-xs text-muted-foreground mt-1">
<p>Reason: {channel.restriction.reason}</p>
{channel.restriction.expiresAt && (
<p>Expires: {format(new Date(channel.restriction.expiresAt), 'PPP')}</p>
<p>
Expires: {format(new Date(channel.restriction.expiresAt), 'PPP')}
</p>
)}
</div>
)}
@@ -549,12 +573,81 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
</CardContent>
</Card>
</TabsContent>
<TabsContent value="audit">
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Audit Log</CardTitle>
<CardDescription>
Organization-wide moderation and admin actions across all admins.
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={fetchAuditLogs}>
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Source</TableHead>
<TableHead>Action</TableHead>
<TableHead>Target</TableHead>
<TableHead>Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditLoading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : auditLogs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8">
No audit logs yet
</TableCell>
</TableRow>
) : (
auditLogs.map((log) => (
<TableRow key={`${log.source}-${log.id}`}>
<TableCell className="text-xs text-muted-foreground">
{format(new Date(log.createdAt), 'PPP p')}
</TableCell>
<TableCell>{log.actor}</TableCell>
<TableCell>
<Badge variant={log.source === 'chat' ? 'secondary' : 'outline'}>
{log.source}
</Badge>
</TableCell>
<TableCell>
<code className="text-xs">{log.action}</code>
</TableCell>
<TableCell>{log.target ?? '-'}</TableCell>
<TableCell className="text-muted-foreground">{log.reason ?? '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Dialog open={banDialogOpen} onOpenChange={(open) => {
setBanDialogOpen(open);
if (!open) resetDialogState();
}}>
<Dialog
open={banDialogOpen}
onOpenChange={(open) => {
setBanDialogOpen(open);
if (!open) resetDialogState();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Ban User</DialogTitle>
@@ -575,9 +668,7 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
</div>
<div>
<label className="text-sm font-medium">Expires (optional)</label>
<p className="text-xs text-muted-foreground mb-2">
Leave empty for a permanent ban
</p>
<p className="text-xs text-muted-foreground mb-2">Leave empty for a permanent ban</p>
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
@@ -626,22 +717,21 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
</PopoverContent>
</Popover>
{expiresAt && (
<Button
variant="ghost"
size="icon"
onClick={() => setExpiresAt(undefined)}
>
<X className='w-4 h-4' />
<Button variant="ghost" size="icon" onClick={() => setExpiresAt(undefined)}>
<X className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setBanDialogOpen(false);
resetDialogState();
}}>
<Button
variant="outline"
onClick={() => {
setBanDialogOpen(false);
resetDialogState();
}}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleBanUser}>
@@ -651,10 +741,13 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
</DialogContent>
</Dialog>
<Dialog open={restrictDialogOpen} onOpenChange={(open) => {
setRestrictDialogOpen(open);
if (!open) resetDialogState();
}}>
<Dialog
open={restrictDialogOpen}
onOpenChange={(open) => {
setRestrictDialogOpen(open);
if (!open) resetDialogState();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Restrict Channel</DialogTitle>
@@ -726,22 +819,21 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
</PopoverContent>
</Popover>
{expiresAt && (
<Button
variant="ghost"
size="icon"
onClick={() => setExpiresAt(undefined)}
>
<X className='w-4 h-4' />
<Button variant="ghost" size="icon" onClick={() => setExpiresAt(undefined)}>
<X className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setRestrictDialogOpen(false);
resetDialogState();
}}>
<Button
variant="outline"
onClick={() => {
setRestrictDialogOpen(false);
resetDialogState();
}}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleRestrictChannel}>
@@ -786,7 +878,18 @@ interface ChannelWithRestriction {
} | null;
}
interface AuditLog {
id: string;
source: 'platform' | 'chat';
action: string;
createdAt: string;
actor: string;
target: string | null;
reason: string | null;
details?: unknown;
channelName?: string;
}
interface AdminPanelClientProps {
currentUser: User;
}

View File

@@ -0,0 +1,118 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const searchParams = request.nextUrl.searchParams;
const take = Math.min(Math.max(Number(searchParams.get('take') ?? 100), 10), 250);
const [adminLogs, chatLogs] = await Promise.all([
prisma.adminAuditLog.findMany({
orderBy: { createdAt: 'desc' },
take,
include: {
actor: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
},
}),
prisma.chatModerationEvent.findMany({
orderBy: { createdAt: 'desc' },
take,
include: {
channel: {
select: {
name: true,
},
},
moderator: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
targetUser: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
},
}),
]);
const targetUserIds = [
...new Set(adminLogs.map((log) => log.targetUserId).filter(Boolean)),
] as string[];
const targetUsers =
targetUserIds.length > 0
? await prisma.user.findMany({
where: {
id: {
in: targetUserIds,
},
},
include: {
personalChannel: {
select: {
name: true,
},
},
},
})
: [];
const targetUserMap = new Map(
targetUsers.map((targetUser) => [
targetUser.id,
targetUser.personalChannel?.name ?? targetUser.slack_id,
])
);
const normalizedAdminLogs = adminLogs.map((log) => ({
id: log.id,
source: 'platform' as const,
action: log.action,
createdAt: log.createdAt,
actor: log.actor.personalChannel?.name ?? log.actor.slack_id,
target:
log.targetChannel ??
(log.targetUserId ? (targetUserMap.get(log.targetUserId) ?? log.targetUserId) : null),
reason: log.reason,
details: log.details,
}));
const normalizedChatLogs = chatLogs.map((log) => ({
id: log.id,
source: 'chat' as const,
action: log.action,
createdAt: log.createdAt,
actor: log.moderator.personalChannel?.name ?? log.moderator.slack_id,
target: log.targetUser?.personalChannel?.name ?? log.channel.name,
reason: log.reason,
details: log.details,
channelName: log.channel.name,
}));
const logs = [...normalizedAdminLogs, ...normalizedChatLogs]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, take);
return Response.json(logs);
}

View File

@@ -1,5 +1,5 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { AdminAuditAction, prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
@@ -14,16 +14,21 @@ export async function GET(request: NextRequest) {
const channels = await prisma.channel.findMany({
where: search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
],
}
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
],
}
: undefined,
include: {
restriction: true,
owner: {
select: { id: true, slack_id: true, pfpUrl: true, personalChannel: { select: { name: true } } },
select: {
id: true,
slack_id: true,
pfpUrl: true,
personalChannel: { select: { name: true } },
},
},
personalFor: {
select: {
@@ -79,11 +84,36 @@ export async function POST(request: NextRequest) {
},
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.CHANNEL_RESTRICTED,
actorId: user.id,
targetChannel: channel.name,
reason,
details: {
channelId,
expiresAt: expiresAt ?? null,
} as any,
},
});
return Response.json({ success: true, message: 'Channel restricted' });
}
if (action === 'unrestrict') {
await prisma.channelRestriction.delete({ where: { channelId } }).catch(() => { });
await prisma.channelRestriction.delete({ where: { channelId } }).catch(() => {});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.CHANNEL_UNRESTRICTED,
actorId: user.id,
targetChannel: channel.name,
details: {
channelId,
} as any,
},
});
return Response.json({ success: true, message: 'Channel unrestricted' });
}

View File

@@ -1,5 +1,5 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { AdminAuditAction, prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
@@ -78,11 +78,32 @@ export async function POST(request: NextRequest) {
},
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.USER_BANNED,
actorId: user.id,
targetUserId: userId,
reason,
details: {
expiresAt: expiresAt ?? null,
} as any,
},
});
return Response.json({ success: true, message: 'User banned' });
}
if (action === 'unban') {
await prisma.userBan.delete({ where: { userId } }).catch(() => {});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.USER_UNBANNED,
actorId: user.id,
targetUserId: userId,
},
});
return Response.json({ success: true, message: 'User unbanned' });
}
@@ -96,6 +117,14 @@ export async function POST(request: NextRequest) {
data: { isAdmin: true },
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.USER_PROMOTED,
actorId: user.id,
targetUserId: userId,
},
});
return Response.json({ success: true, message: 'User promoted to admin' });
}
@@ -113,6 +142,14 @@ export async function POST(request: NextRequest) {
data: { isAdmin: false },
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.USER_DEMOTED,
actorId: user.id,
targetUserId: userId,
},
});
return Response.json({ success: true, message: 'User demoted from admin' });
}

View File

@@ -21,6 +21,7 @@ import {
Wrench,
Eye,
EyeOff,
MessageSquareWarning,
} from 'lucide-react';
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
import {
@@ -31,10 +32,18 @@ import {
toggleGlobalChannelNotifs,
editStreamInfo,
changeUsername,
updateChatModeration,
} from '@/lib/form/actions';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import type { Channel, User, StreamInfo, StreamKey, Follow } from '@hctv/db';
import type {
Channel,
User,
StreamInfo,
StreamKey,
Follow,
ChatModerationSettings,
} from '@hctv/db';
import {
Dialog,
DialogContent,
@@ -72,6 +81,7 @@ interface ChannelSettingsClientProps {
managerPersonalChannels: (Channel | null)[];
streamInfo: StreamInfo[];
streamKey: StreamKey | null;
chatSettings: ChatModerationSettings | null;
followers: (Follow & { user: { id: string; slack_id: string } })[];
followerPersonalChannels: (Channel | null)[];
is247: boolean;
@@ -114,6 +124,12 @@ export default function ChannelSettingsClient({
}
}, []);
const handleModerationActionComplete = useCallback((result: any) => {
if (result?.success) {
toast.success('Moderation settings updated');
}
}, []);
const handleUsernameChangeComplete = useCallback(
(result: any) => {
if (result?.success && result?.newUsername) {
@@ -224,7 +240,7 @@ export default function ChannelSettingsClient({
</div>
<Tabs className="w-full" value={selTab} onValueChange={setSelTab}>
<TabsList className={`grid w-full ${isPersonal ? 'grid-cols-4' : 'grid-cols-5'}`}>
<TabsList className={`grid w-full ${isPersonal ? 'grid-cols-5' : 'grid-cols-6'}`}>
<TabsTrigger value="general" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
General
@@ -243,6 +259,10 @@ export default function ChannelSettingsClient({
<Bell className="h-4 w-4" />
Notifications
</TabsTrigger>
<TabsTrigger value="moderation" className="flex items-center gap-2">
<MessageSquareWarning className="h-4 w-4" />
Moderation
</TabsTrigger>
<TabsTrigger value="utilities" className="flex items-center gap-2">
<Wrench className="size-4" />
Utilities
@@ -798,6 +818,66 @@ export default function ChannelSettingsClient({
</CardContent>
</Card>
</TabsContent>
<TabsContent value="moderation">
<Card>
<CardHeader>
<CardTitle>Chat Moderation</CardTitle>
<CardDescription>
Configure rate limits, slow mode, and blocked words for this channel&apos;s live
chat.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<UniversalForm
fields={[
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
{
name: 'slowModeSeconds',
label: 'Slow mode (seconds)',
type: 'number',
value: channel.chatSettings?.slowModeSeconds ?? 0,
description: 'Users can send one message per interval. Set 0 to disable.',
},
{
name: 'maxMessageLength',
label: 'Max message length',
type: 'number',
value: channel.chatSettings?.maxMessageLength ?? 400,
description: 'Maximum allowed message length in characters.',
},
{
name: 'rateLimitCount',
label: 'Messages per window',
type: 'number',
value: channel.chatSettings?.rateLimitCount ?? 8,
description: 'How many messages a user can send in the rate limit window.',
},
{
name: 'rateLimitWindowSeconds',
label: 'Rate window (seconds)',
type: 'number',
value: channel.chatSettings?.rateLimitWindowSeconds ?? 10,
description: 'Window size used for spam protection.',
},
{
name: 'blockedTerms',
label: 'Blocked terms',
value: (channel.chatSettings?.blockedTerms ?? []).join('\n'),
textArea: true,
textAreaRows: 8,
description:
'One term per line (or comma-separated). Messages containing these terms are blocked.',
},
]}
schemaName="updateChatModeration"
action={updateChatModeration}
submitText="Save moderation settings"
onActionComplete={handleModerationActionComplete}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="utilities">
<Card>
<CardHeader>

View File

@@ -24,6 +24,7 @@ export default async function ChannelSettingsPage({
managers: true,
streamInfo: true,
streamKey: true,
chatSettings: true,
followers: {
include: {
user: {

View File

@@ -9,6 +9,7 @@ import { Message } from './message';
import { useMap } from '@uidotdev/usehooks';
import { EmojiSearch } from './EmojiSearch';
import { useQueryState } from 'nuqs';
import { toast } from 'sonner';
export default function ChatPanel(props: Props) {
const { username } = useParams();
@@ -21,6 +22,12 @@ export default function ChatPanel(props: Props) {
const [emojisToReq, setEmojisToReq] = useState<string[]>([]);
const [cursorPosition, setCursorPosition] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [viewer, setViewer] = useState<{ id: string; username: string } | null>(null);
const [canModerate, setCanModerate] = useState(false);
const [chatAccess, setChatAccess] = useState<ChatAccessState>({
canSend: true,
restriction: null,
});
useEffect(() => {
console.log('Initializing WebSocket connection for user:', username);
@@ -50,6 +57,35 @@ export default function ChatPanel(props: Props) {
return;
}
if (data.type === 'session') {
setViewer(data.viewer ?? null);
setCanModerate(Boolean(data.permissions?.canModerate));
return;
}
if (data.type === 'chatAccess') {
setChatAccess({
canSend: Boolean(data.canSend),
restriction: data.restriction ?? null,
});
return;
}
if (data.type === 'systemMsg') {
setChatMessages((prev) => [...prev, { message: data.message, type: 'systemMsg' }]);
return;
}
if (data.type === 'messageDeleted') {
setChatMessages((prev) => prev.filter((message) => message.msgId !== data.msgId));
return;
}
if (data.type === 'moderationError') {
toast.error(data.message || 'Message blocked by moderation rules.');
return;
}
if (data.type === 'message') {
console.log('Adding new chat message:', data);
setChatMessages((prev) => [...prev, data]);
@@ -84,6 +120,14 @@ export default function ChatPanel(props: Props) {
}, [chatMessages]);
const sendMessage = () => {
if (!chatAccess.canSend) {
toast.error(
chatAccess.restriction?.type === 'timeout'
? 'You are currently timed out in this chat.'
: 'You are currently banned from this chat.'
);
return;
}
if (!message.trim()) return;
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
@@ -102,6 +146,15 @@ export default function ChatPanel(props: Props) {
}
};
const sendModerationCommand = (command: ChatModerationCommand) => {
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
toast.error('Chat connection is offline.');
return;
}
socketRef.current.send(JSON.stringify(command));
};
useEffect(() => {
const interval = setInterval(() => {
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
@@ -260,12 +313,23 @@ export default function ChatPanel(props: Props) {
message={msg.message}
type={msg.type}
emojiMap={emojiMap}
msgId={msg.msgId}
canModerate={canModerate && Boolean(viewer?.id)}
viewerId={viewer?.id}
onModerationCommand={sendModerationCommand}
/>
))}
</div>
</div>
{!props.isObsPanel && (
<div className="p-3 border-t border-border relative">
{!chatAccess.canSend && (
<p className="mb-2 text-xs text-destructive">
{chatAccess.restriction?.type === 'timeout'
? `Timed out${chatAccess.restriction.expiresAt ? ` until ${new Date(chatAccess.restriction.expiresAt).toLocaleTimeString()}` : ''}.`
: 'You are banned from sending messages in this chat.'}
</p>
)}
<div className="flex gap-2">
<Textarea
ref={textareaRef}
@@ -289,12 +353,13 @@ export default function ChatPanel(props: Props) {
placeholder="Send a message..."
className="flex-1 bg-background/50 border-border focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-0 min-h-[40px] max-h-[100px] resize-none py-2 text-sm"
rows={1}
disabled={!chatAccess.canSend}
/>
<Button
size="icon"
className="shrink-0 transition-colors"
onClick={sendMessage}
disabled={!message.trim()}
disabled={!message.trim() || !chatAccess.canSend}
>
<Send className="h-4 w-4" />
</Button>
@@ -317,6 +382,32 @@ export interface ChatMessage {
user?: User;
message: string;
type: 'message' | 'systemMsg';
msgId?: string;
}
export interface ChatModerationCommand {
type:
| 'mod:deleteMessage'
| 'mod:timeoutUser'
| 'mod:banUser'
| 'mod:unbanUser'
| 'mod:liftTimeout';
msgId?: string;
targetUserId?: string;
targetUsername?: string;
durationSeconds?: number;
reason?: string;
}
interface ChatAccessState {
canSend: boolean;
restriction: ChatRestriction | null;
}
interface ChatRestriction {
type: 'timeout' | 'ban';
reason?: string;
expiresAt?: string | null;
}
export interface User {

View File

@@ -1,10 +1,26 @@
import { User } from './ChatPanel';
import { ChatModerationCommand, User } from './ChatPanel';
import React from 'react';
import Image from 'next/image';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Bot } from 'lucide-react';
import { Ban, Bot, Clock3, EllipsisVertical, Eraser, UserRoundCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function Message({ user, message, type, emojiMap }: MessageProps) {
export function Message({
user,
message,
type,
emojiMap,
msgId,
canModerate,
viewerId,
onModerationCommand,
}: MessageProps) {
if (type === 'systemMsg') {
return (
<div className="flex items-center justify-center py-1">
@@ -13,6 +29,8 @@ export function Message({ user, message, type, emojiMap }: MessageProps) {
);
}
const canModerateTarget = type === 'message' && Boolean(user?.id) && !user?.isBot;
return (
<div className="group hover:bg-primary/5 rounded px-2 py-1 -mx-2 transition-colors">
<div className="flex items-start gap-2">
@@ -27,11 +45,113 @@ export function Message({ user, message, type, emojiMap }: MessageProps) {
>
<EmojiRenderer text={message} emojiMap={emojiMap} />
</span>
{canModerateTarget && user ? (
<ModerationMenu
user={user}
msgId={msgId}
canModerate={canModerate}
viewerId={viewerId}
onModerationCommand={onModerationCommand}
/>
) : null}
</div>
</div>
);
}
function ModerationMenu({
user,
msgId,
canModerate,
viewerId,
onModerationCommand,
}: {
user: User;
msgId?: string;
canModerate?: boolean;
viewerId?: string;
onModerationCommand?: (command: ChatModerationCommand) => void;
}) {
if (!canModerate || !viewerId || !user.id || user.id === viewerId || !onModerationCommand) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100">
<EllipsisVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={() => {
if (!msgId) return;
onModerationCommand({ type: 'mod:deleteMessage', msgId });
}}
>
<Eraser className="mr-2 h-4 w-4" />
Delete message
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand({
type: 'mod:timeoutUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
durationSeconds: 300,
reason: 'Timed out by moderator',
});
}}
>
<Clock3 className="mr-2 h-4 w-4" />
Timeout 5 min
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand({
type: 'mod:timeoutUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
durationSeconds: 3600,
reason: 'Timed out by moderator',
});
}}
>
<Clock3 className="mr-2 h-4 w-4" />
Timeout 1 hour
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
onModerationCommand({
type: 'mod:banUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
reason: 'Banned by moderator',
});
}}
>
<Ban className="mr-2 h-4 w-4" />
Ban user
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand({
type: 'mod:liftTimeout',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
});
}}
>
<UserRoundCheck className="mr-2 h-4 w-4" />
Lift timeout/ban
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
if (!text) return null;
@@ -81,6 +201,10 @@ interface MessageProps {
message: string;
type: 'message' | 'systemMsg';
emojiMap: Map<string, string>;
msgId?: string;
canModerate?: boolean;
viewerId?: string;
onModerationCommand?: (command: ChatModerationCommand) => void;
}
interface EmojiRendererProps {

View File

@@ -26,6 +26,7 @@ import {
editBotSchema,
onboardSchema,
streamInfoEditSchema,
updateChatModerationSchema,
updateChannelSettingsSchema,
} from '@/lib/form/zod';
@@ -37,6 +38,7 @@ export const schemaDb = [
{ name: 'createBot', zod: createBotSchema },
{ name: 'editBot', zod: editBotSchema },
{ name: 'changeUsername', zod: changeUsernameSchema },
{ name: 'updateChatModeration', zod: updateChatModerationSchema },
] as const;
export function UniversalForm<T extends z.ZodType>({

View File

@@ -11,6 +11,7 @@ import {
editBotSchema,
onboardSchema,
streamInfoEditSchema,
updateChatModerationSchema,
updateChannelSettingsSchema,
} from './zod';
import { initializeStreamInfo } from '../instrumentation/streamInfo';
@@ -268,6 +269,67 @@ export async function addChannelManager(channelId: string, userChannel: string)
return { success: true };
}
export async function updateChatModeration(prev: any, formData: FormData) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(updateChatModerationSchema, formData);
if (!zod.success) {
return zod;
}
const channel = await prisma.channel.findUnique({
where: { id: zod.data.channelId },
include: {
owner: true,
managers: true,
},
});
if (!channel) {
return { success: false, error: 'Channel not found' };
}
if (!can(user, 'update', 'channel', { channel })) {
return { success: false, error: 'Unauthorized' };
}
const blockedTerms = (zod.data.blockedTerms ?? '')
.split(/[\n,]/)
.map((term) => term.trim().toLowerCase())
.filter((term) => term.length >= 2)
.slice(0, 200);
await prisma.chatModerationSettings.upsert({
where: {
channelId: channel.id,
},
create: {
channelId: channel.id,
blockedTerms,
slowModeSeconds: zod.data.slowModeSeconds,
maxMessageLength: zod.data.maxMessageLength,
rateLimitCount: zod.data.rateLimitCount,
rateLimitWindowSeconds: zod.data.rateLimitWindowSeconds,
},
update: {
blockedTerms,
slowModeSeconds: zod.data.slowModeSeconds,
maxMessageLength: zod.data.maxMessageLength,
rateLimitCount: zod.data.rateLimitCount,
rateLimitWindowSeconds: zod.data.rateLimitWindowSeconds,
},
});
const redis = getRedisConnection();
await redis.del(`chat:moderation:settings:${channel.id}`);
revalidatePath(`/settings/channel/${channel.name}`);
return { success: true };
}
export async function removeChannelManager(channelId: string, userId: string) {
const { user } = await validateRequest();
if (!user) {

View File

@@ -39,6 +39,15 @@ export const updateChannelSettingsSchema = z.object({
is247: z.boolean(),
});
export const updateChatModerationSchema = z.object({
channelId: z.string().min(1),
blockedTerms: z.string().max(5000).optional(),
slowModeSeconds: z.coerce.number().int().min(0).max(120),
maxMessageLength: z.coerce.number().int().min(50).max(2000),
rateLimitCount: z.coerce.number().int().min(3).max(30),
rateLimitWindowSeconds: z.coerce.number().int().min(5).max(60),
});
export const createBotSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
slug: username.refine((val) => val !== 'settings', { message: 'This slug is reserved' }),

View File

@@ -0,0 +1,96 @@
-- CreateEnum
CREATE TYPE "ChatModerationAction" AS ENUM (
'MESSAGE_BLOCKED',
'MESSAGE_DELETED',
'USER_TIMEOUT',
'USER_BANNED',
'USER_UNBANNED'
);
-- CreateTable
CREATE TABLE "ChatModerationSettings" (
"id" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"blockedTerms" TEXT[] DEFAULT ARRAY[]::TEXT[],
"slowModeSeconds" INTEGER NOT NULL DEFAULT 0,
"maxMessageLength" INTEGER NOT NULL DEFAULT 400,
"rateLimitCount" INTEGER NOT NULL DEFAULT 8,
"rateLimitWindowSeconds" INTEGER NOT NULL DEFAULT 10,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChatModerationSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatUserBan" (
"id" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"bannedById" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChatUserBan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatModerationEvent" (
"id" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"action" "ChatModerationAction" NOT NULL,
"moderatorId" TEXT NOT NULL,
"targetUserId" TEXT,
"reason" TEXT,
"details" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatModerationEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ChatModerationSettings_channelId_key" ON "ChatModerationSettings"("channelId");
-- CreateIndex
CREATE INDEX "ChatModerationSettings_channelId_idx" ON "ChatModerationSettings"("channelId");
-- CreateIndex
CREATE UNIQUE INDEX "ChatUserBan_channelId_userId_key" ON "ChatUserBan"("channelId", "userId");
-- CreateIndex
CREATE INDEX "ChatUserBan_channelId_userId_idx" ON "ChatUserBan"("channelId", "userId");
-- CreateIndex
CREATE INDEX "ChatUserBan_expiresAt_idx" ON "ChatUserBan"("expiresAt");
-- CreateIndex
CREATE INDEX "ChatModerationEvent_channelId_createdAt_idx" ON "ChatModerationEvent"("channelId", "createdAt");
-- CreateIndex
CREATE INDEX "ChatModerationEvent_moderatorId_idx" ON "ChatModerationEvent"("moderatorId");
-- CreateIndex
CREATE INDEX "ChatModerationEvent_targetUserId_idx" ON "ChatModerationEvent"("targetUserId");
-- AddForeignKey
ALTER TABLE "ChatModerationSettings" ADD CONSTRAINT "ChatModerationSettings_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatUserBan" ADD CONSTRAINT "ChatUserBan_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatUserBan" ADD CONSTRAINT "ChatUserBan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatUserBan" ADD CONSTRAINT "ChatUserBan_bannedById_fkey" FOREIGN KEY ("bannedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatModerationEvent" ADD CONSTRAINT "ChatModerationEvent_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatModerationEvent" ADD CONSTRAINT "ChatModerationEvent_moderatorId_fkey" FOREIGN KEY ("moderatorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatModerationEvent" ADD CONSTRAINT "ChatModerationEvent_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,35 @@
-- CreateEnum
CREATE TYPE "AdminAuditAction" AS ENUM (
'USER_BANNED',
'USER_UNBANNED',
'USER_PROMOTED',
'USER_DEMOTED',
'CHANNEL_RESTRICTED',
'CHANNEL_UNRESTRICTED'
);
-- CreateTable
CREATE TABLE "AdminAuditLog" (
"id" TEXT NOT NULL,
"action" "AdminAuditAction" NOT NULL,
"actorId" TEXT NOT NULL,
"targetUserId" TEXT,
"targetChannel" TEXT,
"reason" TEXT,
"details" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AdminAuditLog_actorId_idx" ON "AdminAuditLog"("actorId");
-- CreateIndex
CREATE INDEX "AdminAuditLog_createdAt_idx" ON "AdminAuditLog"("createdAt");
-- CreateIndex
CREATE INDEX "AdminAuditLog_action_createdAt_idx" ON "AdminAuditLog"("action", "createdAt");
-- AddForeignKey
ALTER TABLE "AdminAuditLog" ADD CONSTRAINT "AdminAuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -35,6 +35,11 @@ model User {
followers Follow[] @relation("UserFollows")
botAccounts BotAccount[]
ban UserBan?
chatBans ChatUserBan[] @relation("ChatBannedUser")
issuedChatBans ChatUserBan[] @relation("ChatBannedBy")
chatModActions ChatModerationEvent[] @relation("ChatModerationActor")
chatModTargets ChatModerationEvent[] @relation("ChatModerationTarget")
adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor")
@@index([personalChannelId])
}
@@ -60,6 +65,9 @@ model Channel {
obsChatGrantToken String @unique @default(cuid())
is247 Boolean @default(false)
restriction ChannelRestriction?
chatSettings ChatModerationSettings?
chatBans ChatUserBan[]
chatModEvents ChatModerationEvent[]
@@index([ownerId])
}
@@ -169,3 +177,87 @@ model ChannelRestriction {
@@index([channelId])
}
model ChatModerationSettings {
id String @id @default(cuid())
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
channelId String @unique
blockedTerms String[] @default([])
slowModeSeconds Int @default(0)
maxMessageLength Int @default(400)
rateLimitCount Int @default(8)
rateLimitWindowSeconds Int @default(10)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([channelId])
}
model ChatUserBan {
id String @id @default(cuid())
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
channelId String
user User @relation("ChatBannedUser", fields: [userId], references: [id], onDelete: Cascade)
userId String
reason String
bannedBy User @relation("ChatBannedBy", fields: [bannedById], references: [id], onDelete: Cascade)
bannedById String
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([channelId, userId])
@@index([channelId, userId])
@@index([expiresAt])
}
model ChatModerationEvent {
id String @id @default(cuid())
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
channelId String
action ChatModerationAction
moderator User @relation("ChatModerationActor", fields: [moderatorId], references: [id], onDelete: Cascade)
moderatorId String
targetUser User? @relation("ChatModerationTarget", fields: [targetUserId], references: [id], onDelete: SetNull)
targetUserId String?
reason String?
details Json?
createdAt DateTime @default(now())
@@index([channelId, createdAt])
@@index([moderatorId])
@@index([targetUserId])
}
model AdminAuditLog {
id String @id @default(cuid())
action AdminAuditAction
actor User @relation("AdminAuditActor", fields: [actorId], references: [id], onDelete: Cascade)
actorId String
targetUserId String?
targetChannel String?
reason String?
details Json?
createdAt DateTime @default(now())
@@index([actorId])
@@index([createdAt])
@@index([action, createdAt])
}
enum AdminAuditAction {
USER_BANNED
USER_UNBANNED
USER_PROMOTED
USER_DEMOTED
CHANNEL_RESTRICTED
CHANNEL_UNRESTRICTED
}
enum ChatModerationAction {
MESSAGE_BLOCKED
MESSAGE_DELETED
USER_TIMEOUT
USER_BANNED
USER_UNBANNED
}

View File

@@ -1,8 +1,15 @@
// most code here has been written by claude opus 4.5
import type {
ChatAccessHandler,
ChatAccessState,
ChatMessage,
HistoryHandler,
MessageHandler,
ModerationCommand,
ModerationError,
ModerationErrorHandler,
ModerationEvent,
ModerationEventHandler,
ServerChatMessage,
SystemMessage,
SystemMessageHandler,
@@ -17,6 +24,9 @@ interface ChannelConnection {
messageHandlers: Set<MessageHandler>;
systemMessageHandlers: Set<SystemMessageHandler>;
historyHandlers: Set<HistoryHandler>;
moderationErrorHandlers: Set<ModerationErrorHandler>;
moderationEventHandlers: Set<ModerationEventHandler>;
chatAccessHandlers: Set<ChatAccessHandler>;
}
export class ChatClient {
@@ -27,6 +37,9 @@ export class ChatClient {
private globalMessageHandlers: Set<MessageHandler> = new Set();
private globalSystemMessageHandlers: Set<SystemMessageHandler> = new Set();
private globalHistoryHandlers: Set<HistoryHandler> = new Set();
private globalModerationErrorHandlers: Set<ModerationErrorHandler> = new Set();
private globalModerationEventHandlers: Set<ModerationEventHandler> = new Set();
private globalChatAccessHandlers: Set<ChatAccessHandler> = new Set();
constructor(botToken: string, options?: ChatClientOptions) {
this.botToken = botToken;
@@ -54,6 +67,9 @@ export class ChatClient {
messageHandlers: new Set(),
systemMessageHandlers: new Set(),
historyHandlers: new Set(),
moderationErrorHandlers: new Set(),
moderationEventHandlers: new Set(),
chatAccessHandlers: new Set(),
};
this.connections.set(channelName, connection);
@@ -131,6 +147,46 @@ export class ChatClient {
return;
}
if (data.type === 'chatAccess') {
const access: ChatAccessState = {
canSend: Boolean(data.canSend),
restriction: data.restriction ?? null,
};
this.emitChatAccess(access, channelName, connection);
return;
}
if (data.type === 'moderationError') {
const error: ModerationError = {
code: data.code,
message: data.message,
restriction: data.restriction,
};
this.emitModerationError(error, channelName, connection);
return;
}
if (data.type === 'messageDeleted' && typeof data.msgId === 'string') {
const event: ModerationEvent = {
type: 'messageDeleted',
msgId: data.msgId,
channelName,
};
this.emitModerationEvent(event, connection);
return;
}
if (data.type === 'systemMsg' && typeof data.message === 'string') {
const systemMsg: SystemMessage = {
type: 'connected',
channelName,
message: data.message,
timestamp: Date.now(),
};
this.emitSystem(systemMsg, connection);
return;
}
// Handle emoji responses
if (data.type === 'emojiMsgResponse' || data.type === 'emojiSearchResponse') {
// Could add emoji handlers in the future
@@ -141,6 +197,7 @@ export class ChatClient {
private parseServerMessage(msg: ServerChatMessage, channelName: string): ChatMessage {
return {
id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
msgId: msg.msgId,
channelName,
username: msg.user.username,
displayName: msg.user.displayName,
@@ -203,6 +260,59 @@ export class ChatClient {
}
}
sendModerationCommand(command: ModerationCommand, channelName: string): void {
const connection = this.connections.get(channelName);
if (!connection || connection.ws.readyState !== WebSocket.OPEN) {
throw new Error(`Not connected to channel: ${channelName}`);
}
connection.ws.send(JSON.stringify(command));
}
timeoutUser(
channelName: string,
targetUserId: string,
targetUsername: string,
durationSeconds = 300
): void {
this.sendModerationCommand(
{
type: 'mod:timeoutUser',
targetUserId,
targetUsername,
durationSeconds,
},
channelName
);
}
banUser(
channelName: string,
targetUserId: string,
targetUsername: string,
reason?: string
): void {
this.sendModerationCommand(
{
type: 'mod:banUser',
targetUserId,
targetUsername,
reason,
},
channelName
);
}
liftTimeout(channelName: string, targetUserId: string, targetUsername: string): void {
this.sendModerationCommand(
{
type: 'mod:liftTimeout',
targetUserId,
targetUsername,
},
channelName
);
}
onMessage(handler: MessageHandler, channelName?: string): () => void {
if (channelName) {
// Channel-specific handler
@@ -251,6 +361,48 @@ export class ChatClient {
}
}
onModerationError(handler: ModerationErrorHandler, channelName?: string): () => void {
if (channelName) {
const connection = this.connections.get(channelName);
if (!connection) {
throw new Error(`Not connected to channel: ${channelName}`);
}
connection.moderationErrorHandlers.add(handler);
return () => connection.moderationErrorHandlers.delete(handler);
}
this.globalModerationErrorHandlers.add(handler);
return () => this.globalModerationErrorHandlers.delete(handler);
}
onModerationEvent(handler: ModerationEventHandler, channelName?: string): () => void {
if (channelName) {
const connection = this.connections.get(channelName);
if (!connection) {
throw new Error(`Not connected to channel: ${channelName}`);
}
connection.moderationEventHandlers.add(handler);
return () => connection.moderationEventHandlers.delete(handler);
}
this.globalModerationEventHandlers.add(handler);
return () => this.globalModerationEventHandlers.delete(handler);
}
onChatAccess(handler: ChatAccessHandler, channelName?: string): () => void {
if (channelName) {
const connection = this.connections.get(channelName);
if (!connection) {
throw new Error(`Not connected to channel: ${channelName}`);
}
connection.chatAccessHandlers.add(handler);
return () => connection.chatAccessHandlers.delete(handler);
}
this.globalChatAccessHandlers.add(handler);
return () => this.globalChatAccessHandlers.delete(handler);
}
private emitMessage(message: ChatMessage, connection: ChannelConnection): void {
// Emit to channel-specific handlers
connection.messageHandlers.forEach((handler) => handler(message));
@@ -265,6 +417,29 @@ export class ChatClient {
this.globalSystemMessageHandlers.forEach((handler) => handler(message));
}
private emitModerationError(
error: ModerationError,
channelName: string,
connection: ChannelConnection
): void {
connection.moderationErrorHandlers.forEach((handler) => handler(error, channelName));
this.globalModerationErrorHandlers.forEach((handler) => handler(error, channelName));
}
private emitModerationEvent(event: ModerationEvent, connection: ChannelConnection): void {
connection.moderationEventHandlers.forEach((handler) => handler(event));
this.globalModerationEventHandlers.forEach((handler) => handler(event));
}
private emitChatAccess(
access: ChatAccessState,
channelName: string,
connection: ChannelConnection
): void {
connection.chatAccessHandlers.forEach((handler) => handler(access, channelName));
this.globalChatAccessHandlers.forEach((handler) => handler(access, channelName));
}
isConnectedTo(channelName: string): boolean {
const connection = this.connections.get(channelName);
return connection ? connection.ws.readyState === WebSocket.OPEN : false;

View File

@@ -15,4 +15,17 @@ interface ConstructorArgs {
chatOptions?: import('./chat.js').ChatClientOptions;
}
export { ChatClient, type ChatClientOptions } from './chat.js';
export type { ChatMessage, MessageHandler, SystemMessage, SystemMessageHandler, HistoryHandler } from './types.js';
export type {
ChatAccessHandler,
ChatAccessState,
ChatMessage,
HistoryHandler,
MessageHandler,
ModerationCommand,
ModerationError,
ModerationErrorHandler,
ModerationEvent,
ModerationEventHandler,
SystemMessage,
SystemMessageHandler,
} from './types.js';

View File

@@ -1,5 +1,6 @@
export interface ChatMessage {
id: string;
msgId?: string;
channelName: string;
username: string;
displayName?: string;
@@ -27,9 +28,48 @@ export interface ServerChatMessage {
isBot?: boolean;
};
message: string;
msgId?: string;
type?: 'message' | 'systemMsg';
}
export interface ChatAccessState {
canSend: boolean;
restriction?: {
type: 'timeout' | 'ban';
reason?: string;
expiresAt?: string | null;
} | null;
}
export interface ModerationError {
code: string;
message: string;
restriction?: ChatAccessState['restriction'];
}
export interface ModerationEvent {
type: 'messageDeleted';
msgId: string;
channelName: string;
}
export interface ModerationCommand {
type:
| 'mod:deleteMessage'
| 'mod:timeoutUser'
| 'mod:banUser'
| 'mod:unbanUser'
| 'mod:liftTimeout';
msgId?: string;
targetUserId?: string;
targetUsername?: string;
durationSeconds?: number;
reason?: string;
}
export type MessageHandler = (message: ChatMessage) => void;
export type SystemMessageHandler = (message: SystemMessage) => void;
export type HistoryHandler = (messages: ChatMessage[]) => void;
export type ChatAccessHandler = (state: ChatAccessState, channelName: string) => void;
export type ModerationErrorHandler = (error: ModerationError, channelName: string) => void;
export type ModerationEventHandler = (event: ModerationEvent) => void;