fix(chat): code review and documentation update

This commit is contained in:
2026-02-24 22:38:58 +01:00
parent ef5eab0d17
commit b4f66e01d9
3 changed files with 339 additions and 44 deletions

View File

@@ -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: [] }));

View File

@@ -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: {

View File

@@ -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",