feat(api): add expression source guild routes (#2786)

This commit is contained in:
Hampus
2026-09-15 00:31:47 +02:00
committed by GitHub
parent b693d84d2b
commit 951e39da3d
67 changed files with 1598 additions and 923 deletions
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveExpressionSourceGuild} from '@app/api/guild/ExpressionSourceGuild';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {describe, expect, it, vi} from 'vitest';
function guild(features: Array<string>, icon: string | null = null): GuildResponse {
return {id: '20', name: 'Blob Club', icon, features} as unknown as GuildResponse;
}
describe('resolveExpressionSourceGuild', () => {
it('hides a community the gateway reports as unavailable without checking membership', async () => {
const isMember = vi.fn(() => Promise.resolve(true));
await expect(
resolveExpressionSourceGuild({loadGuild: () => Promise.reject(new UnknownGuildError()), isMember}),
).rejects.toBeInstanceOf(UnknownGuildError);
expect(isMember).not.toHaveBeenCalled();
});
it('propagates other gateway failures instead of reporting the community as unavailable', async () => {
const failure = new Error('gateway timeout');
const isMember = vi.fn(() => Promise.resolve(true));
await expect(resolveExpressionSourceGuild({loadGuild: () => Promise.reject(failure), isMember})).rejects.toBe(
failure,
);
expect(isMember).not.toHaveBeenCalled();
});
it('reveals a discoverable community without checking membership', async () => {
const isMember = vi.fn(() => Promise.resolve(false));
const community = await resolveExpressionSourceGuild({
loadGuild: () => Promise.resolve(guild([GuildFeatures.DISCOVERABLE])),
isMember,
});
expect(community).toEqual({id: '20', name: 'Blob Club', icon: null, features: [GuildFeatures.DISCOVERABLE]});
expect(isMember).not.toHaveBeenCalled();
});
it('reveals a private community to a member', async () => {
const community = await resolveExpressionSourceGuild({
loadGuild: () => Promise.resolve(guild([])),
isMember: () => Promise.resolve(true),
});
expect(community.id).toBe('20');
expect(community.features).toEqual([]);
});
it('hides a private community from a non-member', async () => {
await expect(
resolveExpressionSourceGuild({
loadGuild: () => Promise.resolve(guild([])),
isMember: () => Promise.resolve(false),
}),
).rejects.toBeInstanceOf(UnknownGuildError);
});
it('limits features to the badge flags', async () => {
const community = await resolveExpressionSourceGuild({
loadGuild: () =>
Promise.resolve(
guild([
GuildFeatures.DISCOVERABLE,
GuildFeatures.VERIFIED,
GuildFeatures.INVITES_DISABLED,
GuildFeatures.CLONE_EMOJI_ENABLED,
]),
),
isMember: () => Promise.resolve(false),
});
expect(community.features).toEqual(expect.arrayContaining([GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]));
expect(community.features).not.toContain(GuildFeatures.INVITES_DISABLED);
expect(community.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
});
it('strips the animated icon prefix unless the community is entitled to it', async () => {
const plain = await resolveExpressionSourceGuild({
loadGuild: () => Promise.resolve(guild([GuildFeatures.DISCOVERABLE], 'a_icon')),
isMember: () => Promise.resolve(false),
});
const animated = await resolveExpressionSourceGuild({
loadGuild: () => Promise.resolve(guild([GuildFeatures.DISCOVERABLE, GuildFeatures.ANIMATED_ICON], 'a_icon')),
isMember: () => Promise.resolve(false),
});
expect(plain.icon).toBe('icon');
expect(animated.icon).toBe('a_icon');
});
});
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {stripGuildIconForFeatures} from '@app/api/infrastructure/AssetEntitlementUtils';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {
GUILD_EXPRESSION_SOURCE_BADGE_FEATURES,
type GuildExpressionSourceGuildResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
interface ExpressionSourceGuildLookup {
loadGuild: () => Promise<GuildResponse>;
isMember: () => Promise<boolean>;
}
export async function resolveExpressionSourceGuild({
loadGuild,
isMember,
}: ExpressionSourceGuildLookup): Promise<GuildExpressionSourceGuildResponse> {
const guild = await loadGuild();
const features = new Set(guild.features);
if (!features.has(GuildFeatures.DISCOVERABLE) && !(await isMember())) {
throw new UnknownGuildError();
}
return {
id: guild.id,
name: guild.name,
icon: stripGuildIconForFeatures(guild.icon, features),
features: GUILD_EXPRESSION_SOURCE_BADGE_FEATURES.filter((feature) => features.has(feature)),
};
}
+5 -16
View File
@@ -15,13 +15,11 @@ import type {GuildMember} from '@app/api/models/GuildMember';
import type {GuildRole} from '@app/api/models/GuildRole';
import type {GuildSticker} from '@app/api/models/GuildSticker';
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '@app/api/user/UserCacheHelpers';
import {
GUILD_EXPRESSION_SOURCE_BADGE_FEATURES,
type GuildEmojiResponse,
type GuildEmojiWithUserResponse,
type GuildExpressionSourceGuildResponse,
type GuildStickerResponse,
type GuildStickerWithUserResponse,
import type {
GuildEmojiResponse,
GuildEmojiWithUserResponse,
GuildStickerResponse,
GuildStickerWithUserResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildPartialResponse, GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -52,15 +50,6 @@ export function mapGuildToPartialResponse(guild: Guild): GuildPartialResponse {
};
}
export function mapGuildToExpressionSourceGuildResponse(guild: Guild): GuildExpressionSourceGuildResponse {
return {
id: guild.id.toString(),
name: guild.name,
icon: stripGuildIconForFeatures(guild.iconHash, guild.features),
features: GUILD_EXPRESSION_SOURCE_BADGE_FEATURES.filter((feature) => guild.features.has(feature)),
};
}
export function mapGuildToGuildResponse(
guild: Guild,
options?: {
@@ -14,6 +14,7 @@ import {
GuildEmojiMetadataResponse,
GuildEmojiResponse,
GuildEmojiWithUserListResponse,
GuildExpressionSourceGuildResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import {
GuildEmojiBulkCreateRequest,
@@ -197,4 +198,25 @@ export function GuildEmojiController(app: HonoApp) {
return ctx.json(await ctx.get('guildService').getEmojiMetadata(emojiId));
},
);
app.get(
'/emojis/:emoji_id/source',
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_SOURCE),
LoginRequired,
Validator('param', EmojiIdParam),
OpenAPI({
operationId: 'get_emoji_source',
summary: 'Get emoji source guild',
description:
'Lookup the public presentation of the guild a custom emoji belongs to. Returned when the guild is discoverable or the caller is a member of it. Returns an unknown guild error when the guild is private and the caller is not a member, or when the guild is unavailable.',
responseSchema: GuildExpressionSourceGuildResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Emojis'],
}),
async (ctx) => {
const emojiId = createEmojiID(ctx.req.valid('param').emoji_id);
return ctx.json(await ctx.get('guildService').getEmojiSource(emojiId, ctx.get('user').id));
},
);
}
@@ -14,6 +14,7 @@ import {
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {PurgeQuery} from '@fluxer/schema/src/domains/common/CommonQuerySchemas';
import {
GuildExpressionSourceGuildResponse,
GuildStickerBulkCreateResponse,
GuildStickerMetadataResponse,
GuildStickerResponse,
@@ -210,4 +211,25 @@ export function GuildStickerController(app: HonoApp) {
return ctx.json(await ctx.get('guildService').getStickerMetadata(stickerId));
},
);
app.get(
'/stickers/:sticker_id/source',
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_SOURCE),
LoginRequired,
Validator('param', StickerIdParam),
OpenAPI({
operationId: 'get_sticker_source',
summary: 'Get sticker source guild',
description:
'Lookup the public presentation of the guild a custom sticker belongs to. Returned when the guild is discoverable or the caller is a member of it. Returns an unknown guild error when the guild is private and the caller is not a member, or when the guild is unavailable.',
responseSchema: GuildExpressionSourceGuildResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Stickers'],
}),
async (ctx) => {
const stickerId = createStickerID(ctx.req.valid('param').sticker_id);
return ctx.json(await ctx.get('guildService').getStickerSource(stickerId, ctx.get('user').id));
},
);
}
@@ -5,6 +5,7 @@ import type {EmojiID, GuildID, RoleID, StickerID, UserID} from '@app/api/Branded
import {createWebhookID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {resolveExpressionSourceGuild} from '@app/api/guild/ExpressionSourceGuild';
import {
collectGuildAuditLogUserIds,
isNoopGuildAuditLog,
@@ -12,7 +13,6 @@ import {
type StoredGuildAuditLogEntryResponse,
} from '@app/api/guild/GuildAuditLogEntryMapper';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import {mapGuildToExpressionSourceGuildResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {GuildChannelService} from '@app/api/guild/services/GuildChannelService';
import {GuildContentService} from '@app/api/guild/services/GuildContentService';
@@ -52,6 +52,7 @@ import type {
} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
import type {
GuildEmojiMetadataResponse,
GuildExpressionSourceGuildResponse,
GuildStickerMetadataResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
@@ -260,7 +261,6 @@ export class GuildService {
name: emoji.name,
animated: emoji.isAnimated,
allow_cloning: guild.features.has(GuildFeatures.CLONE_EMOJI_ENABLED),
guild: mapGuildToExpressionSourceGuildResponse(guild),
};
}
@@ -274,10 +274,28 @@ export class GuildService {
name: sticker.name,
animated: sticker.animated,
allow_cloning: guild.features.has(GuildFeatures.CLONE_STICKER_ENABLED),
guild: mapGuildToExpressionSourceGuildResponse(guild),
};
}
async getEmojiSource(emojiId: EmojiID, userId: UserID): Promise<GuildExpressionSourceGuildResponse> {
const emoji = await this.guildRepository.getEmojiById(emojiId);
if (!emoji) throw new UnknownGuildEmojiError();
return this.resolveExpressionSourceGuild(emoji.guildId, userId);
}
async getStickerSource(stickerId: StickerID, userId: UserID): Promise<GuildExpressionSourceGuildResponse> {
const sticker = await this.guildRepository.getStickerById(stickerId);
if (!sticker) throw new UnknownGuildStickerError();
return this.resolveExpressionSourceGuild(sticker.guildId, userId);
}
private resolveExpressionSourceGuild(guildId: GuildID, userId: UserID): Promise<GuildExpressionSourceGuildResponse> {
return resolveExpressionSourceGuild({
loadGuild: () => this.gatewayService.getGuildData({guildId, userId, skipMembershipCheck: true}),
isMember: async () => (await this.gatewayService.getGuildMember({guildId, userId})).success,
});
}
async listGuildAuditLogs(params: {
userId: UserID;
guildId: GuildID;
@@ -1,163 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {createGuild, updateGuild} from '@app/api/guild/tests/GuildTestUtils';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
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 {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {
GuildEmojiMetadataResponse,
GuildEmojiWithUserResponse,
GuildStickerMetadataResponse,
GuildStickerWithUserResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
const SOURCE_GUILD_KEYS = ['features', 'icon', 'id', 'name'];
interface MetadataSource {
owner: TestAccount;
guild: GuildResponse;
emoji: GuildEmojiWithUserResponse;
sticker: GuildStickerWithUserResponse;
}
async function createSource(harness: ApiTestHarness, name: string): Promise<MetadataSource> {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, name);
const emoji = await createBuilder<GuildEmojiWithUserResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/emojis`)
.body({name: 'source_emoji', image: getPngDataUrl()})
.execute();
const sticker = await createBuilder<GuildStickerWithUserResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/stickers`)
.body({name: 'source_sticker', description: 'source sticker', tags: [], image: getPngDataUrl()})
.execute();
return {owner, guild, emoji, sticker};
}
async function grantGuildFeatures(harness: ApiTestHarness, guildId: string, features: Array<string>): Promise<void> {
await createBuilder(harness, '').post(`/test/guilds/${guildId}/features`).body({add_features: features}).execute();
}
async function getEmojiMetadata(
harness: ApiTestHarness,
token: string,
emojiId: string,
): Promise<GuildEmojiMetadataResponse> {
return createBuilder<GuildEmojiMetadataResponse>(harness, token)
.get(`/emojis/${emojiId}/metadata`)
.expect(HTTP_STATUS.OK)
.execute();
}
async function getStickerMetadata(
harness: ApiTestHarness,
token: string,
stickerId: string,
): Promise<GuildStickerMetadataResponse> {
return createBuilder<GuildStickerMetadataResponse>(harness, token)
.get(`/stickers/${stickerId}/metadata`)
.expect(HTTP_STATUS.OK)
.execute();
}
describe('Guild expression metadata source community', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
test('returns the source community presentation for both kinds to a member and a non-member alike', async () => {
const source = await createSource(harness, 'Metadata Presentation Source');
const withIcon = await updateGuild(harness, source.owner.token, source.guild.id, {icon: getPngDataUrl()});
expect(withIcon.icon).toBeTruthy();
await grantGuildFeatures(harness, source.guild.id, [GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]);
const outsider = await createTestAccount(harness);
const responses = [
await getEmojiMetadata(harness, source.owner.token, source.emoji.id),
await getEmojiMetadata(harness, outsider.token, source.emoji.id),
await getStickerMetadata(harness, source.owner.token, source.sticker.id),
await getStickerMetadata(harness, outsider.token, source.sticker.id),
];
for (const response of responses) {
expect(response.guild_id).toBe(source.guild.id);
expect(response.guild.id).toBe(source.guild.id);
expect(response.guild.name).toBe('Metadata Presentation Source');
expect(response.guild.icon).toBe(withIcon.icon);
expect(response.guild.features).toContain(GuildFeatures.DISCOVERABLE);
expect(response.guild.features).toContain(GuildFeatures.VERIFIED);
expect(response.allow_cloning).toBe(false);
}
const [memberEmoji, outsiderEmoji, memberSticker, outsiderSticker] = responses;
expect(outsiderEmoji.guild).toEqual(memberEmoji.guild);
expect(outsiderSticker.guild).toEqual(memberSticker.guild);
expect(memberSticker.guild).toEqual(memberEmoji.guild);
});
test('reports no badge features and no icon for a community that has neither', async () => {
const source = await createSource(harness, 'Metadata Plain Source');
const outsider = await createTestAccount(harness);
const emoji = await getEmojiMetadata(harness, outsider.token, source.emoji.id);
const sticker = await getStickerMetadata(harness, outsider.token, source.sticker.id);
for (const response of [emoji, sticker]) {
expect(response.guild.icon).toBeNull();
expect(response.guild.features).toEqual([]);
}
});
test('exposes only id, name, icon and badge features to a non-member for both kinds', async () => {
const source = await createSource(harness, 'Metadata Envelope Source');
await updateGuild(harness, source.owner.token, source.guild.id, {verification_level: 1});
await grantGuildFeatures(harness, source.guild.id, [
GuildFeatures.DISCOVERABLE,
GuildFeatures.VERIFIED,
GuildFeatures.INVITES_DISABLED,
GuildFeatures.CLONE_EMOJI_ENABLED,
GuildFeatures.CLONE_STICKER_ENABLED,
GuildFeatures.BANNER,
GuildFeatures.INVITE_SPLASH,
]);
const outsider = await createTestAccount(harness);
const emoji = await getEmojiMetadata(harness, outsider.token, source.emoji.id);
const sticker = await getStickerMetadata(harness, outsider.token, source.sticker.id);
for (const response of [emoji, sticker]) {
expect(response.allow_cloning).toBe(true);
expect(Object.keys(response.guild).sort()).toEqual(SOURCE_GUILD_KEYS);
const leaked = response.guild as Record<string, unknown>;
for (const key of [
'banner',
'banner_width',
'banner_height',
'splash',
'splash_width',
'splash_height',
'embed_splash',
'embed_splash_width',
'embed_splash_height',
'splash_card_alignment',
]) {
expect(leaked).not.toHaveProperty(key);
}
expect([...response.guild.features].sort()).toEqual([GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]);
expect(response.guild.features).not.toContain(GuildFeatures.INVITES_DISABLED);
expect(response.guild.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
expect(response.guild.features).not.toContain(GuildFeatures.CLONE_STICKER_ENABLED);
expect(response.guild.features).not.toContain(GuildFeatures.BANNER);
}
expect(sticker.guild).toEqual(emoji.guild);
expect(Object.keys(emoji).sort()).toEqual(['allow_cloning', 'animated', 'guild', 'guild_id', 'id', 'name']);
expect(Object.keys(sticker).sort()).toEqual(['allow_cloning', 'animated', 'guild', 'guild_id', 'id', 'name']);
});
});
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {acceptInvite, createChannelInvite, createGuild, updateGuild} from '@app/api/guild/tests/GuildTestUtils';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
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 {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {
GuildEmojiMetadataResponse,
GuildEmojiWithUserResponse,
GuildExpressionSourceGuildResponse,
GuildStickerMetadataResponse,
GuildStickerWithUserResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
const SOURCE_GUILD_KEYS = ['features', 'icon', 'id', 'name'];
const METADATA_KEYS = ['allow_cloning', 'animated', 'guild_id', 'id', 'name'];
type ExpressionPath = 'emojis' | 'stickers';
interface ExpressionSource {
owner: TestAccount;
guild: GuildResponse;
emoji: GuildEmojiWithUserResponse;
sticker: GuildStickerWithUserResponse;
}
async function createSource(harness: ApiTestHarness, name: string): Promise<ExpressionSource> {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, name);
const emoji = await createBuilder<GuildEmojiWithUserResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/emojis`)
.body({name: 'source_emoji', image: getPngDataUrl()})
.execute();
const sticker = await createBuilder<GuildStickerWithUserResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/stickers`)
.body({name: 'source_sticker', description: 'source sticker', tags: [], image: getPngDataUrl()})
.execute();
return {owner, guild, emoji, sticker};
}
async function grantGuildFeatures(harness: ApiTestHarness, guildId: string, features: Array<string>): Promise<void> {
await createBuilder(harness, '').post(`/test/guilds/${guildId}/features`).body({add_features: features}).execute();
}
async function joinSource(harness: ApiTestHarness, source: ExpressionSource): Promise<TestAccount> {
const member = await createTestAccount(harness);
await ensureSessionStarted(harness, member.token);
const invite = await createChannelInvite(harness, source.owner.token, source.guild.system_channel_id!);
await acceptInvite(harness, member.token, invite.code);
return member;
}
function expressionIds(source: ExpressionSource): Array<[ExpressionPath, string]> {
return [
['emojis', source.emoji.id],
['stickers', source.sticker.id],
];
}
async function getSource(
harness: ApiTestHarness,
token: string,
path: ExpressionPath,
id: string,
): Promise<GuildExpressionSourceGuildResponse> {
return createBuilder<GuildExpressionSourceGuildResponse>(harness, token)
.get(`/${path}/${id}/source`)
.expect(HTTP_STATUS.OK)
.execute();
}
async function expectHiddenSource(harness: ApiTestHarness, token: string, path: ExpressionPath, id: string) {
await createBuilder(harness, token)
.get(`/${path}/${id}/source`)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_GUILD)
.execute();
}
describe('Guild expression source community', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
test('reveals a discoverable community to a non-member for both kinds', async () => {
const source = await createSource(harness, 'Discoverable Source');
const withIcon = await updateGuild(harness, source.owner.token, source.guild.id, {icon: getPngDataUrl()});
await grantGuildFeatures(harness, source.guild.id, [GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]);
const outsider = await createTestAccount(harness);
for (const [path, id] of expressionIds(source)) {
const community = await getSource(harness, outsider.token, path, id);
expect(Object.keys(community).sort()).toEqual(SOURCE_GUILD_KEYS);
expect(community.id).toBe(source.guild.id);
expect(community.name).toBe('Discoverable Source');
expect(community.icon).toBe(withIcon.icon);
expect(community.features).toContain(GuildFeatures.DISCOVERABLE);
expect(community.features).toContain(GuildFeatures.VERIFIED);
}
});
test('reveals a private community to its members for both kinds', async () => {
const source = await createSource(harness, 'Private Source');
const member = await joinSource(harness, source);
for (const token of [source.owner.token, member.token]) {
for (const [path, id] of expressionIds(source)) {
const community = await getSource(harness, token, path, id);
expect(community.id).toBe(source.guild.id);
expect(community.name).toBe('Private Source');
expect(community.features).not.toContain(GuildFeatures.DISCOVERABLE);
}
}
});
test('hides a private community from non-members for both kinds', async () => {
const source = await createSource(harness, 'Hidden Source');
const outsider = await createTestAccount(harness);
for (const [path, id] of expressionIds(source)) {
await expectHiddenSource(harness, outsider.token, path, id);
}
});
test('limits the revealed features to the badge flags', async () => {
const source = await createSource(harness, 'Feature Source');
await grantGuildFeatures(harness, source.guild.id, [
GuildFeatures.DISCOVERABLE,
GuildFeatures.VERIFIED,
GuildFeatures.INVITES_DISABLED,
GuildFeatures.CLONE_EMOJI_ENABLED,
]);
const outsider = await createTestAccount(harness);
for (const [path, id] of expressionIds(source)) {
const community = await getSource(harness, outsider.token, path, id);
expect(community.features).not.toContain(GuildFeatures.INVITES_DISABLED);
expect(community.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
}
});
test('keeps the expression metadata contract without the source community', async () => {
const source = await createSource(harness, 'Metadata Source');
await grantGuildFeatures(harness, source.guild.id, [GuildFeatures.DISCOVERABLE]);
const outsider = await createTestAccount(harness);
const emoji = await createBuilder<GuildEmojiMetadataResponse>(harness, outsider.token)
.get(`/emojis/${source.emoji.id}/metadata`)
.expect(HTTP_STATUS.OK)
.execute();
const sticker = await createBuilder<GuildStickerMetadataResponse>(harness, outsider.token)
.get(`/stickers/${source.sticker.id}/metadata`)
.expect(HTTP_STATUS.OK)
.execute();
expect(Object.keys(emoji).sort()).toEqual(METADATA_KEYS);
expect(Object.keys(sticker).sort()).toEqual(METADATA_KEYS);
});
});
+148 -22
View File
@@ -5522,6 +5522,70 @@
]
}
},
"/emojis/{emoji_id}/source": {
"get": {
"operationId": "get_emoji_source",
"summary": "Get emoji source guild",
"tags": ["Emojis"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/GuildExpressionSourceGuildResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Lookup the public presentation of the guild a custom emoji belongs to. Returned when the guild is discoverable or the caller is a member of it. Returns an unknown guild error when the guild is private and the caller is not a member, or when the guild is unavailable.",
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
"name": "emoji_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the emoji", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the emoji"
}
]
}
},
"/experiments": {
"get": {
"operationId": "get_experiments",
@@ -12150,6 +12214,70 @@
]
}
},
"/stickers/{sticker_id}/source": {
"get": {
"operationId": "get_sticker_source",
"summary": "Get sticker source guild",
"tags": ["Stickers"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/GuildExpressionSourceGuildResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Lookup the public presentation of the guild a custom sticker belongs to. Returned when the guild is discoverable or the caller is a member of it. Returns an unknown guild error when the guild is private and the caller is not a member, or when the guild is unavailable.",
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
"name": "sticker_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the sticker", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the sticker"
}
]
}
},
"/streams/{stream_key}/preview": {
"get": {
"operationId": "get_stream_preview",
@@ -24834,6 +24962,22 @@
"contentEncoding": "binary",
"description": "The current stream preview image"
},
"GuildExpressionSourceGuildResponse": {
"type": "object",
"properties": {
"id": {"description": "The ID of the source guild", "$ref": "#/components/schemas/SnowflakeStringType"},
"name": {"type": "string", "description": "The name of the source guild"},
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "The hash of the source guild icon"},
"features": {
"type": "array",
"items": {"type": "string", "enum": ["VERIFIED", "PARTNERED", "DISCOVERABLE"]},
"description": "The badge feature flags of the source guild, limited to VERIFIED, PARTNERED, and DISCOVERABLE"
}
},
"required": ["id", "name", "icon", "features"],
"additionalProperties": false,
"description": "Public presentation of the source guild of an expression"
},
"GuildStickerMetadataResponse": {
"type": "object",
"properties": {
@@ -24850,10 +24994,9 @@
"allow_cloning": {
"type": "boolean",
"description": "Whether the source guild allows non-members to use the in-app clone shortcut"
},
"guild": {"$ref": "#/components/schemas/GuildExpressionSourceGuildResponse"}
}
},
"required": ["id", "guild_id", "name", "animated", "allow_cloning", "guild"],
"required": ["id", "guild_id", "name", "animated", "allow_cloning"],
"additionalProperties": false
},
"GlobalSearchMessagesRequest": {
@@ -27485,10 +27628,9 @@
"allow_cloning": {
"type": "boolean",
"description": "Whether the source guild allows non-members to use the in-app clone shortcut"
},
"guild": {"$ref": "#/components/schemas/GuildExpressionSourceGuildResponse"}
}
},
"required": ["id", "guild_id", "name", "animated", "allow_cloning", "guild"],
"required": ["id", "guild_id", "name", "animated", "allow_cloning"],
"additionalProperties": false
},
"DonationRequestLinkRequest": {
@@ -31202,22 +31344,6 @@
"additionalProperties": false
},
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
"GuildExpressionSourceGuildResponse": {
"type": "object",
"properties": {
"id": {"description": "The ID of the source guild", "$ref": "#/components/schemas/SnowflakeStringType"},
"name": {"type": "string", "description": "The name of the source guild"},
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "The hash of the source guild icon"},
"features": {
"type": "array",
"items": {"type": "string", "enum": ["VERIFIED", "PARTNERED", "DISCOVERABLE"]},
"description": "The badge feature flags of the source guild, limited to VERIFIED, PARTNERED, and DISCOVERABLE"
}
},
"required": ["id", "name", "icon", "features"],
"additionalProperties": false,
"description": "Public presentation of the source guild of an expression"
},
"TypingIndicatorReworkAssignmentResponse": {
"type": "object",
"properties": {
@@ -136,6 +136,10 @@ export const GuildRateLimitConfigs = {
bucket: 'guild:emoji:metadata::user_id',
config: {limit: 60, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
GUILD_EMOJI_SOURCE: {
bucket: 'guild:emoji:source::user_id',
config: {limit: 60, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
GUILD_STICKERS_LIST: {
bucket: 'guild:sticker:list::guild_id',
config: {limit: 60, windowMs: ms('10 seconds')},
@@ -168,4 +172,8 @@ export const GuildRateLimitConfigs = {
bucket: 'guild:sticker:metadata::user_id',
config: {limit: 60, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
GUILD_STICKER_SOURCE: {
bucket: 'guild:sticker:source::user_id',
config: {limit: 60, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
} as const;