mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat: add announcement channels, publishing and following (#3090)
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user