feat: add announcement channels, publishing and following (#3090)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-01 16:57:21 +02:00
1 parent 376afd2ad6
commit 7e4d5137f8
381 files changed
+45650 -14076

No files matched your search

@@ -14,6 +14,12 @@ export interface ChannelReorderOperation {
precedingSiblingId: ChannelID | null;
}
function isTextLikeChannelType(type: number): boolean {
return (
type === ChannelTypes.GUILD_TEXT || type === ChannelTypes.GUILD_ANNOUNCEMENT || type === ChannelTypes.GUILD_LINK
);
}
// biome-ignore lint/complexity/noStaticOnlyClass: Existing callers use this as a namespaced helper API.
export class ChannelHelpers {
static getNextGlobalChannelPosition(
@@ -32,9 +38,7 @@ export class ChannelHelpers {
return existingChannels.reduce((max, c) => Math.max(max, c.position || 0), 0) + 1;
}
const channelsInCategory = existingChannels.filter((c) => c.parentId === parentId);
const textChannels = channelsInCategory.filter(
(c) => c.type === ChannelTypes.GUILD_TEXT || c.type === ChannelTypes.GUILD_LINK,
);
const textChannels = channelsInCategory.filter((c) => isTextLikeChannelType(c.type));
const voiceChannels = channelsInCategory.filter((c) => c.type === ChannelTypes.GUILD_VOICE);
if (channelType === ChannelTypes.GUILD_VOICE) {
if (voiceChannels.length > 0) {
@@ -82,11 +86,10 @@ export class ChannelHelpers {
if (movedIndex < 0) continue;
const movedChannel = siblings[movedIndex];
const isMovedVoice = movedChannel.type === ChannelTypes.GUILD_VOICE;
const isMovedText =
movedChannel.type === ChannelTypes.GUILD_TEXT || movedChannel.type === ChannelTypes.GUILD_LINK;
const isMovedText = isTextLikeChannelType(movedChannel.type);
if (isMovedVoice) {
const next = siblings[movedIndex + 1];
if (next && (next.type === ChannelTypes.GUILD_TEXT || next.type === ChannelTypes.GUILD_LINK)) {
if (next && isTextLikeChannelType(next.type)) {
throw InputValidationError.fromCode(
'preceding_sibling_id',
ValidationErrorCodes.VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS,
@@ -108,7 +111,7 @@ export class ChannelHelpers {
encounteredVoice = true;
continue;
}
const isText = sibling.type === ChannelTypes.GUILD_TEXT || sibling.type === ChannelTypes.GUILD_LINK;
const isText = isTextLikeChannelType(sibling.type);
if (encounteredVoice && isText) {
throw InputValidationError.fromCode(
'preceding_sibling_id',
@@ -120,14 +120,18 @@ export class ChannelOperationsService {
}
let channelName = params.data.name;
let guildFeatures: Array<string> | null = null;
if (params.data.type === ChannelTypes.GUILD_TEXT || params.data.type === ChannelTypes.GUILD_VOICE) {
if (
params.data.type === ChannelTypes.GUILD_TEXT ||
params.data.type === ChannelTypes.GUILD_ANNOUNCEMENT ||
params.data.type === ChannelTypes.GUILD_VOICE
) {
const guildData = await this.gatewayService.getGuildData({
guildId: params.guildId,
userId: params.userId,
});
guildFeatures = guildData.features;
}
if (params.data.type === ChannelTypes.GUILD_TEXT) {
if (params.data.type === ChannelTypes.GUILD_TEXT || params.data.type === ChannelTypes.GUILD_ANNOUNCEMENT) {
const hasFlexibleNamesEnabled = (guildFeatures ?? []).includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
if (!hasFlexibleNamesEnabled) {
channelName = ChannelNameType.parse(channelName);
@@ -230,7 +234,10 @@ export class ChannelOperationsService {
let hasChanges = false;
const updatedChannels: Array<Channel> = [];
for (const channel of channels) {
if (channel.type !== ChannelTypes.GUILD_TEXT || channel.name == null) {
if (
(channel.type !== ChannelTypes.GUILD_TEXT && channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT) ||
channel.name == null
) {
updatedChannels.push(channel);
continue;
}
@@ -3,6 +3,10 @@
import type {ChannelID, GuildID, RoleID, UserID} from '@app/api/BrandedTypes';
import {createChannelID, createGuildID, createRoleID, guildIdToRoleId} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {
type ChannelFollowerRemovalCopyMode,
scheduleDeletedChannelFollowerRemoval,
} from '@app/api/channel/services/ChannelFollowers';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {getContentMessage} from '@app/api/content_i18n/ContentI18n';
import {BatchBuilder} from '@app/api/database/CassandraQueryExecution';
@@ -162,9 +166,12 @@ function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
const TEMPLATE_AFK_TIMEOUT_MIN_SECONDS = 60;
const TEMPLATE_AFK_TIMEOUT_MAX_SECONDS = 3600;
const THE_OTHER_PLATFORM_GUILD_ANNOUNCEMENT_CHANNEL_TYPE = 5;
const THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE = 13;
function isSystemChannelType(type: number): boolean {
return type === ChannelTypes.GUILD_TEXT || type === ChannelTypes.GUILD_ANNOUNCEMENT;
}
export class GuildOperationsService {
constructor(
private readonly guildRepository: IGuildRepositoryAggregate,
@@ -525,7 +532,7 @@ export class GuildOperationsService {
ValidationErrorCodes.SYSTEM_CHANNEL_MUST_BE_IN_GUILD,
);
}
if (systemChannel.type !== ChannelTypes.GUILD_TEXT) {
if (!isSystemChannelType(systemChannel.type)) {
throw InputValidationError.fromCode('system_channel_id', ValidationErrorCodes.SYSTEM_CHANNEL_MUST_BE_TEXT);
}
patch.system_channel_id = systemChannelId;
@@ -735,18 +742,22 @@ export class GuildOperationsService {
if (!guildData || guildData.owner_id !== user.id.toString()) {
throw new MissingPermissionsError();
}
await this.performGuildDeletion(guildId);
await this.performGuildDeletion(guildId, 'source_deleted');
}
async deleteGuildById(guildId: GuildID): Promise<void> {
await this.performGuildDeletion(guildId);
await this.performGuildDeletion(guildId, 'purge');
}
private async performGuildDeletion(guildId: GuildID): Promise<void> {
private async performGuildDeletion(guildId: GuildID, copyMode: ChannelFollowerRemovalCopyMode): Promise<void> {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) {
throw new UnknownGuildError();
}
const channels = await this.channelRepository.listGuildChannels(guildId);
for (const channel of channels) {
await scheduleDeletedChannelFollowerRemoval({channel, crossposts: this.channelRepository.crossposts, copyMode});
}
const members = await this.guildRepository.listMembers(guildId);
await this.gatewayService.dispatchGuild({
guildId,
@@ -776,12 +787,13 @@ export class GuildOperationsService {
await Promise.all(invites.map((invite) => this.inviteRepository.delete(invite.code)));
const webhooks = await this.webhookRepository.listByGuild(guildId);
await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id)));
const channels = await this.channelRepository.listGuildChannels(guildId);
for (const channel of channels) {
await this.channelService.attachments.purgeChannelAttachments(channel);
}
await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id)));
await Promise.all(
channels.map((channel) => deleteChannelMessageSearchDocuments(channel.id, {context: {source: 'guild_delete'}})),
);
await Promise.all(channels.map((channel) => this.channelService.attachments.purgeChannelAttachments(channel)));
const discoveryRow = await this.discoveryRepository.findByGuildId(guildId);
if (discoveryRow) {
await this.discoveryRepository.deleteByGuildId(guildId, discoveryRow.status, discoveryRow.applied_at);
@@ -1032,7 +1044,7 @@ export class GuildOperationsService {
permissionOverwrites = null;
}
}
if (fluxerType === ChannelTypes.GUILD_TEXT && !firstTextChannelId) {
if (isSystemChannelType(fluxerType) && !firstTextChannelId) {
firstTextChannelId = channelId;
}
batch.addPrepared(
@@ -1078,7 +1090,7 @@ export class GuildOperationsService {
if (template.system_channel_id != null) {
const templateSystemChannelKey = this.getTemplateEntityKey(template.system_channel_id);
const templateSystemChannelType = channelTypeMap.get(templateSystemChannelKey);
if (templateSystemChannelType === ChannelTypes.GUILD_TEXT) {
if (templateSystemChannelType !== undefined && isSystemChannelType(templateSystemChannelType)) {
systemChannelId = channelIdMap.get(templateSystemChannelKey) ?? null;
}
}
@@ -1151,14 +1163,12 @@ export class GuildOperationsService {
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_ANNOUNCEMENT_CHANNEL_TYPE) {
return ChannelTypes.GUILD_TEXT;
}
if (channelType === THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE) {
return ChannelTypes.GUILD_VOICE;
}
@@ -0,0 +1,148 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import {disableCrosspostWorker} from '@app/api/channel/tests/AnnouncementTestUtils';
import {
copiesOf,
type FanoutWorld,
followInto,
publishAndDrain,
readRow,
sendWithImage,
setupFanoutWorld,
} from '@app/api/channel/tests/CrosspostWorkerTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {MessageFlags} from '@fluxer/constants/src/ChannelConstants';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
function attachmentKeys(channelId: string, message: MessageResponse): Array<string> {
return (message.attachments ?? []).map(
(attachment) => `attachments/${channelId}/${attachment.id}/${attachment.filename}`,
);
}
describe('Guild deletion attachment purge', () => {
let harness: ApiTestHarness;
let world: FanoutWorld;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
harness.storageService.reset();
world = await setupFanoutWorld(harness);
});
afterEach(() => {
disableCrosspostWorker();
});
afterAll(async () => {
await harness?.shutdown();
});
function deletedKeys(): Array<string> {
return harness.storageService.getDeletedObjects().map((entry) => entry.key);
}
function deletedKeysForAttachments(message: MessageResponse): Array<string> {
const ids = (message.attachments ?? []).map((attachment) => `/${attachment.id}/`);
return deletedKeys().filter((key) => ids.some((id) => key.includes(id)));
}
async function deleteGuild(owner: TestAccount, guildId: string): Promise<void> {
await createBuilder(harness, owner.token)
.post(`/guilds/${guildId}/delete`)
.body({password: owner.password})
.expect(HTTP_STATUS.NO_CONTENT)
.executeWithResponse();
await world.worker.drain();
}
test('purges the attachments of every message in every channel', async () => {
const first = await sendWithImage(harness, world.b.owner.token, world.b.t1.id, {content: 'one'}, [
'a.png',
'b.gif',
]);
const second = await sendWithImage(harness, world.b.owner.token, world.b.t2.id, {content: 'two'});
const expected = [...attachmentKeys(world.b.t1.id, first), ...attachmentKeys(world.b.t2.id, second)];
expect(expected).toHaveLength(3);
for (const key of expected) {
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, key)).toBe(true);
}
await deleteGuild(world.b.owner, world.b.guild.id);
expect(deletedKeys()).toEqual(expect.arrayContaining(expected));
for (const key of expected) {
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, key)).toBe(false);
}
});
test('a follower guild deletion leaves the source objects of its copies in place', async () => {
await followInto(harness, world, world.b.t1.id);
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'release'});
await publishAndDrain(harness, world, source.id);
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
expect(copy?.attachments?.map((attachment) => attachment.id)).toEqual(
source.attachments?.map((attachment) => attachment.id),
);
const own = await sendWithImage(harness, world.b.owner.token, world.b.t1.id, {content: 'local'});
const sourceKeys = attachmentKeys(world.a.ann.id, source);
await deleteGuild(world.b.owner, world.b.guild.id);
expect(deletedKeys()).toEqual(expect.arrayContaining(attachmentKeys(world.b.t1.id, own)));
expect(deletedKeys().some((key) => key.startsWith(`attachments/${world.a.ann.id}/`))).toBe(false);
expect(deletedKeysForAttachments(copy!)).toEqual([]);
for (const key of sourceKeys) {
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, key)).toBe(true);
}
expect(await readRow(world.a.ann.id, source.id)).not.toBeNull();
});
test('an admin guild deletion purges every channel and leaves follower copy sources in place', async () => {
await followInto(harness, world, world.b.t1.id);
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'release'});
await publishAndDrain(harness, world, source.id);
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
expect(copy?.attachments?.map((attachment) => attachment.id)).toEqual(
source.attachments?.map((attachment) => attachment.id),
);
const first = await sendWithImage(harness, world.b.owner.token, world.b.t1.id, {content: 'one'});
const second = await sendWithImage(harness, world.b.owner.token, world.b.t2.id, {content: 'two'});
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'guild:lookup',
'guild:delete',
]);
await createBuilder(harness, admin.token)
.delete(`/admin/guilds/${world.b.guild.id}`)
.body(null)
.expect(HTTP_STATUS.OK)
.execute();
await world.worker.drain();
expect(deletedKeys()).toEqual(
expect.arrayContaining([...attachmentKeys(world.b.t1.id, first), ...attachmentKeys(world.b.t2.id, second)]),
);
expect(deletedKeys().some((key) => key.startsWith(`attachments/${world.a.ann.id}/`))).toBe(false);
expect(deletedKeysForAttachments(copy!)).toEqual([]);
for (const key of attachmentKeys(world.a.ann.id, source)) {
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, key)).toBe(true);
}
});
test('a source guild deletion purges its objects and marks follower copies source deleted', async () => {
await followInto(harness, world, world.b.t1.id);
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'release'});
await publishAndDrain(harness, world, source.id);
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
expect(copy).toBeDefined();
await deleteGuild(world.a.owner, world.a.guild.id);
expect(deletedKeys()).toEqual(expect.arrayContaining(attachmentKeys(world.a.ann.id, source)));
const marked = await readRow(world.b.t1.id, copy!.id);
expect(marked?.flags).toBe(MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED);
});
});
@@ -140,7 +140,7 @@ describe('Guild Template Import', () => {
},
{
id: '4102',
type: 5,
type: ChannelTypes.GUILD_ANNOUNCEMENT,
name: 'announcements',
parent_id: '4101',
position: 1,
@@ -170,7 +170,7 @@ describe('Guild Template Import', () => {
const mediaChannel = channels.find((channel) => channel.name === 'media-feed');
expect(categoryChannel).toBeDefined();
expect(announcementsChannel).toBeDefined();
expect(announcementsChannel?.type).toBe(ChannelTypes.GUILD_TEXT);
expect(announcementsChannel?.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
expect(announcementsChannel?.parent_id).toBe(categoryChannel?.id ?? null);
expect(stageChannel).toBeDefined();
expect(stageChannel?.type).toBe(ChannelTypes.GUILD_VOICE);