mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(guild): import all template channel types, fetch on desktop (#3473)
This commit is contained in:
1 parent
00249f9b4d
commit
99be186fa1
50 files changed
+2008
-111
No files matched your search
@@ -18,6 +18,7 @@ import {
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
import {getContentMessage} from '@app/api/content_i18n/ContentI18n';
|
||||
import {BatchBuilder} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Db, type DbOp} from '@app/api/database/CassandraTypes';
|
||||
import type {PermissionOverwrite} from '@app/api/database/types/ChannelTypes';
|
||||
import type {GuildRow} from '@app/api/database/types/GuildTypes';
|
||||
import {isTainted} from '@app/api/experiment/ChannelThreadsGate';
|
||||
@@ -25,6 +26,11 @@ import {mapGuildToGuildResponse, mapGuildToPartialResponse} from '@app/api/guild
|
||||
import type {GuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
|
||||
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
|
||||
import type {GuildDataHelpers} from '@app/api/guild/services/data/GuildDataHelpers';
|
||||
import {
|
||||
buildTemplateThreadParentConfig,
|
||||
clipTemplateTopic,
|
||||
mapOtherPlatformTemplateChannelType,
|
||||
} from '@app/api/guild/services/data/TemplateChannelImport';
|
||||
import {memberEndsInvitePause} from '@app/api/guild/services/GuildInvitePauseApplier';
|
||||
import {emitGuildCreated, emitGuildFeaturesChanged} from '@app/api/infrastructure/activity/GuildEvents';
|
||||
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
|
||||
@@ -43,7 +49,16 @@ import {getGuildSearchService} from '@app/api/SearchFactory';
|
||||
import type {GuildDiscoveryContext} from '@app/api/search/guild/GuildSearchSerializer';
|
||||
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
|
||||
import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService';
|
||||
import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '@app/api/Tables';
|
||||
import {
|
||||
Channels,
|
||||
ChannelsByGuild,
|
||||
GuildMembers,
|
||||
GuildMembersByUserId,
|
||||
GuildRoles,
|
||||
Guilds,
|
||||
ThreadOnlyChannelsByGuild,
|
||||
ThreadParentConfig as ThreadParentConfigTable,
|
||||
} from '@app/api/Tables';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import {mapUserSettingsToResponse} from '@app/api/user/UserMappers';
|
||||
import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '@app/api/user/utils/GuildFolderUtils';
|
||||
@@ -71,6 +86,7 @@ import {
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
VOICE_CHANNEL_USER_LIMIT_MAX,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
|
||||
import {
|
||||
DEFAULT_THREAD_PERMISSIONS,
|
||||
THREAD_AWARE_ALL_PERMISSIONS,
|
||||
@@ -190,7 +206,6 @@ const THREAD_PURGE_CONCURRENCY = 8;
|
||||
const GUILD_DELETION_MEMBER_CONCURRENCY = 25;
|
||||
const GUILD_DELETION_PENDING_FEATURES = [GuildFeatures.UNAVAILABLE_FOR_EVERYONE, GuildFeatures.UNAVAILABLE_HIDDEN];
|
||||
const TEMPLATE_AFK_TIMEOUT_MAX_SECONDS = 3600;
|
||||
const THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE = 13;
|
||||
|
||||
function isSystemChannelType(type: number): boolean {
|
||||
return type === ChannelTypes.GUILD_TEXT || type === ChannelTypes.GUILD_ANNOUNCEMENT;
|
||||
@@ -1024,7 +1039,7 @@ export class GuildOperationsService {
|
||||
throw new GuildTemplateInvalidError();
|
||||
}
|
||||
seenChannelKeys.add(channelKey);
|
||||
const fluxerType = this.mapOtherPlatformTemplateChannelTypeToFluxer(channel.type);
|
||||
const fluxerType = mapOtherPlatformTemplateChannelType(channel.type);
|
||||
if (fluxerType == null) {
|
||||
skippedOtherPlatformChannelTypes.add(channel.type);
|
||||
continue;
|
||||
@@ -1102,7 +1117,9 @@ export class GuildOperationsService {
|
||||
const parentKey = channel.parent_id != null ? this.getTemplateEntityKey(channel.parent_id) : null;
|
||||
const parentType = parentKey != null ? channelTypeMap.get(parentKey) : null;
|
||||
const parentId =
|
||||
parentKey != null && parentType === ChannelTypes.GUILD_CATEGORY ? (channelIdMap.get(parentKey) ?? null) : null;
|
||||
fluxerType !== ChannelTypes.GUILD_CATEGORY && parentKey != null && parentType === ChannelTypes.GUILD_CATEGORY
|
||||
? (channelIdMap.get(parentKey) ?? null)
|
||||
: null;
|
||||
const isVoice = fluxerType === ChannelTypes.GUILD_VOICE;
|
||||
let permissionOverwrites: Map<RoleID | UserID, PermissionOverwrite> | null = null;
|
||||
if (channel.permission_overwrites && channel.permission_overwrites.length > 0) {
|
||||
@@ -1132,7 +1149,7 @@ export class GuildOperationsService {
|
||||
guild_id: guildId,
|
||||
type: fluxerType,
|
||||
name: channel.name,
|
||||
topic: channel.topic ?? null,
|
||||
topic: clipTemplateTopic(channel.topic, fluxerType),
|
||||
icon_hash: null,
|
||||
url: null,
|
||||
parent_id: parentId,
|
||||
@@ -1160,11 +1177,25 @@ export class GuildOperationsService {
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
ChannelsByGuild.upsertAll({
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
THREAD_ONLY_CHANNEL_TYPES.has(fluxerType)
|
||||
? ThreadOnlyChannelsByGuild.upsertAll({guild_id: guildId, channel_id: channelId})
|
||||
: ChannelsByGuild.upsertAll({guild_id: guildId, channel_id: channelId}),
|
||||
);
|
||||
const parentConfig = await buildTemplateThreadParentConfig(channel, fluxerType, () =>
|
||||
this.snowflakeService.generate(),
|
||||
);
|
||||
if (parentConfig) {
|
||||
const ops: Record<string, DbOp<unknown>> = {};
|
||||
for (const [key, value] of Object.entries(parentConfig)) {
|
||||
if (value !== undefined) ops[key] = Db.set(value);
|
||||
}
|
||||
batch.addPrepared(
|
||||
ThreadParentConfigTable.patchByPk(
|
||||
{guild_id: guildId, channel_id: channelId},
|
||||
ops as Parameters<typeof ThreadParentConfigTable.patchByPk>[1],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let systemChannelId: ChannelID | null = null;
|
||||
if (template.system_channel_id != null) {
|
||||
@@ -1241,21 +1272,6 @@ export class GuildOperationsService {
|
||||
return roles.find((role) => this.getTemplateEntityKey(role.id) === '0') ?? null;
|
||||
}
|
||||
|
||||
private mapOtherPlatformTemplateChannelTypeToFluxer(channelType: number): number | null {
|
||||
if (
|
||||
channelType === ChannelTypes.GUILD_TEXT ||
|
||||
channelType === ChannelTypes.GUILD_ANNOUNCEMENT ||
|
||||
channelType === ChannelTypes.GUILD_VOICE ||
|
||||
channelType === ChannelTypes.GUILD_CATEGORY
|
||||
) {
|
||||
return channelType;
|
||||
}
|
||||
if (channelType === THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE) {
|
||||
return ChannelTypes.GUILD_VOICE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private sanitiseTemplateGuildSettings(template?: TemplateSerializedGuild): TemplateGuildSettings {
|
||||
return {
|
||||
verificationLevel: this.clampTemplateSetting(
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ThreadParentConfigPatch} from '@app/api/channel/repositories/ThreadRepository';
|
||||
import type {ForumTagUdt} from '@app/api/database/types/ThreadTypes';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {CHANNEL_TOPIC_MAX_LENGTH} from '@fluxer/constants/src/LimitConstants';
|
||||
import {
|
||||
ChannelFlags,
|
||||
FORUM_TAG_NAME_MAX_LENGTH,
|
||||
FORUM_TOPIC_MAX_LENGTH,
|
||||
ForumLayoutTypes,
|
||||
ForumSortOrderTypes,
|
||||
ForumTagSettings,
|
||||
MAX_FORUM_TAGS_PER_CHANNEL,
|
||||
settableChannelFlags,
|
||||
THREAD_AUTO_ARCHIVE_DURATIONS,
|
||||
THREAD_ONLY_CHANNEL_TYPES,
|
||||
THREAD_PARENT_CHANNEL_TYPES,
|
||||
THREAD_RATE_LIMIT_PER_USER_MAX,
|
||||
} from '@fluxer/constants/src/ThreadConstants';
|
||||
import type {TemplateChannel} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
|
||||
import {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators';
|
||||
|
||||
const THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE = 13;
|
||||
|
||||
const IMPORTED_CHANNEL_TYPES = new Set<number>([
|
||||
ChannelTypes.GUILD_TEXT,
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
ChannelTypes.GUILD_CATEGORY,
|
||||
ChannelTypes.GUILD_ANNOUNCEMENT,
|
||||
ChannelTypes.GUILD_FORUM,
|
||||
ChannelTypes.GUILD_MEDIA,
|
||||
]);
|
||||
|
||||
const FORUM_SORT_ORDERS = new Set<number>(Object.values(ForumSortOrderTypes));
|
||||
const FORUM_LAYOUTS = new Set<number>(Object.values(ForumLayoutTypes));
|
||||
const FORUM_TAG_SETTINGS = new Set<string>(Object.values(ForumTagSettings));
|
||||
const AUTO_ARCHIVE_DURATIONS = new Set<number>(THREAD_AUTO_ARCHIVE_DURATIONS);
|
||||
|
||||
export function mapOtherPlatformTemplateChannelType(channelType: number): number | null {
|
||||
if (IMPORTED_CHANNEL_TYPES.has(channelType)) return channelType;
|
||||
if (channelType === THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE) return ChannelTypes.GUILD_VOICE;
|
||||
return null;
|
||||
}
|
||||
|
||||
function clipText(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
const last = value.charCodeAt(maxLength - 1);
|
||||
const end = last >= 0xd800 && last <= 0xdbff ? maxLength - 1 : maxLength;
|
||||
return value.slice(0, end);
|
||||
}
|
||||
|
||||
export function clipTemplateTopic(topic: string | null | undefined, channelType: number): string | null {
|
||||
if (!topic) return null;
|
||||
const maxLength = THREAD_ONLY_CHANNEL_TYPES.has(channelType) ? FORUM_TOPIC_MAX_LENGTH : CHANNEL_TOPIC_MAX_LENGTH;
|
||||
return clipText(topic, maxLength);
|
||||
}
|
||||
|
||||
function pickUnicodeEmoji(emojiName: string | null | undefined): string | null {
|
||||
return emojiName != null && isValidSingleUnicodeEmoji(emojiName) ? emojiName : null;
|
||||
}
|
||||
|
||||
async function buildTemplateTags(
|
||||
tags: NonNullable<TemplateChannel['available_tags']>,
|
||||
generateId: () => Promise<bigint>,
|
||||
): Promise<Array<ForumTagUdt>> {
|
||||
const names = new Set<string>();
|
||||
const result: Array<ForumTagUdt> = [];
|
||||
for (const tag of tags) {
|
||||
if (result.length >= MAX_FORUM_TAGS_PER_CHANNEL) break;
|
||||
const name = clipText(tag.name.trim(), FORUM_TAG_NAME_MAX_LENGTH);
|
||||
if (name.length === 0 || names.has(name)) continue;
|
||||
names.add(name);
|
||||
result.push({
|
||||
id: await generateId(),
|
||||
name,
|
||||
moderated: tag.moderated === true,
|
||||
emoji_id: null,
|
||||
emoji_name: pickUnicodeEmoji(tag.emoji_name),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function buildTemplateThreadParentConfig(
|
||||
channel: TemplateChannel,
|
||||
channelType: number,
|
||||
generateId: () => Promise<bigint>,
|
||||
): Promise<ThreadParentConfigPatch | null> {
|
||||
if (!THREAD_PARENT_CHANNEL_TYPES.has(channelType)) return null;
|
||||
const patch: ThreadParentConfigPatch = {};
|
||||
const archiveDuration = channel.default_auto_archive_duration;
|
||||
if (archiveDuration != null && AUTO_ARCHIVE_DURATIONS.has(archiveDuration)) {
|
||||
patch.default_auto_archive_duration = archiveDuration;
|
||||
}
|
||||
const threadSlowmode = channel.default_thread_rate_limit_per_user;
|
||||
if (threadSlowmode != null && threadSlowmode > 0) {
|
||||
patch.default_thread_rate_limit_per_user = Math.min(threadSlowmode, THREAD_RATE_LIMIT_PER_USER_MAX);
|
||||
}
|
||||
if (THREAD_ONLY_CHANNEL_TYPES.has(channelType)) {
|
||||
const tags = await buildTemplateTags(channel.available_tags ?? [], generateId);
|
||||
if (tags.length > 0) patch.available_tags = tags;
|
||||
const reactionEmoji = pickUnicodeEmoji(channel.default_reaction_emoji?.emoji_name);
|
||||
if (reactionEmoji) patch.default_reaction_emoji_name = reactionEmoji;
|
||||
const sortOrder = channel.default_sort_order;
|
||||
if (sortOrder != null && FORUM_SORT_ORDERS.has(sortOrder)) patch.default_sort_order = sortOrder;
|
||||
const layout = channel.default_forum_layout;
|
||||
if (channelType === ChannelTypes.GUILD_FORUM && layout != null && FORUM_LAYOUTS.has(layout)) {
|
||||
patch.default_forum_layout = layout;
|
||||
}
|
||||
const tagSetting = channel.default_tag_setting;
|
||||
if (tagSetting != null && FORUM_TAG_SETTINGS.has(tagSetting)) patch.default_tag_setting = tagSetting;
|
||||
let flags = (channel.flags ?? 0) & settableChannelFlags(channelType);
|
||||
if (!tags.some((tag) => !tag.moderated)) flags &= ~ChannelFlags.REQUIRE_TAG;
|
||||
if (flags !== 0) patch.flags = flags;
|
||||
}
|
||||
return Object.keys(patch).length > 0 ? patch : null;
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuildID} from '@app/api/BrandedTypes';
|
||||
import {fetchMany} from '@app/api/database/CassandraQueryExecution';
|
||||
import {getGuildChannels, getRoles} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {ChannelsByGuild, ThreadOnlyChannelsByGuild} from '@app/api/Tables';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {SystemChannelFlags} from '@fluxer/constants/src/GuildConstants';
|
||||
import {CHANNEL_TOPIC_MAX_LENGTH, VOICE_CHANNEL_USER_LIMIT_MAX} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ChannelFlags, ForumLayoutTypes, ForumSortOrderTypes} from '@fluxer/constants/src/ThreadConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
@@ -110,7 +115,7 @@ describe('Guild Template Import', () => {
|
||||
expect(guild.system_channel_flags).toBe(SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS);
|
||||
expect(guild.afk_timeout).toBe(60);
|
||||
});
|
||||
test('maps supported competitor-only channel types to Fluxer equivalents and ignores unsupported ones', async () => {
|
||||
test('imports announcement, stage, forum and media channels and ignores unsupported types', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createBuilder<GuildResponse>(harness, account.token)
|
||||
.post('/guilds')
|
||||
@@ -155,16 +160,31 @@ describe('Guild Template Import', () => {
|
||||
},
|
||||
{
|
||||
id: '4104',
|
||||
type: 16,
|
||||
type: ChannelTypes.GUILD_MEDIA,
|
||||
name: 'media-feed',
|
||||
parent_id: '4101',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
id: '4105',
|
||||
type: ChannelTypes.GUILD_FORUM,
|
||||
name: 'help',
|
||||
parent_id: '4101',
|
||||
position: 4,
|
||||
rate_limit_per_user: 120,
|
||||
},
|
||||
{
|
||||
id: '4106',
|
||||
type: 14,
|
||||
name: 'directory',
|
||||
parent_id: '4101',
|
||||
position: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.execute();
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const channels = await listThreadAwareChannels(account.token, guild.id);
|
||||
const categoryChannel = channels.find((channel) => channel.name === 'Main');
|
||||
const announcementsChannel = channels.find((channel) => channel.name === 'announcements');
|
||||
const stageChannel = channels.find((channel) => channel.name === 'town-hall');
|
||||
@@ -176,7 +196,14 @@ describe('Guild Template Import', () => {
|
||||
expect(stageChannel).toBeDefined();
|
||||
expect(stageChannel?.type).toBe(ChannelTypes.GUILD_VOICE);
|
||||
expect(stageChannel?.parent_id).toBe(categoryChannel?.id ?? null);
|
||||
expect(mediaChannel).toBeUndefined();
|
||||
const forumChannel = channels.find((channel) => channel.name === 'help');
|
||||
expect(mediaChannel?.type).toBe(ChannelTypes.GUILD_MEDIA);
|
||||
expect(mediaChannel?.parent_id).toBe(categoryChannel?.id ?? null);
|
||||
expect(mediaChannel?.position).toBe(3);
|
||||
expect(forumChannel?.type).toBe(ChannelTypes.GUILD_FORUM);
|
||||
expect(forumChannel?.parent_id).toBe(categoryChannel?.id ?? null);
|
||||
expect(forumChannel?.rate_limit_per_user).toBe(120);
|
||||
expect(channels.find((channel) => channel.name === 'directory')).toBeUndefined();
|
||||
expect(guild.system_channel_id).toBe(announcementsChannel?.id ?? null);
|
||||
});
|
||||
test('accepts template roles and channels with empty names', async () => {
|
||||
@@ -273,7 +300,7 @@ describe('Guild Template Import', () => {
|
||||
expect(channels.find((channel) => channel.name === 'town-hall')?.user_limit).toBe(VOICE_CHANNEL_USER_LIMIT_MAX);
|
||||
expect(channels.find((channel) => channel.name === 'lounge')?.voice_connection_limit).toBe(100);
|
||||
});
|
||||
test('accepts forum-length topics and shortens them to the channel topic limit', async () => {
|
||||
test('accepts forum-length topics and shortens them only for non-forum channels', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const longTopic = `${'a'.repeat(CHANNEL_TOPIC_MAX_LENGTH - 1)}\u{1F600}${'b'.repeat(300)}`;
|
||||
const guild = await createBuilder<GuildResponse>(harness, account.token)
|
||||
@@ -288,13 +315,224 @@ describe('Guild Template Import', () => {
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const channels = await listThreadAwareChannels(account.token, guild.id);
|
||||
const general = channels.find((channel) => channel.name === 'general');
|
||||
expect(general?.topic).toBe('a'.repeat(CHANNEL_TOPIC_MAX_LENGTH - 1));
|
||||
expect(channels.find((channel) => channel.name === 'projects')).toBeUndefined();
|
||||
expect(channels.find((channel) => channel.name === 'projects')?.topic).toBe('c'.repeat(1356));
|
||||
});
|
||||
test('imports forum settings and drops values that cannot carry over', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createBuilder<GuildResponse>(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Forum Settings Guild',
|
||||
template: buildMinimalTemplate({
|
||||
channels: [
|
||||
{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0},
|
||||
{
|
||||
id: 6002,
|
||||
type: ChannelTypes.GUILD_FORUM,
|
||||
name: 'support',
|
||||
position: 1,
|
||||
topic: 'Be kind',
|
||||
available_tags: [
|
||||
{id: 111, name: 'bug', moderated: false, emoji_id: null, emoji_name: '\u{1F41B}'},
|
||||
{id: 112, name: 'custom', moderated: false, emoji_id: '123456789', emoji_name: null},
|
||||
{id: 113, name: 'bug', moderated: true, emoji_id: null, emoji_name: null},
|
||||
{id: 114, name: 'staff', moderated: true, emoji_id: null, emoji_name: 'not-an-emoji'},
|
||||
],
|
||||
default_reaction_emoji: {emoji_id: '987654321', emoji_name: null},
|
||||
default_sort_order: ForumSortOrderTypes.CREATION_TIME,
|
||||
default_forum_layout: ForumLayoutTypes.GRID,
|
||||
default_tag_setting: 'match_all',
|
||||
default_thread_rate_limit_per_user: 60,
|
||||
default_auto_archive_duration: 1440,
|
||||
flags: ChannelFlags.REQUIRE_TAG | ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS,
|
||||
},
|
||||
{
|
||||
id: 6003,
|
||||
type: ChannelTypes.GUILD_MEDIA,
|
||||
name: 'gallery',
|
||||
position: 2,
|
||||
available_tags: Array.from({length: 25}, (_, index) => ({name: `tag-${index}`})),
|
||||
default_reaction_emoji: {emoji_id: null, emoji_name: '\u{1F525}'},
|
||||
default_forum_layout: ForumLayoutTypes.LIST,
|
||||
default_sort_order: 7,
|
||||
flags: ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS,
|
||||
},
|
||||
{
|
||||
id: 6004,
|
||||
type: ChannelTypes.GUILD_FORUM,
|
||||
name: 'announcements-forum',
|
||||
position: 3,
|
||||
available_tags: [{name: 'staff', moderated: true}],
|
||||
flags: ChannelFlags.REQUIRE_TAG,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
const channels = await listThreadAwareChannels(account.token, guild.id);
|
||||
const forum = await getThreadAwareChannel(account.token, findChannelId(channels, 'support'));
|
||||
expect(forum.type).toBe(ChannelTypes.GUILD_FORUM);
|
||||
expect(forum.topic).toBe('Be kind');
|
||||
expect(
|
||||
forum.available_tags?.map(({name, moderated, emoji_id, emoji_name}) => ({name, moderated, emoji_id, emoji_name})),
|
||||
).toEqual([
|
||||
{name: 'bug', moderated: false, emoji_id: null, emoji_name: '\u{1F41B}'},
|
||||
{name: 'custom', moderated: false, emoji_id: null, emoji_name: null},
|
||||
{name: 'staff', moderated: true, emoji_id: null, emoji_name: null},
|
||||
]);
|
||||
expect(new Set(forum.available_tags?.map((tag) => tag.id)).size).toBe(3);
|
||||
expect(forum.available_tags?.some((tag) => ['111', '112', '113', '114'].includes(tag.id))).toBe(false);
|
||||
expect(forum.default_reaction_emoji).toBeNull();
|
||||
expect(forum.default_sort_order).toBe(ForumSortOrderTypes.CREATION_TIME);
|
||||
expect(forum.default_forum_layout).toBe(ForumLayoutTypes.GRID);
|
||||
expect(forum.default_tag_setting).toBe('match_all');
|
||||
expect(forum.default_thread_rate_limit_per_user).toBe(60);
|
||||
expect(forum.default_auto_archive_duration).toBe(1440);
|
||||
expect(forum.flags).toBe(ChannelFlags.REQUIRE_TAG);
|
||||
const media = await getThreadAwareChannel(account.token, findChannelId(channels, 'gallery'));
|
||||
expect(media.type).toBe(ChannelTypes.GUILD_MEDIA);
|
||||
expect(media.available_tags).toHaveLength(20);
|
||||
expect(media.default_reaction_emoji).toEqual({emoji_id: null, emoji_name: '\u{1F525}'});
|
||||
expect(media.default_forum_layout).toBeUndefined();
|
||||
expect(media.default_sort_order).toBeNull();
|
||||
expect(media.flags).toBe(ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS);
|
||||
const moderatedOnly = await getThreadAwareChannel(account.token, findChannelId(channels, 'announcements-forum'));
|
||||
expect(moderatedOnly.flags).toBe(0);
|
||||
});
|
||||
test('indexes imported forum and media channels as thread-only channels', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createBuilder<GuildResponse>(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Index Guild',
|
||||
template: buildMinimalTemplate({
|
||||
channels: [
|
||||
{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0},
|
||||
{id: 6002, type: ChannelTypes.GUILD_FORUM, name: 'forum', position: 1},
|
||||
{id: 6003, type: ChannelTypes.GUILD_MEDIA, name: 'media', position: 2},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
const channels = await listThreadAwareChannels(account.token, guild.id);
|
||||
const threadOnlyIds = [findChannelId(channels, 'forum'), findChannelId(channels, 'media')];
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
const threadOnlyRows = await fetchMany<{channel_id: bigint}>(
|
||||
ThreadOnlyChannelsByGuild.selectCql({where: ThreadOnlyChannelsByGuild.where.eq('guild_id')}),
|
||||
{guild_id: guildId},
|
||||
);
|
||||
const regularRows = await fetchMany<{channel_id: bigint}>(
|
||||
ChannelsByGuild.selectCql({where: ChannelsByGuild.where.eq('guild_id')}),
|
||||
{guild_id: guildId},
|
||||
);
|
||||
expect(threadOnlyRows.map((row) => row.channel_id.toString()).sort()).toEqual([...threadOnlyIds].sort());
|
||||
expect(regularRows.some((row) => threadOnlyIds.includes(row.channel_id.toString()))).toBe(false);
|
||||
expect(regularRows.some((row) => row.channel_id.toString() === findChannelId(channels, 'general'))).toBe(true);
|
||||
});
|
||||
test('accepts channels shaped like a full template export', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const exported = {
|
||||
bitrate: 64000,
|
||||
user_limit: 0,
|
||||
nsfw: false,
|
||||
rate_limit_per_user: 0,
|
||||
topic: null,
|
||||
default_auto_archive_duration: null,
|
||||
permission_overwrites: [{id: 0, type: 0, allow: '0', deny: Permissions.SEND_MESSAGES.toString()}],
|
||||
available_tags: [],
|
||||
template: '',
|
||||
default_reaction_emoji: null,
|
||||
default_thread_rate_limit_per_user: 0,
|
||||
default_sort_order: null,
|
||||
default_forum_layout: 0,
|
||||
default_tag_setting: null,
|
||||
icon_emoji: null,
|
||||
theme_color: null,
|
||||
};
|
||||
const guild = await createBuilder<GuildResponse>(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Exported Guild',
|
||||
template: {
|
||||
...buildMinimalTemplate({
|
||||
channels: [
|
||||
{...exported, id: 1, type: ChannelTypes.GUILD_CATEGORY, name: 'Info', position: 0, parent_id: null},
|
||||
{...exported, id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'rules', position: 0, parent_id: 1},
|
||||
{...exported, id: 3, type: ChannelTypes.GUILD_CATEGORY, name: 'Nested', position: 1, parent_id: 1},
|
||||
{
|
||||
...exported,
|
||||
id: 4,
|
||||
type: ChannelTypes.GUILD_FORUM,
|
||||
name: 'ideas',
|
||||
position: 1,
|
||||
parent_id: 1,
|
||||
nsfw: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
afk_channel_id: null,
|
||||
preferred_locale: 'en-US',
|
||||
region: 'us-west',
|
||||
},
|
||||
})
|
||||
.execute();
|
||||
const channels = await listThreadAwareChannels(account.token, guild.id);
|
||||
const category = channels.find((channel) => channel.name === 'Info');
|
||||
const rules = channels.find((channel) => channel.name === 'rules');
|
||||
expect(rules?.parent_id).toBe(category?.id ?? null);
|
||||
expect(rules?.permission_overwrites?.find((overwrite) => overwrite.id === guild.id)?.deny).toBe(
|
||||
Permissions.SEND_MESSAGES.toString(),
|
||||
);
|
||||
expect(channels.find((channel) => channel.name === 'Nested')?.parent_id ?? null).toBeNull();
|
||||
expect(channels.find((channel) => channel.name === 'ideas')?.parent_id).toBe(category?.id ?? null);
|
||||
expect(guild.system_channel_id).toBe(rules?.id ?? null);
|
||||
});
|
||||
test('hides imported forum channels from viewers outside the threads experiment', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createBuilder<GuildResponse>(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Hidden Forum Guild',
|
||||
template: buildMinimalTemplate({
|
||||
channels: [
|
||||
{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0},
|
||||
{id: 6002, type: ChannelTypes.GUILD_FORUM, name: 'forum', position: 1},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
const legacy = await getGuildChannels(harness, account.token, guild.id);
|
||||
expect(legacy.some((channel) => channel.name === 'forum')).toBe(false);
|
||||
expect(legacy.some((channel) => channel.name === 'general')).toBe(true);
|
||||
});
|
||||
|
||||
function listThreadAwareChannels(token: string, guildId: string): Promise<Array<ChannelResponse>> {
|
||||
return createBuilder<Array<ChannelResponse>>(harness, token)
|
||||
.get(`/guilds/${guildId}/channels`)
|
||||
.header(THREAD_FEATURES_HEADER, THREAD_FEATURES_CAPABLE)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function getThreadAwareChannel(token: string, channelId: string): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token)
|
||||
.get(`/channels/${channelId}`)
|
||||
.header(THREAD_FEATURES_HEADER, THREAD_FEATURES_CAPABLE)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
const THREAD_FEATURES_HEADER = 'X-Fluxer-Features';
|
||||
const THREAD_FEATURES_CAPABLE = 'channel_threads';
|
||||
|
||||
function findChannelId(channels: Array<ChannelResponse>, name: string): string {
|
||||
const channel = channels.find((candidate) => candidate.name === name);
|
||||
if (!channel) throw new Error(`Missing channel ${name}`);
|
||||
return channel.id;
|
||||
}
|
||||
|
||||
const DEFAULT_EVERYONE_PERMISSIONS = Permissions.VIEW_CHANNEL.toString();
|
||||
|
||||
function buildMinimalTemplate(overrides: {channels?: Array<object>; roles?: Array<object>}) {
|
||||
|
||||
Generated
+121
-27
@@ -36430,13 +36430,16 @@
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}],
|
||||
"description": "The template-local channel ID"
|
||||
},
|
||||
"type": {"type": "number", "description": "The channel type (0 = text, 2 = voice, 4 = category)"},
|
||||
"type": {
|
||||
"type": "number",
|
||||
"description": "The channel type (0 = text, 2 = voice, 4 = category, 5 = announcement, 13 = stage, 15 = forum, 16 = media)"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name of the channel",
|
||||
"anyOf": [{"type": "string", "maxLength": 100}, {"type": "null"}]
|
||||
},
|
||||
"topic": {
|
||||
"description": "The channel topic, shortened to the Fluxer channel topic limit",
|
||||
"description": "The channel topic or forum guidelines, shortened to the limit for the channel type",
|
||||
"anyOf": [{"type": "string", "maxLength": 4096}, {"type": "null"}]
|
||||
},
|
||||
"position": {"description": "The position of the channel", "$ref": "#/components/schemas/Int32Type"},
|
||||
@@ -36462,38 +36465,129 @@
|
||||
"description": "The per-user voice connection limit for voice channels",
|
||||
"anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}]
|
||||
},
|
||||
"nsfw": {"description": "Whether the channel is NSFW", "type": "boolean"},
|
||||
"nsfw": {"description": "Whether the channel is NSFW", "type": ["boolean", "null"]},
|
||||
"rate_limit_per_user": {
|
||||
"description": "Slowmode rate limit in seconds",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 21600
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 21600}, {"type": "null"}]
|
||||
},
|
||||
"permission_overwrites": {
|
||||
"description": "Permission overwrites for this channel",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}],
|
||||
"description": "The ID of the role or user for this overwrite"
|
||||
},
|
||||
"type": {
|
||||
"description": "The type of overwrite (0/role = role, 1/member = member)",
|
||||
"type": ["number", "string"]
|
||||
},
|
||||
"allow": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}],
|
||||
"description": "The allowed permissions bitfield as a string"
|
||||
},
|
||||
"deny": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}],
|
||||
"description": "The denied permissions bitfield as a string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}],
|
||||
"description": "The ID of the role or user for this overwrite"
|
||||
},
|
||||
"type": {
|
||||
"description": "The type of overwrite (0/role = role, 1/member = member)",
|
||||
"type": ["number", "string"]
|
||||
},
|
||||
"allow": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}],
|
||||
"description": "The allowed permissions bitfield as a string"
|
||||
},
|
||||
"deny": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}],
|
||||
"description": "The denied permissions bitfield as a string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "allow", "deny"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "allow", "deny"]
|
||||
}
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"available_tags": {
|
||||
"description": "Tags for forum and media channels",
|
||||
"anyOf": [
|
||||
{
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "maxLength": 100, "description": "The name of the tag"},
|
||||
"moderated": {
|
||||
"description": "Whether only moderators can apply the tag",
|
||||
"type": ["boolean", "null"]
|
||||
},
|
||||
"emoji_id": {
|
||||
"description": "A custom emoji reference, ignored on import",
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{"type": "string", "maxLength": 64},
|
||||
{"type": "integer", "minimum": 0, "maximum": 9007199254740991}
|
||||
]
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"emoji_name": {
|
||||
"description": "The unicode emoji of the tag",
|
||||
"anyOf": [{"type": "string", "maxLength": 64}, {"type": "null"}]
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"default_reaction_emoji": {
|
||||
"description": "Default reaction for forum and media posts",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"emoji_id": {
|
||||
"description": "A custom emoji reference, ignored on import",
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{"type": "string", "maxLength": 64},
|
||||
{"type": "integer", "minimum": 0, "maximum": 9007199254740991}
|
||||
]
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"emoji_name": {
|
||||
"description": "The unicode emoji",
|
||||
"anyOf": [{"type": "string", "maxLength": 64}, {"type": "null"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"default_sort_order": {
|
||||
"description": "Default post sort order for forum and media channels",
|
||||
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
|
||||
},
|
||||
"default_forum_layout": {
|
||||
"description": "Default layout for forum channels",
|
||||
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
|
||||
},
|
||||
"default_tag_setting": {
|
||||
"description": "How posts are filtered by several tags",
|
||||
"anyOf": [{"type": "string", "maxLength": 32}, {"type": "null"}]
|
||||
},
|
||||
"default_thread_rate_limit_per_user": {
|
||||
"description": "Slowmode in seconds copied onto new threads",
|
||||
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
|
||||
},
|
||||
"default_auto_archive_duration": {
|
||||
"description": "Default auto archive duration in minutes for new threads",
|
||||
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
|
||||
},
|
||||
"flags": {
|
||||
"description": "Channel flags",
|
||||
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "position"]
|
||||
|
||||
Reference in new issue
Block a user