mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(threads): thread and forum API, admin, schemas and docs (#3251)
This commit is contained in:
1 parent
5f4295e399
commit
3f4160b138
495 files changed
+39232
-1215
No files matched your search
@@ -46,6 +46,7 @@ export const APIErrorCodes = {
|
||||
CAPTCHA_REQUIRED: 'CAPTCHA_REQUIRED',
|
||||
CHANNEL_ALREADY_FOLLOWED: 'CHANNEL_ALREADY_FOLLOWED',
|
||||
CHANNEL_HAS_FOLLOWED_CHANNELS: 'CHANNEL_HAS_FOLLOWED_CHANNELS',
|
||||
CHANNEL_HAS_THREADS: 'CHANNEL_HAS_THREADS',
|
||||
CHANNEL_TYPE_CONVERSION_NOT_SUPPORTED: 'CHANNEL_TYPE_CONVERSION_NOT_SUPPORTED',
|
||||
COMMUNICATION_DISABLED: 'COMMUNICATION_DISABLED',
|
||||
CONNECTION_ALREADY_EXISTS: 'CONNECTION_ALREADY_EXISTS',
|
||||
@@ -93,6 +94,8 @@ export const APIErrorCodes = {
|
||||
FEATURE_TEMPORARILY_DISABLED: 'FEATURE_TEMPORARILY_DISABLED',
|
||||
FILE_SIZE_TOO_LARGE: 'FILE_SIZE_TOO_LARGE',
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
FORUM_TAG_NAMES_MUST_BE_UNIQUE: 'FORUM_TAG_NAMES_MUST_BE_UNIQUE',
|
||||
FORUM_TAG_REQUIRED: 'FORUM_TAG_REQUIRED',
|
||||
FRIEND_REQUEST_BLOCKED: 'FRIEND_REQUEST_BLOCKED',
|
||||
GATEWAY_TIMEOUT: 'GATEWAY_TIMEOUT',
|
||||
GENERAL_ERROR: 'GENERAL_ERROR',
|
||||
@@ -104,6 +107,7 @@ export const APIErrorCodes = {
|
||||
HARVEST_EXPIRED: 'HARVEST_EXPIRED',
|
||||
HARVEST_FAILED: 'HARVEST_FAILED',
|
||||
HARVEST_NOT_READY: 'HARVEST_NOT_READY',
|
||||
HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY: 'HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY',
|
||||
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||
INVALID_ACLS_FORMAT: 'INVALID_ACLS_FORMAT',
|
||||
INVALID_API_ORIGIN: 'INVALID_API_ORIGIN',
|
||||
@@ -128,6 +132,7 @@ export const APIErrorCodes = {
|
||||
INVALID_SYSTEM_FLAG: 'INVALID_SYSTEM_FLAG',
|
||||
INVALID_TIMESTAMP: 'INVALID_TIMESTAMP',
|
||||
INVALID_TOKEN: 'INVALID_TOKEN',
|
||||
INVALID_THREAD_NOTIFICATION_SETTINGS: 'INVALID_THREAD_NOTIFICATION_SETTINGS',
|
||||
INVALID_WEBAUTHN_AUTHENTICATION_COUNTER: 'INVALID_WEBAUTHN_AUTHENTICATION_COUNTER',
|
||||
INVALID_WEBAUTHN_CREDENTIAL_COUNTER: 'INVALID_WEBAUTHN_CREDENTIAL_COUNTER',
|
||||
INVALID_WEBAUTHN_CREDENTIAL: 'INVALID_WEBAUTHN_CREDENTIAL',
|
||||
@@ -141,10 +146,12 @@ export const APIErrorCodes = {
|
||||
IP_BAN_DECLINED: 'IP_BAN_DECLINED',
|
||||
MAX_APPLICATIONS: 'MAX_APPLICATIONS',
|
||||
MAX_BOOKMARKS: 'MAX_BOOKMARKS',
|
||||
MAX_ACTIVE_THREADS: 'MAX_ACTIVE_THREADS',
|
||||
MAX_CATEGORY_CHANNELS: 'MAX_CATEGORY_CHANNELS',
|
||||
MAX_EMOJIS: 'MAX_EMOJIS',
|
||||
MAX_FAVORITE_MEMES: 'MAX_FAVORITE_MEMES',
|
||||
MAX_FRIENDS: 'MAX_FRIENDS',
|
||||
MAX_FORUM_TAGS: 'MAX_FORUM_TAGS',
|
||||
MAX_GROUP_DM_RECIPIENTS: 'MAX_GROUP_DM_RECIPIENTS',
|
||||
MAX_GROUP_DMS: 'MAX_GROUP_DMS',
|
||||
GROUP_DM_RECIPIENTS_NOT_ADDABLE: 'GROUP_DM_RECIPIENTS_NOT_ADDABLE',
|
||||
@@ -152,9 +159,11 @@ export const APIErrorCodes = {
|
||||
MAX_GUILD_MEMBERS: 'MAX_GUILD_MEMBERS',
|
||||
MAX_GUILD_ROLES: 'MAX_GUILD_ROLES',
|
||||
MAX_GUILDS: 'MAX_GUILDS',
|
||||
MAX_PINNED_THREADS_IN_FORUM: 'MAX_PINNED_THREADS_IN_FORUM',
|
||||
MAX_INVITES: 'MAX_INVITES',
|
||||
MAX_REACTIONS: 'MAX_REACTIONS',
|
||||
MAX_STICKERS: 'MAX_STICKERS',
|
||||
MAX_THREAD_MEMBERS: 'MAX_THREAD_MEMBERS',
|
||||
MAX_WEBHOOKS_PER_CHANNEL: 'MAX_WEBHOOKS_PER_CHANNEL',
|
||||
MAX_WEBHOOKS_PER_GUILD: 'MAX_WEBHOOKS_PER_GUILD',
|
||||
NCMEC_ALREADY_SUBMITTED: 'NCMEC_ALREADY_SUBMITTED',
|
||||
@@ -168,6 +177,7 @@ export const APIErrorCodes = {
|
||||
MISSING_PERMISSIONS: 'MISSING_PERMISSIONS',
|
||||
NO_ACTIVE_CALL: 'NO_ACTIVE_CALL',
|
||||
NO_ACTIVE_SUBSCRIPTION: 'NO_ACTIVE_SUBSCRIPTION',
|
||||
NO_TAGS_AVAILABLE_TO_NON_MODERATORS: 'NO_TAGS_AVAILABLE_TO_NON_MODERATORS',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
|
||||
NO_PASSKEYS_REGISTERED: 'NO_PASSKEYS_REGISTERED',
|
||||
@@ -192,6 +202,7 @@ export const APIErrorCodes = {
|
||||
SINGLE_COMMUNITY_CANNOT_LEAVE: 'SINGLE_COMMUNITY_CANNOT_LEAVE',
|
||||
INSTANCE_POLICY_TRANSITION_NOT_ALLOWED: 'INSTANCE_POLICY_TRANSITION_NOT_ALLOWED',
|
||||
SLOWMODE_RATE_LIMITED: 'SLOWMODE_RATE_LIMITED',
|
||||
SEARCH_INDEX_NOT_READY: 'SEARCH_INDEX_NOT_READY',
|
||||
SSO_REQUIRED: 'SSO_REQUIRED',
|
||||
STORE_BILLING_UNAVAILABLE: 'STORE_BILLING_UNAVAILABLE',
|
||||
STORE_NOTIFICATION_UNAUTHORIZED: 'STORE_NOTIFICATION_UNAUTHORIZED',
|
||||
@@ -226,6 +237,9 @@ export const APIErrorCodes = {
|
||||
TEMPORARY_INVITE_REQUIRES_PRESENCE: 'TEMPORARY_INVITE_REQUIRES_PRESENCE',
|
||||
TEST_HARNESS_DISABLED: 'TEST_HARNESS_DISABLED',
|
||||
TEST_HARNESS_FORBIDDEN: 'TEST_HARNESS_FORBIDDEN',
|
||||
THREAD_ALREADY_CREATED_FOR_MESSAGE: 'THREAD_ALREADY_CREATED_FOR_MESSAGE',
|
||||
THREAD_ARCHIVED: 'THREAD_ARCHIVED',
|
||||
THREAD_LOCKED: 'THREAD_LOCKED',
|
||||
TWO_FA_NOT_ENABLED: 'TWO_FA_NOT_ENABLED',
|
||||
TWO_FACTOR_REQUIRED: 'TWO_FACTOR_REQUIRED',
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
@@ -244,6 +258,7 @@ export const APIErrorCodes = {
|
||||
UNKNOWN_CHANNEL: 'UNKNOWN_CHANNEL',
|
||||
UNKNOWN_EMOJI: 'UNKNOWN_EMOJI',
|
||||
UNKNOWN_FAVORITE_MEME: 'UNKNOWN_FAVORITE_MEME',
|
||||
UNKNOWN_FORUM_TAG: 'UNKNOWN_FORUM_TAG',
|
||||
UNKNOWN_GIFT_CODE: 'UNKNOWN_GIFT_CODE',
|
||||
UNKNOWN_GUILD: 'UNKNOWN_GUILD',
|
||||
UNKNOWN_HARVEST: 'UNKNOWN_HARVEST',
|
||||
@@ -257,6 +272,7 @@ export const APIErrorCodes = {
|
||||
UNKNOWN_ROLE: 'UNKNOWN_ROLE',
|
||||
UNKNOWN_STICKER: 'UNKNOWN_STICKER',
|
||||
UNKNOWN_STORE_PURCHASE: 'UNKNOWN_STORE_PURCHASE',
|
||||
UNKNOWN_THREAD_MEMBER: 'UNKNOWN_THREAD_MEMBER',
|
||||
UNKNOWN_USER_FLAG: 'UNKNOWN_USER_FLAG',
|
||||
UNKNOWN_USER: 'UNKNOWN_USER',
|
||||
UNKNOWN_VOICE_REGION: 'UNKNOWN_VOICE_REGION',
|
||||
@@ -264,6 +280,10 @@ export const APIErrorCodes = {
|
||||
UNKNOWN_WEBAUTHN_CREDENTIAL: 'UNKNOWN_WEBAUTHN_CREDENTIAL',
|
||||
UNKNOWN_APPLICATION: 'UNKNOWN_APPLICATION',
|
||||
UNKNOWN_WEBHOOK: 'UNKNOWN_WEBHOOK',
|
||||
WEBHOOK_FORUM_TARGET_CONFLICT: 'WEBHOOK_FORUM_TARGET_CONFLICT',
|
||||
WEBHOOK_FORUM_TARGET_REQUIRED: 'WEBHOOK_FORUM_TARGET_REQUIRED',
|
||||
WEBHOOK_SERVICE_FORUM_UNSUPPORTED: 'WEBHOOK_SERVICE_FORUM_UNSUPPORTED',
|
||||
WEBHOOK_THREAD_NAME_REQUIRES_FORUM: 'WEBHOOK_THREAD_NAME_REQUIRES_FORUM',
|
||||
USERNAME_NOT_AVAILABLE: 'USERNAME_NOT_AVAILABLE',
|
||||
USERNAME_SIGN_IN_ONLY: 'USERNAME_SIGN_IN_ONLY',
|
||||
UPDATE_FAILED: 'UPDATE_FAILED',
|
||||
|
||||
@@ -36,4 +36,7 @@ export enum AuditLogActionType {
|
||||
MESSAGE_BULK_DELETE = 73,
|
||||
MESSAGE_PIN = 74,
|
||||
MESSAGE_UNPIN = 75,
|
||||
THREAD_CREATE = 110,
|
||||
THREAD_UPDATE = 111,
|
||||
THREAD_DELETE = 112,
|
||||
}
|
||||
@@ -41,3 +41,30 @@ describe('canAuthorizeBotInvite', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bot invite permission mask', () => {
|
||||
const threadBit = 1n << 38n;
|
||||
|
||||
it('drops bits outside the default mask', () => {
|
||||
expect(normalizeBotInvitePermissions(threadBit | Permissions.SEND_MESSAGES)).toBe(Permissions.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
it('keeps bits inside a wider mask', () => {
|
||||
const mask = Permissions.SEND_MESSAGES | threadBit;
|
||||
expect(normalizeBotInvitePermissions(threadBit | Permissions.SEND_MESSAGES, mask)).toBe(mask);
|
||||
expect(
|
||||
canAuthorizeBotInvite({
|
||||
userPermissions: Permissions.MANAGE_GUILD,
|
||||
requestedPermissions: threadBit,
|
||||
mask,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canAuthorizeBotInvite({
|
||||
userPermissions: Permissions.MANAGE_GUILD | threadBit,
|
||||
requestedPermissions: threadBit,
|
||||
mask,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
export function normalizeBotInvitePermissions(requestedPermissions: bigint): bigint {
|
||||
return requestedPermissions & ALL_PERMISSIONS;
|
||||
export function normalizeBotInvitePermissions(requestedPermissions: bigint, mask: bigint = ALL_PERMISSIONS): bigint {
|
||||
return requestedPermissions & mask;
|
||||
}
|
||||
|
||||
function hasAdministratorPermission(permissions: bigint): boolean {
|
||||
@@ -17,11 +17,13 @@ function hasManageGuildPermission(permissions: bigint): boolean {
|
||||
export function canAuthorizeBotInvite({
|
||||
userPermissions,
|
||||
requestedPermissions,
|
||||
mask = ALL_PERMISSIONS,
|
||||
}: {
|
||||
userPermissions: bigint;
|
||||
requestedPermissions?: bigint | null;
|
||||
mask?: bigint;
|
||||
}): boolean {
|
||||
const normalizedRequestedPermissions = normalizeBotInvitePermissions(requestedPermissions ?? 0n);
|
||||
const normalizedRequestedPermissions = normalizeBotInvitePermissions(requestedPermissions ?? 0n, mask);
|
||||
if (!hasAdministratorPermission(userPermissions) && !hasManageGuildPermission(userPermissions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ export const ChannelTypes = {
|
||||
GROUP_DM: 3,
|
||||
GUILD_CATEGORY: 4,
|
||||
GUILD_ANNOUNCEMENT: 5,
|
||||
ANNOUNCEMENT_THREAD: 10,
|
||||
PUBLIC_THREAD: 11,
|
||||
PRIVATE_THREAD: 12,
|
||||
GUILD_FORUM: 15,
|
||||
GUILD_MEDIA: 16,
|
||||
GUILD_LINK: 998,
|
||||
DM_PERSONAL_NOTES: 999,
|
||||
} as const;
|
||||
@@ -59,7 +64,9 @@ export const MessageTypes = {
|
||||
CHANNEL_PINNED_MESSAGE: 6,
|
||||
USER_JOIN: 7,
|
||||
CHANNEL_FOLLOW_ADD: 12,
|
||||
THREAD_CREATED: 18,
|
||||
REPLY: 19,
|
||||
THREAD_STARTER_MESSAGE: 21,
|
||||
CLIENT_SYSTEM: 99,
|
||||
} as const;
|
||||
|
||||
@@ -71,6 +78,8 @@ const MESSAGE_TYPE_DELETABLE = {
|
||||
[MessageTypes.CHANNEL_PINNED_MESSAGE]: true,
|
||||
[MessageTypes.USER_JOIN]: true,
|
||||
[MessageTypes.CHANNEL_FOLLOW_ADD]: true,
|
||||
[MessageTypes.THREAD_CREATED]: true,
|
||||
[MessageTypes.THREAD_STARTER_MESSAGE]: false,
|
||||
[MessageTypes.RECIPIENT_ADD]: false,
|
||||
[MessageTypes.RECIPIENT_REMOVE]: false,
|
||||
[MessageTypes.CALL]: false,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const GatewayOpcodes = {
|
||||
LAZY_REQUEST: 14,
|
||||
REQUEST_GUILD_COUNTS: 15,
|
||||
REQUEST_CHANNEL_MEMBER_COUNTS: 16,
|
||||
REQUEST_FORUM_UNREADS: 28,
|
||||
} as const;
|
||||
export const LARGE_GUILD_THRESHOLD = 250;
|
||||
export const MEMBER_LIST_RANGE_MAX_END = 100000;
|
||||
@@ -27,6 +28,7 @@ export const MEMBER_LIST_RANGE_PAGE_SIZE = MEMBER_LIST_RANGE_MAX_SPAN + 1;
|
||||
export const MEMBER_LIST_RANGE_WINDOW_OVERSCAN_PAGES = 1;
|
||||
export const GatewayIdentifyFlags = {
|
||||
DEBOUNCE_MESSAGE_REACTIONS: 1 << 1,
|
||||
CHANNEL_THREADS: 1 << 2,
|
||||
} as const;
|
||||
export const GatewayCloseCodes = {
|
||||
UNKNOWN_ERROR: 4000,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as ChannelConstants from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
ChannelTypes,
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES,
|
||||
isMessageTypeDeletable,
|
||||
MessageTypes,
|
||||
TEXT_BASED_CHANNEL_TYPES,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import * as ThreadConstants from '@fluxer/constants/src/ThreadConstants';
|
||||
import {
|
||||
ChannelFlags,
|
||||
isThreadAutoArchiveDuration,
|
||||
isValidThreadMemberSettingsFlags,
|
||||
publicThreadTypeFor,
|
||||
resolveTextThreadType,
|
||||
settableChannelFlags,
|
||||
THREAD_CHANNEL_TYPES,
|
||||
THREAD_FEATURE_CHANNEL_TYPES,
|
||||
THREAD_MESSAGE_FLAG_MASK,
|
||||
THREAD_ONLY_CHANNEL_TYPES,
|
||||
THREAD_PARENT_CHANNEL_TYPES,
|
||||
ThreadMemberFlags,
|
||||
} from '@fluxer/constants/src/ThreadConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function numericValues(value: unknown): Array<number> {
|
||||
if (typeof value === 'number') return [value];
|
||||
if (value instanceof Set) return [...value].flatMap(numericValues);
|
||||
if (Array.isArray(value)) return value.flatMap(numericValues);
|
||||
if (value !== null && typeof value === 'object') return Object.values(value).flatMap(numericValues);
|
||||
return [];
|
||||
}
|
||||
|
||||
describe('thread channel and message types', () => {
|
||||
it('uses the expected channel type numbers', () => {
|
||||
expect(ChannelTypes.ANNOUNCEMENT_THREAD).toBe(10);
|
||||
expect(ChannelTypes.PUBLIC_THREAD).toBe(11);
|
||||
expect(ChannelTypes.PRIVATE_THREAD).toBe(12);
|
||||
expect(ChannelTypes.GUILD_FORUM).toBe(15);
|
||||
expect(ChannelTypes.GUILD_MEDIA).toBe(16);
|
||||
expect(MessageTypes.THREAD_CREATED).toBe(18);
|
||||
expect(MessageTypes.THREAD_STARTER_MESSAGE).toBe(21);
|
||||
});
|
||||
|
||||
it('keeps the text type sets unchanged', () => {
|
||||
expect([...GUILD_TEXT_BASED_CHANNEL_TYPES].sort((a, b) => a - b)).toEqual([
|
||||
ChannelTypes.GUILD_TEXT,
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
ChannelTypes.GUILD_ANNOUNCEMENT,
|
||||
]);
|
||||
expect([...TEXT_BASED_CHANNEL_TYPES].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 5, 999]);
|
||||
for (const type of THREAD_FEATURE_CHANNEL_TYPES) {
|
||||
expect(TEXT_BASED_CHANNEL_TYPES.has(type)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('makes announcement channels thread parents of announcement threads', () => {
|
||||
expect(THREAD_CHANNEL_TYPES.has(ChannelTypes.ANNOUNCEMENT_THREAD)).toBe(true);
|
||||
expect(THREAD_PARENT_CHANNEL_TYPES.has(ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(true);
|
||||
expect(THREAD_ONLY_CHANNEL_TYPES.has(ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(false);
|
||||
expect(publicThreadTypeFor(ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(ChannelTypes.ANNOUNCEMENT_THREAD);
|
||||
expect(publicThreadTypeFor(ChannelTypes.GUILD_TEXT)).toBe(ChannelTypes.PUBLIC_THREAD);
|
||||
expect(publicThreadTypeFor(ChannelTypes.GUILD_FORUM)).toBe(ChannelTypes.PUBLIC_THREAD);
|
||||
});
|
||||
|
||||
it('resolves the thread type a text or announcement parent creates', () => {
|
||||
const {GUILD_TEXT, GUILD_ANNOUNCEMENT, GUILD_FORUM, ANNOUNCEMENT_THREAD, PUBLIC_THREAD, PRIVATE_THREAD} =
|
||||
ChannelTypes;
|
||||
expect(resolveTextThreadType(GUILD_ANNOUNCEMENT, ANNOUNCEMENT_THREAD)).toBe(ANNOUNCEMENT_THREAD);
|
||||
expect(resolveTextThreadType(GUILD_ANNOUNCEMENT, PUBLIC_THREAD)).toBe(ANNOUNCEMENT_THREAD);
|
||||
expect(resolveTextThreadType(GUILD_ANNOUNCEMENT, PRIVATE_THREAD)).toBeNull();
|
||||
expect(resolveTextThreadType(GUILD_TEXT, PUBLIC_THREAD)).toBe(PUBLIC_THREAD);
|
||||
expect(resolveTextThreadType(GUILD_TEXT, PRIVATE_THREAD)).toBe(PRIVATE_THREAD);
|
||||
expect(resolveTextThreadType(GUILD_TEXT, ANNOUNCEMENT_THREAD)).toBeNull();
|
||||
expect(resolveTextThreadType(GUILD_FORUM, PUBLIC_THREAD)).toBeNull();
|
||||
});
|
||||
|
||||
it('lets thread created messages be deleted but never the synthesized starter', () => {
|
||||
expect(isMessageTypeDeletable(MessageTypes.THREAD_CREATED)).toBe(true);
|
||||
expect(isMessageTypeDeletable(MessageTypes.THREAD_STARTER_MESSAGE)).toBe(false);
|
||||
});
|
||||
|
||||
it('masks both server-only message flags', () => {
|
||||
expect(THREAD_MESSAGE_FLAG_MASK).toBe(0x120);
|
||||
expect(THREAD_MESSAGE_FLAG_MASK & ChannelConstants.SENDABLE_MESSAGE_FLAGS).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thread constants', () => {
|
||||
it('keeps every numeric value inside a non-negative int32', () => {
|
||||
for (const value of numericValues(ThreadConstants)) {
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(2 ** 31);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts only the supported auto archive durations', () => {
|
||||
for (const duration of [60, 1440, 4320, 10080]) {
|
||||
expect(isThreadAutoArchiveDuration(duration)).toBe(true);
|
||||
}
|
||||
for (const duration of [0, 59, 61, 10081]) {
|
||||
expect(isThreadAutoArchiveDuration(duration)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows at most one notification bit on thread member settings', () => {
|
||||
expect(isValidThreadMemberSettingsFlags(0)).toBe(true);
|
||||
expect(isValidThreadMemberSettingsFlags(ThreadMemberFlags.ALL_MESSAGES)).toBe(true);
|
||||
expect(isValidThreadMemberSettingsFlags(ThreadMemberFlags.NO_MESSAGES)).toBe(true);
|
||||
expect(isValidThreadMemberSettingsFlags(ThreadMemberFlags.ALL_MESSAGES | ThreadMemberFlags.ONLY_MENTIONS)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isValidThreadMemberSettingsFlags(ThreadMemberFlags.HAS_INTERACTED)).toBe(false);
|
||||
expect(isValidThreadMemberSettingsFlags(1 << 4)).toBe(false);
|
||||
expect(isValidThreadMemberSettingsFlags(-1)).toBe(false);
|
||||
expect(isValidThreadMemberSettingsFlags(2 ** 32 + 2)).toBe(false);
|
||||
expect(isValidThreadMemberSettingsFlags(2 ** 32)).toBe(false);
|
||||
});
|
||||
|
||||
it('limits settable channel flags by type', () => {
|
||||
expect(settableChannelFlags(ChannelTypes.GUILD_TEXT)).toBe(0);
|
||||
expect(settableChannelFlags(ChannelTypes.PRIVATE_THREAD)).toBe(0);
|
||||
expect(settableChannelFlags(ChannelTypes.PUBLIC_THREAD)).toBe(0);
|
||||
expect(settableChannelFlags(ChannelTypes.PUBLIC_THREAD, ChannelTypes.GUILD_TEXT)).toBe(0);
|
||||
expect(settableChannelFlags(ChannelTypes.PUBLIC_THREAD, ChannelTypes.GUILD_FORUM)).toBe(ChannelFlags.PINNED);
|
||||
expect(settableChannelFlags(ChannelTypes.PUBLIC_THREAD, ChannelTypes.GUILD_MEDIA)).toBe(ChannelFlags.PINNED);
|
||||
expect(settableChannelFlags(ChannelTypes.PRIVATE_THREAD, ChannelTypes.GUILD_TEXT)).toBe(0);
|
||||
expect(settableChannelFlags(ChannelTypes.ANNOUNCEMENT_THREAD, ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(0);
|
||||
expect(settableChannelFlags(ChannelTypes.GUILD_FORUM)).toBe(ChannelFlags.REQUIRE_TAG);
|
||||
expect(settableChannelFlags(ChannelTypes.GUILD_MEDIA)).toBe(
|
||||
ChannelFlags.REQUIRE_TAG | ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
|
||||
export const THREAD_CHANNEL_TYPES = new Set<number>([
|
||||
ChannelTypes.ANNOUNCEMENT_THREAD,
|
||||
ChannelTypes.PUBLIC_THREAD,
|
||||
ChannelTypes.PRIVATE_THREAD,
|
||||
]);
|
||||
export const PUBLIC_THREAD_CHANNEL_TYPES = new Set<number>([
|
||||
ChannelTypes.ANNOUNCEMENT_THREAD,
|
||||
ChannelTypes.PUBLIC_THREAD,
|
||||
]);
|
||||
export const THREAD_ONLY_CHANNEL_TYPES = new Set<number>([ChannelTypes.GUILD_FORUM, ChannelTypes.GUILD_MEDIA]);
|
||||
export const TEXT_THREAD_PARENT_CHANNEL_TYPES = new Set<number>([
|
||||
ChannelTypes.GUILD_TEXT,
|
||||
ChannelTypes.GUILD_ANNOUNCEMENT,
|
||||
]);
|
||||
export const THREAD_PARENT_CHANNEL_TYPES = new Set<number>([
|
||||
...TEXT_THREAD_PARENT_CHANNEL_TYPES,
|
||||
...THREAD_ONLY_CHANNEL_TYPES,
|
||||
]);
|
||||
export const THREAD_FEATURE_CHANNEL_TYPES = new Set<number>([...THREAD_CHANNEL_TYPES, ...THREAD_ONLY_CHANNEL_TYPES]);
|
||||
|
||||
export function publicThreadTypeFor(parentType: number): number {
|
||||
return parentType === ChannelTypes.GUILD_ANNOUNCEMENT ? ChannelTypes.ANNOUNCEMENT_THREAD : ChannelTypes.PUBLIC_THREAD;
|
||||
}
|
||||
|
||||
export function resolveTextThreadType(parentType: number, requestedType: number): number | null {
|
||||
if (parentType === ChannelTypes.GUILD_ANNOUNCEMENT) {
|
||||
return PUBLIC_THREAD_CHANNEL_TYPES.has(requestedType) ? ChannelTypes.ANNOUNCEMENT_THREAD : null;
|
||||
}
|
||||
if (parentType !== ChannelTypes.GUILD_TEXT || requestedType === ChannelTypes.ANNOUNCEMENT_THREAD) return null;
|
||||
return THREAD_CHANNEL_TYPES.has(requestedType) ? requestedType : null;
|
||||
}
|
||||
|
||||
export const ServerMessageFlags = {
|
||||
HAS_THREAD: 1 << 5,
|
||||
FAILED_TO_MENTION_SOME_ROLES_IN_THREAD: 1 << 8,
|
||||
} as const;
|
||||
export const THREAD_MESSAGE_FLAG_MASK =
|
||||
ServerMessageFlags.HAS_THREAD | ServerMessageFlags.FAILED_TO_MENTION_SOME_ROLES_IN_THREAD;
|
||||
|
||||
export const ChannelFlags = {
|
||||
PINNED: 1 << 1,
|
||||
REQUIRE_TAG: 1 << 4,
|
||||
HIDE_MEDIA_DOWNLOAD_OPTIONS: 1 << 15,
|
||||
} as const;
|
||||
|
||||
export type ChannelFlag = ValueOf<typeof ChannelFlags>;
|
||||
|
||||
export function settableChannelFlags(type: number, parentType?: number | null): number {
|
||||
switch (type) {
|
||||
case ChannelTypes.PUBLIC_THREAD:
|
||||
return parentType != null && THREAD_ONLY_CHANNEL_TYPES.has(parentType) ? ChannelFlags.PINNED : 0;
|
||||
case ChannelTypes.GUILD_FORUM:
|
||||
return ChannelFlags.REQUIRE_TAG;
|
||||
case ChannelTypes.GUILD_MEDIA:
|
||||
return ChannelFlags.REQUIRE_TAG | ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const ThreadMemberFlags = {
|
||||
HAS_INTERACTED: 1 << 0,
|
||||
ALL_MESSAGES: 1 << 1,
|
||||
ONLY_MENTIONS: 1 << 2,
|
||||
NO_MESSAGES: 1 << 3,
|
||||
} as const;
|
||||
|
||||
export const THREAD_MEMBER_NOTIFICATION_FLAG_MASK =
|
||||
ThreadMemberFlags.ALL_MESSAGES | ThreadMemberFlags.ONLY_MENTIONS | ThreadMemberFlags.NO_MESSAGES;
|
||||
|
||||
export function isValidThreadMemberSettingsFlags(flags: number): boolean {
|
||||
if (!Number.isInteger(flags) || flags < 0 || flags > THREAD_MEMBER_NOTIFICATION_FLAG_MASK) return false;
|
||||
if ((flags & ~THREAD_MEMBER_NOTIFICATION_FLAG_MASK) !== 0) return false;
|
||||
const notify = flags & THREAD_MEMBER_NOTIFICATION_FLAG_MASK;
|
||||
return (notify & (notify - 1)) === 0;
|
||||
}
|
||||
|
||||
export const ReadStateFlags = {
|
||||
IS_GUILD_CHANNEL: 1 << 0,
|
||||
IS_THREAD: 1 << 1,
|
||||
} as const;
|
||||
|
||||
export const ChannelOverrideFlags = {
|
||||
NEW_FORUM_THREADS_OFF: 1 << 13,
|
||||
NEW_FORUM_THREADS_ON: 1 << 14,
|
||||
} as const;
|
||||
|
||||
export const CHANNEL_OVERRIDE_FLAG_MASK =
|
||||
ChannelOverrideFlags.NEW_FORUM_THREADS_OFF | ChannelOverrideFlags.NEW_FORUM_THREADS_ON;
|
||||
|
||||
export const ForumLayoutTypes = {
|
||||
DEFAULT: 0,
|
||||
LIST: 1,
|
||||
GRID: 2,
|
||||
} as const;
|
||||
|
||||
export type ForumLayoutType = ValueOf<typeof ForumLayoutTypes>;
|
||||
|
||||
export const ForumSortOrderTypes = {
|
||||
LATEST_ACTIVITY: 0,
|
||||
CREATION_TIME: 1,
|
||||
} as const;
|
||||
|
||||
export type ForumSortOrderType = ValueOf<typeof ForumSortOrderTypes>;
|
||||
|
||||
export const ForumTagSettings = {
|
||||
MATCH_SOME: 'match_some',
|
||||
MATCH_ALL: 'match_all',
|
||||
} as const;
|
||||
|
||||
export type ForumTagSetting = ValueOf<typeof ForumTagSettings>;
|
||||
|
||||
export const THREAD_AUTO_ARCHIVE_DURATIONS = [60, 1440, 4320, 10080] as const;
|
||||
|
||||
export type ThreadAutoArchiveDuration = (typeof THREAD_AUTO_ARCHIVE_DURATIONS)[number];
|
||||
|
||||
export const DEFAULT_THREAD_AUTO_ARCHIVE_DURATION: ThreadAutoArchiveDuration = 4320;
|
||||
|
||||
export function isThreadAutoArchiveDuration(value: number): value is ThreadAutoArchiveDuration {
|
||||
return (THREAD_AUTO_ARCHIVE_DURATIONS as ReadonlyArray<number>).includes(value);
|
||||
}
|
||||
|
||||
export const THREAD_NAME_MIN_LENGTH = 1;
|
||||
export const THREAD_NAME_MAX_LENGTH = 100;
|
||||
export const FORUM_TOPIC_MAX_LENGTH = 4096;
|
||||
export const MAX_FORUM_TAGS_PER_CHANNEL = 20;
|
||||
export const FORUM_TAG_NAME_MAX_LENGTH = 50;
|
||||
export const MAX_APPLIED_TAGS_PER_THREAD = 5;
|
||||
export const MAX_THREAD_MEMBERS = 1000;
|
||||
export const THREAD_MEMBER_COUNT_DISPLAY_CAP = 50;
|
||||
export const THREAD_MEMBER_IDS_PREVIEW_SIZE = 8;
|
||||
export const MAX_ACTIVE_THREADS_PER_GUILD = 1000;
|
||||
export const MAX_PINNED_THREADS_PER_FORUM = 1;
|
||||
export const MAX_ROLE_MENTION_THREAD_ADDS = 250;
|
||||
export const THREAD_ARCHIVED_LIST_MIN_LIMIT = 2;
|
||||
export const THREAD_ARCHIVED_LIST_MAX_LIMIT = 100;
|
||||
export const THREAD_ARCHIVED_LIST_DEFAULT_LIMIT = 50;
|
||||
export const THREAD_SEARCH_MIN_LIMIT = 1;
|
||||
export const THREAD_SEARCH_MAX_LIMIT = 25;
|
||||
export const THREAD_SEARCH_MAX_OFFSET = 9975;
|
||||
export const THREAD_SEARCH_NAME_MAX_LENGTH = 100;
|
||||
export const THREAD_SEARCH_MAX_TAGS = 20;
|
||||
export const POST_DATA_MAX_IDS = 100;
|
||||
export const FORUM_UNREADS_MAX_THREADS = 40;
|
||||
export const FORUM_UNREAD_COUNT_CAP = 25;
|
||||
export const MAX_THREAD_ARCHIVES_PER_SWEEP_TICK = 200;
|
||||
export const THREAD_SCOPE_MAX = 5000;
|
||||
export const SEARCH_INDEX_ENQUEUE_MAX = 50;
|
||||
export const THREAD_RATE_LIMIT_PER_USER_MAX = 21600;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import cases from '@fluxer/constants/src/ThreadPermissionCases.json' with {type: 'json'};
|
||||
import {
|
||||
canAddThreadMember,
|
||||
canCreateThread,
|
||||
canDeleteThread,
|
||||
canJoinThread,
|
||||
canLeave,
|
||||
canListArchivedThreads,
|
||||
canRemoveThreadMember,
|
||||
canSetTags,
|
||||
canUnarchive,
|
||||
canViewThread,
|
||||
DEFAULT_THREAD_PERMISSIONS,
|
||||
isThreadModerator,
|
||||
messageSendPermissionFor,
|
||||
THREAD_AWARE_ALL_PERMISSIONS,
|
||||
THREAD_PERMISSIONS,
|
||||
type ThreadActorContext,
|
||||
type ThreadCreateKind,
|
||||
type ThreadPatch,
|
||||
ThreadPermissionFlags,
|
||||
type ThreadWriteAction,
|
||||
threadPatchRequirement,
|
||||
threadViewPermissions,
|
||||
threadWriteBlock,
|
||||
withImplicitThreadBits,
|
||||
} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface CaseRow {
|
||||
name: string;
|
||||
fn: string;
|
||||
actor: {
|
||||
perms: Array<string>;
|
||||
isOwner?: boolean;
|
||||
timedOut?: boolean;
|
||||
isThreadOwner?: boolean;
|
||||
isMember?: boolean;
|
||||
};
|
||||
thread?: {type?: number; archived?: boolean; locked?: boolean; invitable?: boolean};
|
||||
args?: {
|
||||
action?: string;
|
||||
patch?: ThreadPatch;
|
||||
currentFlags?: number;
|
||||
touchesModeratedTag?: boolean;
|
||||
target?: {canViewParent: boolean; isModerator: boolean};
|
||||
kind?: string;
|
||||
privateThreads?: boolean;
|
||||
};
|
||||
expect: string | boolean | null;
|
||||
}
|
||||
|
||||
const PERMISSION_BITS: Record<string, bigint> = {...Permissions, ...ThreadPermissionFlags};
|
||||
|
||||
function permissionsOf(names: Array<string>): bigint {
|
||||
return names.reduce((acc, name) => {
|
||||
const bit = PERMISSION_BITS[name];
|
||||
if (bit === undefined) throw new Error(`unknown permission ${name}`);
|
||||
return acc | bit;
|
||||
}, 0n);
|
||||
}
|
||||
|
||||
function contextOf(row: CaseRow): ThreadActorContext {
|
||||
return {
|
||||
permissions: permissionsOf(row.actor.perms),
|
||||
isOwner: row.actor.isOwner ?? false,
|
||||
timedOut: row.actor.timedOut ?? false,
|
||||
isThreadOwner: row.actor.isThreadOwner ?? false,
|
||||
isMember: row.actor.isMember ?? false,
|
||||
thread: {
|
||||
type: row.thread?.type ?? ChannelTypes.PUBLIC_THREAD,
|
||||
archived: row.thread?.archived ?? false,
|
||||
locked: row.thread?.locked ?? false,
|
||||
invitable: row.thread?.invitable ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function evaluate(row: CaseRow): string | boolean | null {
|
||||
const ctx = contextOf(row);
|
||||
const args = row.args ?? {};
|
||||
switch (row.fn) {
|
||||
case 'isThreadModerator':
|
||||
return isThreadModerator(ctx.permissions, ctx);
|
||||
case 'canViewThread':
|
||||
return canViewThread(ctx);
|
||||
case 'threadWriteBlock':
|
||||
return threadWriteBlock(args.action as ThreadWriteAction, ctx);
|
||||
case 'canUnarchive':
|
||||
return canUnarchive(ctx);
|
||||
case 'threadPatchRequirement':
|
||||
return threadPatchRequirement(args.patch ?? {}, args.currentFlags ?? 0, ctx);
|
||||
case 'canSetTags':
|
||||
return canSetTags(ctx, {touchesModeratedTag: args.touchesModeratedTag ?? false});
|
||||
case 'canJoinThread':
|
||||
return canJoinThread(ctx);
|
||||
case 'canAddThreadMember':
|
||||
return canAddThreadMember(ctx, args.target ?? {canViewParent: true, isModerator: false});
|
||||
case 'canRemoveThreadMember':
|
||||
return canRemoveThreadMember(ctx);
|
||||
case 'canLeave':
|
||||
return canLeave(ctx);
|
||||
case 'canDeleteThread':
|
||||
return canDeleteThread(ctx);
|
||||
case 'canCreateThread':
|
||||
return canCreateThread(ctx, args.kind as ThreadCreateKind);
|
||||
case 'canListArchivedThreads':
|
||||
return canListArchivedThreads(ctx, {privateThreads: args.privateThreads ?? false});
|
||||
default:
|
||||
throw new Error(`unknown case function ${row.fn}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('thread permission case table', () => {
|
||||
it.each((cases as Array<CaseRow>).map((row) => [row.name, row] as const))('%s', (_name, row) => {
|
||||
expect(evaluate(row)).toBe(row.expect);
|
||||
});
|
||||
|
||||
it('has unique case names', () => {
|
||||
const names = (cases as Array<CaseRow>).map((row) => row.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thread permission bits', () => {
|
||||
it('uses the expected bit positions', () => {
|
||||
expect(ThreadPermissionFlags.MANAGE_THREADS).toBe(1n << 34n);
|
||||
expect(ThreadPermissionFlags.CREATE_PUBLIC_THREADS).toBe(1n << 35n);
|
||||
expect(ThreadPermissionFlags.CREATE_PRIVATE_THREADS).toBe(1n << 36n);
|
||||
expect(ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS).toBe(1n << 38n);
|
||||
});
|
||||
|
||||
it('stays outside the control permission set', () => {
|
||||
expect(ALL_PERMISSIONS & THREAD_PERMISSIONS).toBe(0n);
|
||||
expect(THREAD_AWARE_ALL_PERMISSIONS).toBe(ALL_PERMISSIONS | THREAD_PERMISSIONS);
|
||||
});
|
||||
|
||||
it('defaults to everything except manage threads', () => {
|
||||
expect(DEFAULT_THREAD_PERMISSIONS).toBe(THREAD_PERMISSIONS & ~ThreadPermissionFlags.MANAGE_THREADS);
|
||||
});
|
||||
|
||||
it('grants every thread bit to administrators only', () => {
|
||||
expect(withImplicitThreadBits(Permissions.ADMINISTRATOR) & THREAD_PERMISSIONS).toBe(THREAD_PERMISSIONS);
|
||||
expect(withImplicitThreadBits(Permissions.MANAGE_CHANNELS)).toBe(Permissions.MANAGE_CHANNELS);
|
||||
});
|
||||
|
||||
it('aliases send messages to send messages in threads', () => {
|
||||
const parent = Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES;
|
||||
expect(threadViewPermissions(parent) & Permissions.SEND_MESSAGES).toBe(0n);
|
||||
const threadSender = Permissions.VIEW_CHANNEL | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
|
||||
expect(threadViewPermissions(threadSender) & Permissions.SEND_MESSAGES).toBe(Permissions.SEND_MESSAGES);
|
||||
expect(threadViewPermissions(Permissions.ADMINISTRATOR) & Permissions.SEND_MESSAGES).toBe(
|
||||
Permissions.SEND_MESSAGES,
|
||||
);
|
||||
});
|
||||
|
||||
it('picks the send bit by channel type', () => {
|
||||
expect(messageSendPermissionFor(ChannelTypes.ANNOUNCEMENT_THREAD)).toBe(
|
||||
ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS,
|
||||
);
|
||||
expect(messageSendPermissionFor(ChannelTypes.PUBLIC_THREAD)).toBe(ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS);
|
||||
expect(messageSendPermissionFor(ChannelTypes.PRIVATE_THREAD)).toBe(ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS);
|
||||
expect(messageSendPermissionFor(ChannelTypes.GUILD_TEXT)).toBe(Permissions.SEND_MESSAGES);
|
||||
expect(messageSendPermissionFor(ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(Permissions.SEND_MESSAGES);
|
||||
expect(messageSendPermissionFor(ChannelTypes.GUILD_FORUM)).toBe(Permissions.SEND_MESSAGES);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {THREAD_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
|
||||
|
||||
export const ThreadPermissionFlags = {
|
||||
MANAGE_THREADS: 1n << 34n,
|
||||
CREATE_PUBLIC_THREADS: 1n << 35n,
|
||||
CREATE_PRIVATE_THREADS: 1n << 36n,
|
||||
SEND_MESSAGES_IN_THREADS: 1n << 38n,
|
||||
} as const;
|
||||
|
||||
export const THREAD_PERMISSIONS = Object.values(ThreadPermissionFlags).reduce((acc, p) => acc | p, 0n);
|
||||
export const DEFAULT_THREAD_PERMISSIONS =
|
||||
ThreadPermissionFlags.CREATE_PUBLIC_THREADS |
|
||||
ThreadPermissionFlags.CREATE_PRIVATE_THREADS |
|
||||
ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
|
||||
export const THREAD_AWARE_ALL_PERMISSIONS = ALL_PERMISSIONS | THREAD_PERMISSIONS;
|
||||
|
||||
export type ThreadDenial =
|
||||
| typeof APIErrorCodes.MISSING_ACCESS
|
||||
| typeof APIErrorCodes.MISSING_PERMISSIONS
|
||||
| typeof APIErrorCodes.COMMUNICATION_DISABLED
|
||||
| typeof APIErrorCodes.THREAD_ARCHIVED
|
||||
| typeof APIErrorCodes.THREAD_LOCKED
|
||||
| typeof APIErrorCodes.UNKNOWN_THREAD_MEMBER;
|
||||
|
||||
export interface ThreadActor {
|
||||
permissions: bigint;
|
||||
isOwner: boolean;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadFacts {
|
||||
type: number;
|
||||
archived: boolean;
|
||||
locked: boolean;
|
||||
invitable: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadActorContext extends ThreadActor {
|
||||
thread: ThreadFacts;
|
||||
isThreadOwner: boolean;
|
||||
isMember: boolean;
|
||||
}
|
||||
|
||||
export type ThreadWriteAction = 'send' | 'react' | 'pin' | 'edit' | 'delete';
|
||||
|
||||
export interface ThreadPatch {
|
||||
name?: unknown;
|
||||
archived?: boolean;
|
||||
auto_archive_duration?: unknown;
|
||||
locked?: boolean;
|
||||
invitable?: unknown;
|
||||
rate_limit_per_user?: unknown;
|
||||
flags?: number;
|
||||
applied_tags?: unknown;
|
||||
}
|
||||
|
||||
export type ThreadCreateKind = 'from_message' | 'public' | 'private' | 'forum_post';
|
||||
|
||||
function has(perms: bigint, bit: bigint): boolean {
|
||||
return (perms & bit) === bit;
|
||||
}
|
||||
|
||||
export function withImplicitThreadBits(perms: bigint): bigint {
|
||||
return has(perms, Permissions.ADMINISTRATOR) ? perms | THREAD_AWARE_ALL_PERMISSIONS : perms;
|
||||
}
|
||||
|
||||
export function isThreadModerator(perms: bigint, {isOwner, timedOut}: {isOwner: boolean; timedOut: boolean}): boolean {
|
||||
if (isOwner || has(perms, Permissions.ADMINISTRATOR)) return true;
|
||||
return !timedOut && has(perms, ThreadPermissionFlags.MANAGE_THREADS);
|
||||
}
|
||||
|
||||
function isCommunicationBlocked(actor: ThreadActor): boolean {
|
||||
return actor.timedOut && !actor.isOwner && !has(actor.permissions, Permissions.ADMINISTRATOR);
|
||||
}
|
||||
|
||||
function isModerator(actor: ThreadActor): boolean {
|
||||
return isThreadModerator(withImplicitThreadBits(actor.permissions), actor);
|
||||
}
|
||||
|
||||
export function threadViewPermissions(parentPerms: bigint): bigint {
|
||||
const perms = withImplicitThreadBits(parentPerms);
|
||||
const withoutSend = perms & ~Permissions.SEND_MESSAGES;
|
||||
return has(perms, ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS)
|
||||
? withoutSend | Permissions.SEND_MESSAGES
|
||||
: withoutSend;
|
||||
}
|
||||
|
||||
export function messageSendPermissionFor(type: number): bigint {
|
||||
return THREAD_CHANNEL_TYPES.has(type) ? ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS : Permissions.SEND_MESSAGES;
|
||||
}
|
||||
|
||||
export function canViewThread(ctx: ThreadActorContext): ThreadDenial | null {
|
||||
const perms = withImplicitThreadBits(ctx.permissions);
|
||||
if (!ctx.isOwner && !has(perms, Permissions.VIEW_CHANNEL)) return APIErrorCodes.MISSING_ACCESS;
|
||||
if (ctx.thread.type === ChannelTypes.PRIVATE_THREAD && !ctx.isMember && !isModerator(ctx)) {
|
||||
return APIErrorCodes.MISSING_ACCESS;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function threadWriteBlock(action: ThreadWriteAction, ctx: ThreadActorContext): ThreadDenial | null {
|
||||
if (action === 'delete') return null;
|
||||
if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED;
|
||||
const moderator = isModerator(ctx);
|
||||
if (action === 'send') {
|
||||
return ctx.thread.locked && !moderator ? APIErrorCodes.THREAD_LOCKED : null;
|
||||
}
|
||||
if (ctx.thread.archived) return APIErrorCodes.THREAD_ARCHIVED;
|
||||
if (ctx.thread.locked && !moderator) return APIErrorCodes.THREAD_LOCKED;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canUnarchive(ctx: ThreadActorContext): ThreadDenial | null {
|
||||
if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED;
|
||||
if (isModerator(ctx)) return null;
|
||||
if (ctx.thread.locked) return APIErrorCodes.THREAD_LOCKED;
|
||||
return ctx.isMember || ctx.isThreadOwner ? null : APIErrorCodes.MISSING_PERMISSIONS;
|
||||
}
|
||||
|
||||
export function threadPatchRequirement(
|
||||
patch: ThreadPatch,
|
||||
currentFlags: number,
|
||||
ctx: ThreadActorContext,
|
||||
): ThreadDenial | null {
|
||||
if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED;
|
||||
const moderator = isModerator(ctx);
|
||||
const ownerOrModerator = moderator || ctx.isThreadOwner;
|
||||
if (patch.archived === false && ctx.thread.archived) {
|
||||
const unarchive = canUnarchive(ctx);
|
||||
if (unarchive !== null) return unarchive;
|
||||
}
|
||||
const staysArchived = ctx.thread.archived && patch.archived !== false;
|
||||
const touchesOtherFields =
|
||||
patch.name !== undefined ||
|
||||
patch.auto_archive_duration !== undefined ||
|
||||
patch.locked !== undefined ||
|
||||
patch.invitable !== undefined ||
|
||||
patch.rate_limit_per_user !== undefined ||
|
||||
(patch.flags !== undefined && patch.flags !== currentFlags) ||
|
||||
patch.applied_tags !== undefined;
|
||||
if (staysArchived && touchesOtherFields) return APIErrorCodes.THREAD_ARCHIVED;
|
||||
if (patch.locked !== undefined && patch.locked !== ctx.thread.locked && !moderator) {
|
||||
if (!(patch.locked && ctx.isThreadOwner)) return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
}
|
||||
if (ctx.thread.locked && !moderator) return APIErrorCodes.THREAD_LOCKED;
|
||||
if ((patch.name !== undefined || patch.auto_archive_duration !== undefined) && !ownerOrModerator) {
|
||||
return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
}
|
||||
if (patch.archived === true && !ctx.thread.archived && !ownerOrModerator) return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
if (patch.invitable !== undefined && !ownerOrModerator) return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
if (patch.rate_limit_per_user !== undefined && !moderator) return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
if (patch.flags !== undefined && patch.flags !== currentFlags && !moderator) return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canSetTags(
|
||||
ctx: ThreadActorContext,
|
||||
{touchesModeratedTag}: {touchesModeratedTag: boolean},
|
||||
): ThreadDenial | null {
|
||||
if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED;
|
||||
const moderator = isModerator(ctx);
|
||||
if (!moderator && !ctx.isThreadOwner) return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
if (touchesModeratedTag && !moderator) return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canJoinThread(ctx: ThreadActorContext): ThreadDenial | null {
|
||||
const view = canViewThread(ctx);
|
||||
if (view !== null) return view;
|
||||
if (ctx.thread.archived) return APIErrorCodes.THREAD_ARCHIVED;
|
||||
if (ctx.thread.locked && !isModerator(ctx)) return APIErrorCodes.THREAD_LOCKED;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canAddThreadMember(
|
||||
ctx: ThreadActorContext,
|
||||
target: {canViewParent: boolean; isModerator: boolean},
|
||||
): ThreadDenial | null {
|
||||
if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED;
|
||||
if (ctx.thread.archived) return APIErrorCodes.THREAD_ARCHIVED;
|
||||
const moderator = isModerator(ctx);
|
||||
if (ctx.thread.locked && !moderator) return APIErrorCodes.THREAD_LOCKED;
|
||||
if (!has(threadViewPermissions(ctx.permissions), Permissions.SEND_MESSAGES)) return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
if (ctx.thread.type === ChannelTypes.PRIVATE_THREAD && !ctx.thread.invitable && !moderator && !target.isModerator) {
|
||||
return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
}
|
||||
if (!target.canViewParent) return APIErrorCodes.MISSING_ACCESS;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canRemoveThreadMember(ctx: ThreadActorContext): ThreadDenial | null {
|
||||
if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED;
|
||||
if (ctx.thread.archived) return APIErrorCodes.THREAD_ARCHIVED;
|
||||
if (isModerator(ctx)) return null;
|
||||
if (ctx.thread.type === ChannelTypes.PRIVATE_THREAD && ctx.isThreadOwner) return null;
|
||||
return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
}
|
||||
|
||||
export function canLeave(ctx: ThreadActorContext): ThreadDenial | null {
|
||||
if (!ctx.isMember) return APIErrorCodes.UNKNOWN_THREAD_MEMBER;
|
||||
if (ctx.thread.archived) return APIErrorCodes.THREAD_ARCHIVED;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canDeleteThread(ctx: ThreadActor): ThreadDenial | null {
|
||||
if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED;
|
||||
return isModerator(ctx) ? null : APIErrorCodes.MISSING_PERMISSIONS;
|
||||
}
|
||||
|
||||
export function canCreateThread(actor: ThreadActor, kind: ThreadCreateKind): ThreadDenial | null {
|
||||
const perms = withImplicitThreadBits(actor.permissions);
|
||||
if (actor.isOwner) return null;
|
||||
if (!has(perms, Permissions.VIEW_CHANNEL)) return APIErrorCodes.MISSING_ACCESS;
|
||||
if (isCommunicationBlocked(actor)) return APIErrorCodes.COMMUNICATION_DISABLED;
|
||||
switch (kind) {
|
||||
case 'forum_post':
|
||||
return has(perms, Permissions.SEND_MESSAGES) ? null : APIErrorCodes.MISSING_PERMISSIONS;
|
||||
case 'from_message':
|
||||
return has(perms, ThreadPermissionFlags.CREATE_PUBLIC_THREADS) && has(perms, Permissions.READ_MESSAGE_HISTORY)
|
||||
? null
|
||||
: APIErrorCodes.MISSING_PERMISSIONS;
|
||||
case 'public':
|
||||
return has(perms, ThreadPermissionFlags.CREATE_PUBLIC_THREADS) ? null : APIErrorCodes.MISSING_PERMISSIONS;
|
||||
case 'private':
|
||||
return has(perms, ThreadPermissionFlags.CREATE_PRIVATE_THREADS) ? null : APIErrorCodes.MISSING_PERMISSIONS;
|
||||
}
|
||||
}
|
||||
|
||||
export function canListArchivedThreads(
|
||||
actor: ThreadActor,
|
||||
{privateThreads}: {privateThreads: boolean},
|
||||
): ThreadDenial | null {
|
||||
const perms = withImplicitThreadBits(actor.permissions);
|
||||
if (!actor.isOwner && !has(perms, Permissions.READ_MESSAGE_HISTORY)) return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
if (privateThreads && !isModerator(actor)) return APIErrorCodes.MISSING_PERMISSIONS;
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user