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

This commit is contained in:
Hampus
2026-10-01 16:57:21 +02:00
committed by GitHub
parent 376afd2ad6
commit 7e4d5137f8
381 changed files with 45650 additions and 14076 deletions
@@ -51,6 +51,7 @@ message SyncedPreferences {
optional bool save_camera_uploads_to_device = 113;
ReactionEmoji double_tap_reaction = 114;
ChannelFrecencyState channel_frecency = 115;
AnnouncementPromptsState announcement_prompts = 116;
}
@@ -222,6 +223,10 @@ message VoicePromptsState {
bool skip_hide_own_screenshare_confirm = 2;
}
message AnnouncementPromptsState {
bool hide_publish_nudge = 1;
}
message SudoPromptState {
optional MfaMethod last_used_mfa_method = 1;
}
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Int32Type, SnowflakeStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const ChannelFollowRequest = z.object({
webhook_channel_id: SnowflakeType.describe('The ID of the text channel that receives published messages'),
});
export type ChannelFollowRequest = z.infer<typeof ChannelFollowRequest>;
export const FollowedChannelResponse = z.object({
channel_id: SnowflakeStringType.describe('The ID of the followed announcement channel'),
webhook_id: SnowflakeStringType.describe('The ID of the channel follower webhook created in the target channel'),
});
export type FollowedChannelResponse = z.infer<typeof FollowedChannelResponse>;
export const ChannelFollowerStatsResponse = z.object({
channel_count: Int32Type.describe('The number of channels following this announcement channel'),
guild_count: Int32Type.describe('The number of distinct guilds following this announcement channel'),
});
export type ChannelFollowerStatsResponse = z.infer<typeof ChannelFollowerStatsResponse>;
@@ -137,6 +137,15 @@ const ChannelCreateTextRequest = ChannelCreateCommon.extend({
name: GeneralChannelNameType.describe('The name of the channel'),
});
const ChannelCreateAnnouncementRequest = ChannelCreateCommon.extend({
type: createNamedLiteral(
ChannelTypes.GUILD_ANNOUNCEMENT,
'GUILD_ANNOUNCEMENT',
'Channel type (announcement channel)',
),
name: GeneralChannelNameType.describe('The name of the channel'),
});
const ChannelCreateVoiceRequest = ChannelCreateCommon.extend({
type: createNamedLiteral(ChannelTypes.GUILD_VOICE, 'GUILD_VOICE', 'Channel type (voice channel)'),
name: GeneralChannelNameType.describe('The name of the channel'),
@@ -154,6 +163,7 @@ const ChannelCreateLinkRequest = ChannelCreateCommon.extend({
export const ChannelCreateRequest = z.discriminatedUnion('type', [
ChannelCreateTextRequest,
ChannelCreateAnnouncementRequest,
ChannelCreateVoiceRequest,
ChannelCreateCategoryRequest,
ChannelCreateLinkRequest,
@@ -166,6 +176,15 @@ const ChannelUpdateTextRequest = ChannelUpdateCommon.extend({
name: GeneralChannelNameType.nullish().describe('The name of the channel'),
});
const ChannelUpdateAnnouncementRequest = ChannelUpdateCommon.extend({
type: createNamedLiteral(
ChannelTypes.GUILD_ANNOUNCEMENT,
'GUILD_ANNOUNCEMENT',
'Channel type (announcement channel)',
),
name: GeneralChannelNameType.nullish().describe('The name of the channel'),
});
const ChannelUpdateVoiceRequest = ChannelUpdateCommon.extend({
type: createNamedLiteral(ChannelTypes.GUILD_VOICE, 'GUILD_VOICE', 'Channel type (voice channel)'),
name: GeneralChannelNameType.nullish().describe('The name of the channel'),
@@ -193,6 +212,7 @@ const ChannelUpdateGroupDmRequest = z.object({
export const ChannelUpdateRequest = z.discriminatedUnion('type', [
ChannelUpdateTextRequest,
ChannelUpdateAnnouncementRequest,
ChannelUpdateVoiceRequest,
ChannelUpdateCategoryRequest,
ChannelUpdateLinkRequest,
@@ -201,8 +221,20 @@ export const ChannelUpdateRequest = z.discriminatedUnion('type', [
export type ChannelUpdateRequest = z.infer<typeof ChannelUpdateRequest>;
const ChannelTypeConversionField = z
.union([z.literal(ChannelTypes.GUILD_TEXT), z.literal(ChannelTypes.GUILD_ANNOUNCEMENT)])
.optional()
.describe('Convert between text (0) and announcement (5). Other conversions are rejected.');
const CONVERTIBLE_UPDATE_REQUESTS = new Set<z.ZodObject>([ChannelUpdateTextRequest, ChannelUpdateAnnouncementRequest]);
export const ChannelUpdateRequestBody = z.union(
ChannelUpdateRequest.options.map(({shape: {type, ...shape}}) => z.object(shape)),
ChannelUpdateRequest.options.map((option) => {
const {type, ...shape} = option.shape;
return CONVERTIBLE_UPDATE_REQUESTS.has(option)
? z.object({...shape, type: ChannelTypeConversionField})
: z.object(shape);
}),
);
export const PermissionOverwriteCreateRequest = z.object({
@@ -48,7 +48,12 @@ export function compareChannelOrdering<Id extends string | bigint>(
}
function channelLayoutRank(channel: {type: number}): number {
if (channel.type === ChannelTypes.GUILD_TEXT || channel.type === ChannelTypes.GUILD_LINK) return 0;
if (
channel.type === ChannelTypes.GUILD_TEXT ||
channel.type === ChannelTypes.GUILD_ANNOUNCEMENT ||
channel.type === ChannelTypes.GUILD_LINK
)
return 0;
if (channel.type === ChannelTypes.GUILD_VOICE) return 1;
return 2;
}
@@ -108,6 +108,11 @@ export const GuildFeatureSchema = withOpenApiType(
[GuildFeatures.VISIONARY, 'VISIONARY', 'Guild is a visionary guild'],
[GuildFeatures.LARGE_GUILD_OVERRIDE, 'LARGE_GUILD_OVERRIDE', 'Guild has large guild overrides enabled'],
[GuildFeatures.VERY_LARGE_GUILD, 'VERY_LARGE_GUILD', 'Guild has increased member capacity enabled'],
[
GuildFeatures.ANNOUNCEMENT_CHANNELS_DISABLED,
'ANNOUNCEMENT_CHANNELS_DISABLED',
'Guild cannot publish announcement messages or gain new followers',
],
],
'A guild feature flag',
),
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {GuildFeatureSchema} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const CrosspostSourceGuildResponse = z.object({
id: SnowflakeStringType.describe('The ID of the source community'),
name: z.string().describe('The name of the source community'),
icon: z.string().nullish().describe('The hash of the source community icon'),
banner: z.string().nullish().describe('The hash of the source community banner'),
description: z
.string()
.nullable()
.describe('The discovery description of the source community, or null when it is not listed in discovery'),
features: z
.array(GuildFeatureSchema)
.describe('The public badge features of the source community: VERIFIED, PARTNERED and DISCOVERABLE only'),
approximate_member_count: Int32Type.nullable().describe(
'Approximate number of members in the source community, or null when unavailable',
),
approximate_presence_count: Int32Type.nullable().describe(
'Approximate number of online members in the source community, or null when unavailable',
),
discoverable: z.boolean().describe('Whether the source community can be joined through discovery'),
});
export type CrosspostSourceGuildResponse = z.infer<typeof CrosspostSourceGuildResponse>;
export const CrosspostSourceResponse = z.object({
guild: CrosspostSourceGuildResponse.describe('The community the message was published from'),
});
export type CrosspostSourceResponse = z.infer<typeof CrosspostSourceResponse>;
@@ -49,7 +49,9 @@ export type MessageAttachmentResponse = z.infer<typeof MessageAttachmentResponse
const MessageReferenceResponse = z.object({
channel_id: SnowflakeStringType.describe('The ID of the channel containing the referenced message'),
message_id: SnowflakeStringType.describe('The ID of the referenced message'),
message_id: SnowflakeStringType.nullish().describe(
'The ID of the referenced message, absent on a channel follow system message',
),
guild_id: SnowflakeStringType.nullish().describe('The ID of the guild containing the referenced message'),
type: MessageReferenceTypeSchema,
});
@@ -235,7 +237,7 @@ export const BulkMessageFetchResponse = z.object({
export type BulkMessageFetchResponse = z.infer<typeof BulkMessageFetchResponse>;
export interface MessageReference {
readonly message_id: string;
readonly message_id?: string;
readonly channel_id: string;
readonly guild_id?: string;
readonly type?: number;
@@ -0,0 +1,132 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelTypes, MessageFlags, MessageTypes, WebhookTypes} from '@fluxer/constants/src/ChannelConstants';
import {
ChannelFollowerStatsResponse,
ChannelFollowRequest,
FollowedChannelResponse,
} from '@fluxer/schema/src/domains/channel/ChannelFollowSchemas';
import {ChannelCreateRequest, ChannelUpdateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
import {MessageResponseSchema} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {
WebhookCreateResponse,
WebhookResponse,
WebhookTokenResponse,
} from '@fluxer/schema/src/domains/webhook/WebhookSchemas';
import {ChannelTypeSchema} from '@fluxer/schema/src/primitives/ChannelValidators';
import {MessageTypeSchema} from '@fluxer/schema/src/primitives/MessageValidators';
import {describe, expect, it} from 'vitest';
const creator = {
id: '100',
username: 'owner',
discriminator: '0001',
global_name: null,
avatar: null,
avatar_color: null,
flags: 0,
mention_flags: 0,
};
const incomingWebhook = {
id: '1',
guild_id: '2',
channel_id: '3',
name: 'Hook',
avatar: null,
type: WebhookTypes.INCOMING,
token: 'secret',
};
describe('announcement channel type', () => {
it('accepts type 5 as a channel type', () => {
expect(ChannelTypeSchema.parse(ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(5);
});
it('creates an announcement channel with the text channel fields', () => {
const parsed = ChannelCreateRequest.parse({
type: ChannelTypes.GUILD_ANNOUNCEMENT,
name: 'news',
topic: 'Updates',
rate_limit_per_user: 10,
});
expect(parsed).toMatchObject({type: 5, name: 'news', topic: 'Updates', rate_limit_per_user: 10, nsfw: false});
});
it('updates an announcement channel through its own variant', () => {
expect(ChannelUpdateRequest.parse({type: ChannelTypes.GUILD_ANNOUNCEMENT, topic: 'New'})).toMatchObject({
type: 5,
topic: 'New',
});
});
});
describe('channel follow add message type', () => {
it('accepts type 12 as a message type', () => {
expect(MessageTypeSchema.parse(MessageTypes.CHANNEL_FOLLOW_ADD)).toBe(12);
});
});
describe('channel follow schemas', () => {
it('parses the target channel of a follow request', () => {
expect(ChannelFollowRequest.parse({webhook_channel_id: '123'})).toEqual({webhook_channel_id: 123n});
expect(ChannelFollowRequest.safeParse({webhook_channel_id: 'abc'}).success).toBe(false);
expect(ChannelFollowRequest.safeParse({}).success).toBe(false);
});
it('describes the followed channel and its webhook', () => {
expect(FollowedChannelResponse.parse({channel_id: '1', webhook_id: '2'})).toEqual({
channel_id: '1',
webhook_id: '2',
});
});
it('reports follower counts as integers', () => {
expect(ChannelFollowerStatsResponse.parse({channel_count: 3, guild_count: 2})).toEqual({
channel_count: 3,
guild_count: 2,
});
expect(ChannelFollowerStatsResponse.safeParse({channel_count: 1.5, guild_count: 1}).success).toBe(false);
});
});
describe('webhook responses', () => {
it('keeps the token required on token responses', () => {
expect(WebhookTokenResponse.parse(incomingWebhook)).toEqual(incomingWebhook);
const {token: _token, ...withoutToken} = incomingWebhook;
expect(WebhookTokenResponse.safeParse(withoutToken).success).toBe(false);
expect(WebhookCreateResponse.safeParse({...withoutToken, user: creator}).success).toBe(false);
});
it('omits the token and carries the source on follower webhooks', () => {
const {token: _token, ...withoutToken} = incomingWebhook;
const follower = {
...withoutToken,
type: WebhookTypes.CHANNEL_FOLLOWER,
user: creator,
source_guild: {id: '7', name: 'Source', icon: null},
source_channel: {id: '8', name: 'news'},
};
const parsed = WebhookResponse.parse(follower);
expect(parsed).not.toHaveProperty('token');
expect(parsed.source_guild).toEqual({id: '7', name: 'Source', icon: null});
expect(parsed.source_channel).toEqual({id: '8', name: 'news'});
});
it('rejects unknown webhook types', () => {
expect(WebhookResponse.safeParse({...incomingWebhook, type: 3, user: creator}).success).toBe(false);
});
});
describe('message responses', () => {
it('allows a message reference without a message id', () => {
const shape = MessageResponseSchema.shape.message_reference;
const reference = {type: 0, guild_id: '1', channel_id: '2'};
expect(shape.parse(reference)).toEqual(reference);
});
it('accepts the crosspost flags', () => {
const flags = MessageFlags.CROSSPOSTED | MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED;
expect(MessageResponseSchema.shape.flags.parse(flags)).toBe(flags);
});
});
@@ -6,6 +6,7 @@ import {
computeGuildChannelReorderPlan,
computePositionFromPrecedingSiblingId,
computePrecedingSiblingIdFromPosition,
sortChannelsForOrdering,
} from '@fluxer/schema/src/domains/channel/GuildChannelOrdering';
import {describe, expect, it} from 'vitest';
@@ -125,4 +126,18 @@ describe('GuildChannelOrdering', () => {
if (result.ok) return;
expect(result.code).toBe('PRECEDING_PARENT_MISMATCH');
});
it('ranks announcement channels with text channels ahead of voice channels', () => {
const channels: Array<Ch> = [
{id: 'cat', parentId: null, type: ChannelTypes.GUILD_CATEGORY, position: 0},
{id: 'voice', parentId: 'cat', type: ChannelTypes.GUILD_VOICE, position: 1},
{id: 'announcements', parentId: 'cat', type: ChannelTypes.GUILD_ANNOUNCEMENT, position: 3},
{id: 'text', parentId: 'cat', type: ChannelTypes.GUILD_TEXT, position: 2},
];
expect(sortChannelsForOrdering<string, Ch>(channels).map((c) => c.id)).toEqual([
'cat',
'text',
'announcements',
'voice',
]);
});
});
@@ -103,6 +103,7 @@ describe('channel update request body', () => {
it.each([
ChannelTypes.GUILD_TEXT,
ChannelTypes.GUILD_ANNOUNCEMENT,
ChannelTypes.GUILD_VOICE,
ChannelTypes.GUILD_CATEGORY,
ChannelTypes.GUILD_LINK,
@@ -119,7 +120,11 @@ describe('channel update request body', () => {
expect(ChannelUpdateRequestBody.parse(input)).toEqual({...input, topic: 'Topic', parent_id: 123n});
});
it('does not accept the injected discriminator as a body property', () => {
it('keeps only text and announcement conversions as a body type', () => {
expect(ChannelUpdateRequestBody.parse({type: ChannelTypes.GUILD_TEXT})).toEqual({type: ChannelTypes.GUILD_TEXT});
expect(ChannelUpdateRequestBody.parse({type: ChannelTypes.GUILD_ANNOUNCEMENT})).toEqual({
type: ChannelTypes.GUILD_ANNOUNCEMENT,
});
expect(ChannelUpdateRequestBody.parse({type: ChannelTypes.GUILD_VOICE})).toEqual({});
});
});
@@ -2,26 +2,69 @@
import {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {WebhookTypeSchema} from '@fluxer/schema/src/primitives/WebhookValidators';
import {z} from 'zod';
const WebhookBaseResponse = {
const WebhookCommonResponse = {
id: SnowflakeStringType.describe('The unique identifier (snowflake) for the webhook'),
guild_id: SnowflakeStringType.describe('The ID of the guild this webhook belongs to'),
channel_id: SnowflakeStringType.describe('The ID of the channel this webhook posts to'),
name: z.string().describe('The display name of the webhook'),
avatar: z.string().nullish().describe('The hash of the webhook avatar image'),
token: z.string().describe('The secure token used to execute the webhook'),
type: WebhookTypeSchema,
};
export const WebhookTokenResponse = z.object(WebhookBaseResponse);
export const WebhookTokenResponse = z.object({
...WebhookCommonResponse,
token: z.string().describe('The secure token used to execute the webhook'),
});
export type WebhookTokenResponse = z.infer<typeof WebhookTokenResponse>;
export const WebhookResponse = WebhookTokenResponse.extend({
export const WebhookCreateResponse = WebhookTokenResponse.extend({
user: z.lazy(() => UserPartialResponse).describe('The user who created the webhook'),
});
export type WebhookCreateResponse = z.infer<typeof WebhookCreateResponse>;
const WebhookSourceGuildResponse = z.object({
id: SnowflakeStringType.describe('The ID of the guild that owns the followed announcement channel'),
name: z.string().describe('The name of the guild that owns the followed announcement channel'),
icon: z.string().nullish().describe('The icon hash of the guild that owns the followed announcement channel'),
});
const WebhookSourceChannelResponse = z.object({
id: SnowflakeStringType.describe('The ID of the followed announcement channel'),
name: z.string().describe('The name of the followed announcement channel'),
});
export const WebhookResponse = z.object({
...WebhookCommonResponse,
token: z
.string()
.optional()
.describe('The secure token used to execute the webhook, omitted for channel follower webhooks'),
user: z.lazy(() => UserPartialResponse).describe('The user who created the webhook'),
source_guild: WebhookSourceGuildResponse.optional().describe(
'The guild of the followed announcement channel, present on channel follower webhooks while the creator can view it',
),
source_channel: WebhookSourceChannelResponse.optional().describe(
'The followed announcement channel, present on channel follower webhooks while the creator can view it',
),
});
export type WebhookResponse = z.infer<typeof WebhookResponse>;
export interface WebhookSourceGuild {
readonly id: string;
readonly name: string;
readonly icon?: string | null;
}
export interface WebhookSourceChannel {
readonly id: string;
readonly name: string;
}
export interface Webhook {
readonly id: string;
readonly guild_id: string;
@@ -29,7 +72,10 @@ export interface Webhook {
readonly user: UserPartialResponse;
readonly name: string;
readonly avatar: string | null;
readonly token: string;
readonly type: number;
readonly token?: string;
readonly source_guild?: WebhookSourceGuild;
readonly source_channel?: WebhookSourceChannel;
}
export const WebhookListResponse = z.array(WebhookResponse);
File diff suppressed because one or more lines are too long
@@ -25,6 +25,11 @@ export const ChannelTypeSchema = createInt32EnumType(
[ChannelTypes.GUILD_VOICE, 'GUILD_VOICE', 'A voice channel within a guild'],
[ChannelTypes.GROUP_DM, 'GROUP_DM', 'A group direct message between users'],
[ChannelTypes.GUILD_CATEGORY, 'GUILD_CATEGORY', 'A category that contains channels'],
[
ChannelTypes.GUILD_ANNOUNCEMENT,
'GUILD_ANNOUNCEMENT',
'A guild channel whose messages can be published to channels that follow it',
],
[ChannelTypes.GUILD_LINK, 'GUILD_LINK', 'A link channel for external resources'],
[ChannelTypes.DM_PERSONAL_NOTES, 'DM_PERSONAL_NOTES', 'Personal notes DM channel'],
],
@@ -23,6 +23,11 @@ export const MessageTypeSchema = createInt32EnumType(
[MessageTypes.CHANNEL_ICON_CHANGE, 'CHANNEL_ICON_CHANGE', 'A system message indicating the channel icon changed'],
[MessageTypes.CHANNEL_PINNED_MESSAGE, 'CHANNEL_PINNED_MESSAGE', 'A system message indicating a message was pinned'],
[MessageTypes.USER_JOIN, 'USER_JOIN', 'A system message indicating a user joined'],
[
MessageTypes.CHANNEL_FOLLOW_ADD,
'CHANNEL_FOLLOW_ADD',
'System message posted when a channel starts following an announcement channel',
],
[MessageTypes.REPLY, 'REPLY', 'A reply message'],
],
'The type of message',