mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
fix(chat): code review and documentation update
This commit is contained in:
@@ -199,6 +199,14 @@ async function broadcastRestrictionStateToUser(
|
||||
});
|
||||
}
|
||||
|
||||
const RATE_LIMIT_LUA = `
|
||||
local current = redis.call('INCR', KEYS[1])
|
||||
if current == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return current
|
||||
`;
|
||||
|
||||
async function isRateLimited(
|
||||
channelId: string,
|
||||
userId: string,
|
||||
@@ -206,12 +214,7 @@ async function isRateLimited(
|
||||
windowSeconds: number
|
||||
): Promise<boolean> {
|
||||
const key = `chat:ratelimit:${channelId}:${userId}`;
|
||||
const currentCount = await redis.incr(key);
|
||||
|
||||
if (currentCount === 1) {
|
||||
await redis.expire(key, windowSeconds);
|
||||
}
|
||||
|
||||
const currentCount = (await redis.eval(RATE_LIMIT_LUA, 1, key, String(windowSeconds))) as number;
|
||||
return currentCount > count;
|
||||
}
|
||||
|
||||
@@ -649,6 +652,7 @@ app.get(
|
||||
broadcastToChannel(targetUsername, socket, msgObj as unknown as Record<string, unknown>);
|
||||
}
|
||||
if (msg.type === 'emojiMsg') {
|
||||
if (!socketState.chatUser) return;
|
||||
const emojis = msg.emojis as string[];
|
||||
const emojiMap: Record<string, string> = {};
|
||||
|
||||
@@ -675,6 +679,7 @@ app.get(
|
||||
);
|
||||
}
|
||||
if (msg.type === 'emojiSearch') {
|
||||
if (!socketState.chatUser) return;
|
||||
const rawSearchTerm = (msg.searchTerm as string)?.trim() ?? '';
|
||||
if (!rawSearchTerm || rawSearchTerm.length > 50) {
|
||||
ws.send(JSON.stringify({ type: 'emojiSearchResponse', results: [] }));
|
||||
|
||||
@@ -6,6 +6,18 @@ import type {
|
||||
ChatUser,
|
||||
} from '../types/chat.js';
|
||||
|
||||
const ROLE_RANK: Record<NonNullable<ChatUser['channelRole']> | '__none__', number> = {
|
||||
owner: 100,
|
||||
manager: 50,
|
||||
chatModerator: 10,
|
||||
botModerator: 10,
|
||||
__none__: 0,
|
||||
};
|
||||
|
||||
function roleRank(role: ChatUser['channelRole']): number {
|
||||
return role ? (ROLE_RANK[role] ?? 0) : ROLE_RANK.__none__;
|
||||
}
|
||||
|
||||
type ModerationContext = {
|
||||
chatUser: ChatUser;
|
||||
targetUsername: string;
|
||||
@@ -67,31 +79,80 @@ export function sendModerationError(
|
||||
);
|
||||
}
|
||||
|
||||
function requireModerationContext(
|
||||
async function requireModerationContext(
|
||||
socket: ChatSocket,
|
||||
socketState: ChatSocket
|
||||
): ModerationContext | null {
|
||||
if (
|
||||
!socketState.isModerator ||
|
||||
!socketState.chatUser ||
|
||||
!socketState.targetUsername ||
|
||||
!socketState.channelId
|
||||
) {
|
||||
): Promise<ModerationContext | null> {
|
||||
if (!socketState.chatUser || !socketState.targetUsername || !socketState.channelId) {
|
||||
sendModerationError(socket, 'FORBIDDEN', 'You do not have permission to moderate this chat.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const chatUser = socketState.chatUser;
|
||||
const channelId = socketState.channelId;
|
||||
|
||||
const [channel, moderatorRecord] = await Promise.all([
|
||||
prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
select: {
|
||||
ownerId: true,
|
||||
managers: { select: { id: true } },
|
||||
chatModerators: { select: { id: true } },
|
||||
chatModeratorBots: { select: { id: true } },
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: chatUser.moderatorUserId },
|
||||
select: { isAdmin: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!channel) {
|
||||
sendModerationError(socket, 'FORBIDDEN', 'You do not have permission to moderate this chat.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPlatformAdmin = Boolean(moderatorRecord?.isAdmin);
|
||||
|
||||
let channelRole: ChatUser['channelRole'] = null;
|
||||
if (chatUser.isBot) {
|
||||
if (channel.chatModeratorBots.some((b) => b.id === chatUser.id)) {
|
||||
channelRole = 'botModerator';
|
||||
}
|
||||
} else if (channel.ownerId === chatUser.id) {
|
||||
channelRole = 'owner';
|
||||
} else if (channel.managers.some((m) => m.id === chatUser.id)) {
|
||||
channelRole = 'manager';
|
||||
} else if (channel.chatModerators.some((m) => m.id === chatUser.id)) {
|
||||
channelRole = 'chatModerator';
|
||||
}
|
||||
|
||||
const isModerator =
|
||||
isPlatformAdmin ||
|
||||
channelRole === 'owner' ||
|
||||
channelRole === 'manager' ||
|
||||
channelRole === 'chatModerator' ||
|
||||
channelRole === 'botModerator';
|
||||
|
||||
if (!isModerator) {
|
||||
sendModerationError(socket, 'FORBIDDEN', 'You do not have permission to moderate this chat.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedChatUser: ChatUser = { ...chatUser, isPlatformAdmin, channelRole };
|
||||
|
||||
return {
|
||||
chatUser: socketState.chatUser,
|
||||
chatUser: resolvedChatUser,
|
||||
targetUsername: socketState.targetUsername,
|
||||
channelId: socketState.channelId,
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveModerationTarget(
|
||||
socket: ChatSocket,
|
||||
actingModeratorUserId: string,
|
||||
rawTargetUserId: unknown
|
||||
rawTargetUserId: unknown,
|
||||
channelId: string
|
||||
) {
|
||||
const targetUserId = typeof rawTargetUserId === 'string' ? rawTargetUserId : '';
|
||||
|
||||
@@ -105,6 +166,9 @@ async function resolveModerationTarget(
|
||||
select: {
|
||||
isAdmin: true,
|
||||
personalChannel: { select: { name: true } },
|
||||
ownedChannels: { where: { id: channelId }, select: { id: true } },
|
||||
managedChannels: { where: { id: channelId }, select: { id: true } },
|
||||
moderatedChannels: { where: { id: channelId }, select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -113,9 +177,19 @@ async function resolveModerationTarget(
|
||||
return null;
|
||||
}
|
||||
|
||||
let targetChannelRole: ChatUser['channelRole'] = null;
|
||||
if (targetUserRecord.ownedChannels.length > 0) {
|
||||
targetChannelRole = 'owner';
|
||||
} else if (targetUserRecord.managedChannels.length > 0) {
|
||||
targetChannelRole = 'manager';
|
||||
} else if (targetUserRecord.moderatedChannels.length > 0) {
|
||||
targetChannelRole = 'chatModerator';
|
||||
}
|
||||
|
||||
return {
|
||||
targetUserId,
|
||||
targetUserRecord,
|
||||
targetChannelRole,
|
||||
resolvedTargetUsername: targetUserRecord.personalChannel?.name ?? 'that user',
|
||||
};
|
||||
}
|
||||
@@ -125,7 +199,7 @@ async function ensureAdminTargetModerationAllowed(
|
||||
actingModeratorUserId: string,
|
||||
targetIsAdmin: boolean
|
||||
) {
|
||||
if (process.env.NODE_ENV !== 'production' || !targetIsAdmin) {
|
||||
if (!targetIsAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -146,13 +220,33 @@ async function ensureAdminTargetModerationAllowed(
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureRoleHierarchyAllowed(
|
||||
socket: ChatSocket,
|
||||
actorRole: ChatUser['channelRole'],
|
||||
actorIsPlatformAdmin: boolean,
|
||||
targetRole: ChatUser['channelRole']
|
||||
): boolean {
|
||||
if (actorIsPlatformAdmin) return true;
|
||||
|
||||
if (roleRank(actorRole) <= roleRank(targetRole)) {
|
||||
sendModerationError(
|
||||
socket,
|
||||
'FORBIDDEN',
|
||||
'You cannot moderate a user with an equal or higher role than yours.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function handleDeleteMessageCommand(
|
||||
socket: ChatSocket,
|
||||
socketState: ChatSocket,
|
||||
msg: ChatModerationCommand,
|
||||
deps: DeleteMessageDeps
|
||||
) {
|
||||
const context = requireModerationContext(socket, socketState);
|
||||
const context = await requireModerationContext(socket, socketState);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
@@ -186,13 +280,18 @@ export async function handleUserRestrictionCommand(
|
||||
msg: ChatModerationCommand,
|
||||
deps: UserRestrictionDeps
|
||||
) {
|
||||
const context = requireModerationContext(socket, socketState);
|
||||
const context = await requireModerationContext(socket, socketState);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actingModeratorUserId = context.chatUser.moderatorUserId;
|
||||
const target = await resolveModerationTarget(socket, actingModeratorUserId, msg.targetUserId);
|
||||
const target = await resolveModerationTarget(
|
||||
socket,
|
||||
actingModeratorUserId,
|
||||
msg.targetUserId,
|
||||
context.channelId
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
@@ -206,6 +305,16 @@ export async function handleUserRestrictionCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const hierarchyAllowed = ensureRoleHierarchyAllowed(
|
||||
socket,
|
||||
context.chatUser.channelRole,
|
||||
context.chatUser.isPlatformAdmin,
|
||||
target.targetChannelRole
|
||||
);
|
||||
if (!hierarchyAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'mod:unbanUser' || msg.type === 'mod:liftTimeout') {
|
||||
await prisma.chatUserBan.deleteMany({
|
||||
where: {
|
||||
|
||||
@@ -21,64 +21,235 @@ Bot accounts are now supported. You can choose to connect as a bot by providing
|
||||
**Security Note:** When using the `?botAuth=` query parameter, be aware that query parameters may be logged in server logs, and/or proxy logs. Use the `Authorization` header method whenever possible. The query parameter method should only be used when connecting from an environment where headers cannot be set.
|
||||
|
||||
It is highly advised to use a bot account for any automated task, and to implement anything pointed out in this page.
|
||||
|
||||
</Aside>
|
||||
|
||||
Once connected, you must implement a subroutine in your code to send ping messages every about 5 seconds. This is because of Cloudflare limitations.
|
||||
|
||||
Messages are sent and received in JSON format. The following message types are supported:
|
||||
|
||||
- `session`: sent by the server immediately upon connection.
|
||||
- received by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "session",
|
||||
"viewer": {
|
||||
"id": "user_id",
|
||||
"username": "your_username"
|
||||
},
|
||||
"permissions": {
|
||||
"canModerate": false
|
||||
},
|
||||
"moderation": {
|
||||
"hasBlockedTerms": false,
|
||||
"slowModeSeconds": 0,
|
||||
"maxMessageLength": 400
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`viewer` is `null` for unauthenticated (grant-only) connections. `canModerate` is `true` for channel owners, managers, moderators, and platform admins.
|
||||
|
||||
- `chatAccess`: sent by the server on connect (for authenticated non-bot users) and whenever a user's restriction state changes.
|
||||
- received by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "chatAccess",
|
||||
"canSend": true,
|
||||
"restriction": null
|
||||
}
|
||||
```
|
||||
|
||||
When the user is restricted, `canSend` is `false` and `restriction` contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "timeout",
|
||||
"reason": "Timed out by moderator",
|
||||
"expiresAt": "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
`type` is either `"timeout"` or `"ban"`. `expiresAt` is an ISO 8601 string for timeouts, or `null` for permanent bans.
|
||||
|
||||
- `ping`: a ping message to keep the connection alive.
|
||||
- sent by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
|
||||
- received by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "pong"
|
||||
}
|
||||
```
|
||||
|
||||
- `message`: a chat message.
|
||||
- sent by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"message": "Hello, world!"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
|
||||
- received by client (broadcast to all viewers of the channel):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"msgId": "uuid-v4",
|
||||
"user": {
|
||||
"id": "user_id",
|
||||
"username": "user_who_sent_message",
|
||||
"avatar": "https://emoji.slack-edge.com/avatar.png"
|
||||
"pfpUrl": "https://example.com/avatar.png",
|
||||
"displayName": "Display Name",
|
||||
"isBot": false,
|
||||
"isPlatformAdmin": false,
|
||||
"channelRole": null
|
||||
},
|
||||
"message": "Hello, world!"
|
||||
}
|
||||
```
|
||||
- `ping`: a ping message to keep the connection alive.
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "pong"
|
||||
}
|
||||
```
|
||||
- `history`: a message containing the chat history. This is sent upon connection.
|
||||
|
||||
`channelRole` is one of `"owner"`, `"manager"`, `"chatModerator"`, `"botModerator"`, or `null`. `displayName` may be `undefined` for regular users.
|
||||
|
||||
- `history`: the recent chat history, sent upon connection.
|
||||
- received by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "history",
|
||||
"messages": [
|
||||
{
|
||||
"type": "message",
|
||||
"msgId": "uuid-v4",
|
||||
"user": {
|
||||
"id": "user_id",
|
||||
"username": "user_who_sent_message",
|
||||
"avatar": "https://emoji.slack-edge.com/avatar.png"
|
||||
"pfpUrl": "https://example.com/avatar.png",
|
||||
"displayName": "Display Name",
|
||||
"isBot": false,
|
||||
"isPlatformAdmin": false,
|
||||
"channelRole": null
|
||||
},
|
||||
"message": "Hello, world!",
|
||||
"type": "message",
|
||||
},
|
||||
...
|
||||
"message": "Hello, world!"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Up to 100 messages are returned. Each message has the same shape as a received `message` event.
|
||||
|
||||
- `systemMsg`: a system notification broadcast to all viewers, e.g. when a user is banned or unbanned.
|
||||
- received by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "systemMsg",
|
||||
"message": "username was banned."
|
||||
}
|
||||
```
|
||||
|
||||
- `moderationError`: sent to the acting client when a message or moderation action is rejected.
|
||||
- received by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "moderationError",
|
||||
"code": "RATE_LIMIT",
|
||||
"message": "You are sending messages too fast.",
|
||||
"restriction": null
|
||||
}
|
||||
```
|
||||
|
||||
`restriction` is only present (non-null) for `TIMED_OUT` and `BANNED` codes, and has the same shape as the `restriction` field in `chatAccess`. Possible codes:
|
||||
|
||||
| Code | Trigger |
|
||||
| ------------------ | ---------------------------------------------- |
|
||||
| `FORBIDDEN` | Not permitted to perform the action |
|
||||
| `RATE_LIMIT` | Too many messages in the rate limit window |
|
||||
| `SLOW_MODE` | Sent before the slow mode cooldown expired |
|
||||
| `TIMED_OUT` | User is currently timed out |
|
||||
| `BANNED` | User is permanently banned |
|
||||
| `MESSAGE_TOO_LONG` | Message exceeds `maxMessageLength` |
|
||||
| `BLOCKED_TERM` | Message contains a blocked term |
|
||||
| `INVALID_TARGET` | Moderation target is invalid or does not exist |
|
||||
| `INVALID_REQUEST` | Malformed moderation command |
|
||||
| `NOT_FOUND` | Target message not found (delete) |
|
||||
|
||||
## Moderation commands
|
||||
|
||||
moderation commands are only available to authenticated users with the `canModerate` permission (`owner`, `manager`, `chatModerator`, `botModerator`, or platform admin). sending any of these without permission returns a `moderationError` with code `FORBIDDEN`.
|
||||
|
||||
obviously, role hierarchy is enforced: a `chatModerator` cannot moderate a `manager` or `owner`. Platform admins bypass hierarchy checks entirely.
|
||||
|
||||
- `mod:deleteMessage`: delete a message from the chat history and broadcast its removal.
|
||||
- sent by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "mod:deleteMessage",
|
||||
"msgId": "uuid-of-message-to-delete"
|
||||
}
|
||||
```
|
||||
|
||||
- received by all clients on success:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "messageDeleted",
|
||||
"msgId": "uuid-of-message-to-delete"
|
||||
}
|
||||
```
|
||||
|
||||
- `mod:timeoutUser`: temporarily restrict a user from sending messages.
|
||||
- sent by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "mod:timeoutUser",
|
||||
"targetUserId": "user_id",
|
||||
"durationSeconds": 300,
|
||||
"reason": "Optional reason"
|
||||
}
|
||||
```
|
||||
|
||||
`durationSeconds` is clamped between 10 and 86400 (24 hours). Defaults to 300 if omitted. On success, a `systemMsg` is broadcast and the target receives a `chatAccess` update.
|
||||
|
||||
- `mod:banUser`: permanently ban a user from sending messages.
|
||||
- sent by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "mod:banUser",
|
||||
"targetUserId": "user_id",
|
||||
"reason": "Optional reason"
|
||||
}
|
||||
```
|
||||
|
||||
On success, a `systemMsg` is broadcast and the target receives a `chatAccess` update.
|
||||
|
||||
- `mod:liftTimeout` / `mod:unbanUser`: remove an active timeout or ban.
|
||||
- sent by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "mod:liftTimeout",
|
||||
"targetUserId": "user_id"
|
||||
}
|
||||
```
|
||||
|
||||
Both types behave identically and remove any active restriction for the target user. On success, a `systemMsg` is broadcast and the target receives a `chatAccess` update with `canSend: true`.
|
||||
|
||||
## Emoji handling
|
||||
|
||||
_diagram source: devin deepwiki_
|
||||
@@ -119,22 +290,29 @@ The server then checks Redis for the emoji URL and returns it.
|
||||
|
||||
When a user wants to look up an emoji (by typing `:(partial name)`), the server uses uFuzzy to find matching emojis in the Redis `emojis` hash key and returns the results.
|
||||
|
||||
<Aside type="caution">
|
||||
`emojiMsg` and `emojiSearch` require an authenticated connection. They are not available to
|
||||
grant-only (OBS) viewers.
|
||||
</Aside>
|
||||
|
||||
Here's what gets sent on the websocket:
|
||||
|
||||
- `emojiMsg`: Looks up emojis
|
||||
- sent by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "emojiMsg",
|
||||
"emojis": ["aga", "yapa", "heavysob", "yay", "yay-bounce"]
|
||||
}
|
||||
```
|
||||
|
||||
- received by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "emojiMsgResponse",
|
||||
"emojis": {
|
||||
// rough example of urls
|
||||
"aga": "https://emoji.slack-edge.com/aga.png",
|
||||
"yapa": "https://emoji.slack-edge.com/yapa.png",
|
||||
"heavysob": "https://emoji.slack-edge.com/heavysob.png",
|
||||
@@ -143,20 +321,23 @@ Here's what gets sent on the websocket:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `emojiSearch`: Searches for emojis
|
||||
- sent by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "emojiSearch",
|
||||
"searchTerm": "aga"
|
||||
}
|
||||
```
|
||||
|
||||
- received by client:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "emojiSearchResponse",
|
||||
"results": [
|
||||
// real results btw
|
||||
"aga",
|
||||
"aga-brick-throw",
|
||||
"aga-dance",
|
||||
|
||||
Reference in New Issue
Block a user