feat(threads): thread and forum API, admin, schemas and docs (#3251)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:25:12 +02:00
1 parent 5f4295e399
commit 3f4160b138
495 files changed
+39232 -1215

No files matched your search

+20
View File
@@ -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);
});
});
+5 -3
View File
@@ -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,
);
});
});
+154
View File
@@ -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;
}