mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat(chat): chat moderation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
118
apps/web/src/app/(ui)/(protected)/api/admin/audit/route.ts
Normal file
118
apps/web/src/app/(ui)/(protected)/api/admin/audit/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -24,6 +24,7 @@ export default async function ChannelSettingsPage({
|
||||
managers: true,
|
||||
streamInfo: true,
|
||||
streamKey: true,
|
||||
chatSettings: true,
|
||||
followers: {
|
||||
include: {
|
||||
user: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user