Compare commits

...
Author SHA1 Message Date
HampusandGitHub 3f1f899b23 fix(api): keep a deprecated nsfw field for older clients (#2463) 2026-09-04 23:08:34 +02:00
HampusandGitHub 51cb750502 feat(api): drop NSFW classification for emojis and stickers (#2462) 2026-09-04 22:55:58 +02:00
HampusandGitHub 1e6c332eae fix(app): show empty categories when hiding muted channels (#2460) 2026-09-04 21:04:34 +02:00
HampusandGitHub 18ae2e563e fix(worker): fit the job streams to the jetstream budget (#2458) 2026-09-04 19:31:20 +02:00
HampusandGitHub a4d039c910 fix(app-proxy): publish source maps with the asset tree (#2457) 2026-09-04 19:10:30 +02:00
HampusandGitHub 6163fd5644 fix(message): turn mentions red in failed messages (#2456) 2026-09-04 19:03:30 +02:00
HampusandGitHub 3e2ddaca4f fix(message): turn links red in failed messages (#2455) 2026-09-04 18:21:27 +02:00
HampusandGitHub 054a59e622 fix(message): keep reply previews on one line after a mention (#2454) 2026-09-04 18:20:14 +02:00
HampusandGitHub 84d7290ed9 fix(api): tighten guild emoji and sticker mutation limits (#2453) 2026-09-04 17:58:08 +02:00
HampusandGitHub f4c1254d91 fix(media-proxy): stop serving animated originals as stills (#2452) 2026-09-04 16:56:58 +02:00
HampusandGitHub c01d22dc05 fix(self-host): unfurl media hosted by the instance itself (#2451) 2026-09-04 16:56:35 +02:00
HampusandGitHub 4c0f02d8a5 chore(i18n): refresh catalogs for the window share audio scope (#2450) 2026-09-04 16:41:53 +02:00
HampusandGitHub 2770482baf perf(app-proxy): hold the frozen snapshot by reference (#2449) 2026-09-04 16:00:23 +02:00
HampusandGitHub 8e39a00e34 perf(app-proxy): run on jemalloc to curb arena growth (#2448) 2026-09-04 15:57:47 +02:00
HampusandGitHub 7bd0d3a962 fix(app-proxy): remove the SPA document render reservation (#2447) 2026-09-04 15:55:59 +02:00
HampusandGitHub bc7f701e87 feat(voice): give window shares their own audio scope (#2446) 2026-09-04 15:48:37 +02:00
HampusandGitHub 0d8116d73e fix(workspace): restore the devcontainer compose project name (#2445) 2026-09-04 15:41:38 +02:00
HampusandGitHub 255cbc1248 perf(app-proxy): drop dynamic brotli compression (#2444) 2026-09-04 14:47:49 +02:00
HampusandGitHub 098aeef412 fix(media-proxy): stop lifting bt709 video thumbnails (#2443) 2026-09-04 13:17:30 +02:00
HampusandGitHub baa18aed5b fix(media-proxy): link source-built native libs first (#2442) 2026-09-04 03:42:56 +02:00
HampusandGitHub b7c8dab019 fix(media-proxy): pin builder libheif, fix the image build (#2441) 2026-09-04 02:06:02 +02:00
HampusandGitHub 587324fa38 fix(voice): reuse the Linux audio capture across routing changes (#2440) 2026-09-04 01:00:32 +02:00
HampusandGitHub cc3a9c8613 fix(app): jitter gateway reconnects and recover status nagbar (#2439) 2026-09-03 23:21:32 +02:00
HampusandGitHub b0645300ec fix(i18n): reaction tooltip word order and plural agreement (#2438) 2026-09-03 22:11:59 +02:00
HampusandGitHub d7d4e8da03 refactor(media-proxy): split into modules and harden streaming (#2437) 2026-09-03 22:04:00 +02:00
468 changed files with 76308 additions and 46473 deletions
+28
View File
@@ -32,6 +32,7 @@ RUN apt-get update \
jq \
libasound2 \
libatk-bridge2.0-0 \
libaom-dev \
libavcodec-dev \
libavfilter-dev \
libavformat-dev \
@@ -51,9 +52,15 @@ RUN apt-get update \
libcurl4-openssl-dev \
libswresample-dev \
libswscale-dev \
libdav1d-dev \
libde265-dev \
liblcms2-dev \
libvips-dev \
libyuv-dev \
libwayland-dev \
libwebp-dev \
nasm \
yasm \
libssl-dev \
libx11-xcb1 \
libxcb-dri3-0 \
@@ -78,6 +85,7 @@ RUN apt-get update \
unzip \
xz-utils \
xdg-utils \
zlib1g-dev \
zstd \
&& rm -rf /var/lib/apt/lists/*
@@ -127,6 +135,26 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesour
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
COPY fluxer_media_proxy/tools/install-native-deps.sh /tmp/fluxer-install-native-deps.sh
RUN /tmp/fluxer-install-native-deps.sh /usr/local \
&& rm /tmp/fluxer-install-native-deps.sh
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig
ENV LD_LIBRARY_PATH=/usr/local/lib
RUN printf '%s\n' \
'#include <libheif/heif.h>' \
'#include <string.h>' \
'#if !LIBHEIF_HAVE_VERSION(1, 23, 0)' \
'#error the source-built libheif headers must win the include search' \
'#endif' \
'int main(void) { return strcmp(heif_get_version(), LIBHEIF_VERSION) != 0; }' \
>/tmp/fluxer-heif-probe.c \
&& cc /tmp/fluxer-heif-probe.c $(pkg-config --cflags --libs libheif) -o /tmp/fluxer-heif-probe \
&& /tmp/fluxer-heif-probe \
&& [ "$(pkg-config --variable=prefix libheif)" = /usr/local ] \
&& rm /tmp/fluxer-heif-probe.c /tmp/fluxer-heif-probe
COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt
RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \
&& rm /tmp/fluxer-fonts-requirements.txt \
+2
View File
@@ -1,3 +1,5 @@
name: fluxer-dev
services:
workspace:
build:
+38 -6
View File
@@ -152,8 +152,8 @@ jobs:
'packages/markdown_parser/rust/src/**') }}
rust:
runs-on: ubuntu-24.04
timeout-minutes: 60
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -174,11 +174,15 @@ jobs:
cache: 'pnpm'
- name: Cache cargo
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
workspaces: |
. -> target
save-if: ${{ github.ref == 'refs/heads/main' }}
path: |
~/.cargo/registry
~/.cargo/git
target
key: rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
- name: Install cargo-deny
run: cargo install cargo-deny --version 0.19.6 --locked
@@ -189,6 +193,12 @@ jobs:
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
- name: Cache native media dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: /opt/fluxer-native
key: media-native-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}
- name: Install native dependencies
run: |
sudo apt-get update
@@ -197,21 +207,34 @@ jobs:
binutils \
clang \
cmake \
curl \
libaom-dev \
libavfilter-dev \
libclang-dev \
libcurl4-openssl-dev \
libdav1d-dev \
libde265-dev \
libfido2-dev \
libheif-dev \
liblcms2-dev \
libpipewire-0.3-dev \
libspa-0.2-dev \
libssl-dev \
libudev-dev \
libvips-dev \
libwebp-dev \
libyuv-dev \
meson \
nasm \
ninja-build \
pkg-config \
xz-utils \
yasm \
zlib1g-dev
sudo fluxer_media_proxy/tools/install-native-deps.sh /opt/fluxer-native
echo "PKG_CONFIG_PATH=/opt/fluxer-native/lib/pkgconfig:/opt/fluxer-native/lib64/pkgconfig" >> "$GITHUB_ENV"
echo "LD_LIBRARY_PATH=/opt/fluxer-native/lib:/opt/fluxer-native/lib64" >> "$GITHUB_ENV"
echo "/opt/fluxer-native/bin" >> "$GITHUB_PATH"
- name: Install Node.js dependencies
run: pnpm --filter fluxer_admin install
@@ -228,7 +251,16 @@ jobs:
- name: Clippy (desktop native workspaces on Linux, warnings as errors)
run: tools/ci/check-desktop-native-workspaces.sh clippy
- name: Verify the source-built ffmpeg CLI is on PATH
run: |
set -euo pipefail
command -v ffmpeg
test "$(command -v ffmpeg)" = /opt/fluxer-native/bin/ffmpeg
ffmpeg -hide_banner -version
- name: Run tests
env:
FLUXER_REQUIRE_MEDIA_FIXTURES: "1"
run: cargo test --workspace --all-features --locked
- name: Run desktop native workspace tests on Linux
Generated
+1
View File
@@ -1809,6 +1809,7 @@ dependencies = [
"clap",
"criterion",
"fluxer_common",
"futures-util",
"hex",
"hmac 0.13.0",
"http 1.4.2",
+1
View File
@@ -434,6 +434,7 @@ services:
FLUXER_MEDIA_PROXY_PORT: "8080"
FLUXER_MEDIA_PROXY_MODE: upload
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
depends_on:
seaweedfs-init: {condition: service_completed_successfully}
nats: {condition: service_healthy}
+1 -13
View File
@@ -9034,7 +9034,6 @@
"NOT_FRIENDS_WITH_USER",
"NOT_OWNER_OF_ADMIN_API_KEY",
"NSFW_CONTENT_AGE_RESTRICTED",
"NSFW_EMOJI_STICKER_BLOCKED",
"PASSKEY_AUTHENTICATION_FAILED",
"PASSKEYS_DISABLED",
"PHONE_ADD_NOT_ELIGIBLE",
@@ -12283,11 +12282,6 @@
"type": "array",
"items": {"$ref": "#/components/schemas/MessageStickerResponse"}
},
"nsfw_emojis": {
"type": "array",
"items": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "IDs of custom emojis in this message that are classified as NSFW"
},
"reactions": {
"description": "The reactions on the message",
"nullable": true,
@@ -12431,11 +12425,6 @@
"type": "array",
"items": {"$ref": "#/components/schemas/MessageStickerResponse"}
},
"nsfw_emojis": {
"type": "array",
"items": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "IDs of custom emojis in this message that are classified as NSFW"
},
"reactions": {
"description": "The reactions on the message",
"nullable": true,
@@ -12881,8 +12870,7 @@
"description": "The unique identifier of the sticker"
},
"name": {"type": "string", "description": "The name of the sticker"},
"animated": {"type": "boolean", "description": "Whether the sticker is animated"},
"nsfw": {"type": "boolean", "description": "Whether this sticker is classified as NSFW"}
"animated": {"type": "boolean", "description": "Whether the sticker is animated"}
},
"required": ["id", "name", "animated"]
},
@@ -90,7 +90,6 @@ function makeMessageRow(channelId: ChannelID, messageId: MessageID): MessageRow
message_reference: null,
message_snapshots: null,
call: null,
nsfw_emojis: null,
has_reaction: false,
version: 1,
};
@@ -60,7 +60,6 @@ function buildMessageRow(messageId: MessageID, hasReaction: boolean | null): Mes
message_reference: null,
message_snapshots: null,
call: null,
nsfw_emojis: null,
has_reaction: hasReaction,
version: 1,
};
@@ -205,7 +205,6 @@ export class CallService {
participant_ids: new Set(allRecipients),
ended_timestamp: null,
},
nsfw_emojis: null,
has_reaction: false,
version: 1,
});
@@ -1,13 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {
GuildExplicitContentFilterTypes,
GuildFeatures,
GuildNSFWLevel,
GuildOperations,
} from '@fluxer/constants/src/GuildConstants';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {MAX_REACTIONS_PER_MESSAGE, MAX_USERS_PER_MESSAGE_REACTION} from '@fluxer/constants/src/LimitConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -17,10 +12,7 @@ import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMes
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {NsfwEmojiStickerBlockedError} from '@fluxer/errors/src/domains/moderation/NsfwEmojiStickerBlockedError';
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
@@ -226,21 +218,6 @@ export class MessageReactionService extends MessageInteractionBase {
hasPermission: channel.guildId ? hasPermission : undefined,
});
}
if (parsedEmoji.id) {
const reactionEmojiId = createEmojiID(BigInt(parsedEmoji.id));
const emojiObj = await this.guildRepository.getEmojiById(reactionEmojiId);
if (emojiObj?.isNsfw) {
const isNSFWAllowed = this.isNSFWContentAllowedForReaction({
channel,
guild,
member: authChannel.member,
isBot: requestingUser?.isBot,
});
if (!isNSFWAllowed) {
throw new NsfwEmojiStickerBlockedError();
}
}
}
if (reactionCount >= maxUsersPerReaction) {
throw new MaxUsersPerMessageReactionError(maxUsersPerReaction);
}
@@ -548,42 +525,4 @@ export class MessageReactionService extends MessageInteractionBase {
},
});
}
private isNSFWContentAllowedForReaction(params: {
channel: Channel;
guild: GuildResponse | null;
member: GuildMemberResponse | null;
isBot?: boolean;
}): boolean {
const {channel, guild, member, isBot} = params;
if (isBot) {
return true;
}
if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) && channel.isNsfw) {
return true;
}
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return true;
}
if (!guild) {
return false;
}
const guildMarkedNsfw = guild.nsfw_level === GuildNSFWLevel.AGE_RESTRICTED;
if (guildMarkedNsfw) {
return true;
}
const features = new Set(guild.features ?? []);
if (features.has(GuildFeatures.DISCOVERABLE)) {
return false;
}
const explicitContentFilter = guild.explicit_content_filter;
if (explicitContentFilter === GuildExplicitContentFilterTypes.DISABLED) {
return true;
}
if (explicitContentFilter === GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES) {
const hasRoles = member && member.roles.length > 0;
return !!hasRoles;
}
return false;
}
}
@@ -6,7 +6,6 @@ import {ATTACHMENT_MAX_SIZE_NON_PREMIUM} from '@fluxer/constants/src/LimitConsta
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {NsfwEmojiStickerBlockedError} from '@fluxer/errors/src/domains/moderation/NsfwEmojiStickerBlockedError';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
@@ -15,8 +14,8 @@ import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
import {AttachmentDecayService} from '../../../attachment/AttachmentDecayService';
import type {ChannelID, EmojiID, GuildID, MessageID, RoleID, StickerID, UserID, WebhookID} from '../../../BrandedTypes';
import {createAttachmentID, createEmojiID, createGuildID} from '../../../BrandedTypes';
import type {ChannelID, GuildID, MessageID, RoleID, StickerID, UserID, WebhookID} from '../../../BrandedTypes';
import {createAttachmentID, createGuildID} from '../../../BrandedTypes';
import {Config} from '../../../Config';
import {getContentMessage} from '../../../content_i18n/ContentI18n';
import type {
@@ -121,7 +120,7 @@ export class MessagePersistenceService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
private userRepository: IUserRepository,
private guildRepository: IGuildRepositoryAggregate,
guildRepository: IGuildRepositoryAggregate,
private embedService: EmbedService,
private readonly storageService: IStorageService,
attachmentUploadTraceRepository: AttachmentUploadTraceRepository,
@@ -168,18 +167,10 @@ export class MessagePersistenceService {
isBot,
dmNsfwContext: params.dmNsfwContext,
});
let nsfwEmojiIds = new Set<EmojiID>();
if (params.content) {
if (!isNSFWAllowed) {
await this.enforceNsfwEmojiRestrictions(params.content);
} else {
nsfwEmojiIds = await this.collectNsfwEmojiIds(params.content);
}
}
const [sanitizedContent, attachmentResult, processedStickers] = await Promise.all([
this.sanitizeContentIfNeeded(params, authorId),
this.processAttachments(params, isNSFWAllowed ? 'allow' : 'block'),
this.processStickers(params, authorId, isNSFWAllowed),
this.processStickers(params, authorId),
]);
let messageContent = sanitizedContent;
let processedAttachments: Array<MessageAttachment> = params.processedAttachments
@@ -242,7 +233,6 @@ export class MessagePersistenceService {
? params.messageSnapshots.map((snapshot) => snapshot.toMessageSnapshot())
: null,
call: null,
nsfw_emojis: nsfwEmojiIds.size > 0 ? nsfwEmojiIds : null,
has_reaction: false,
version: 1,
};
@@ -305,7 +295,6 @@ export class MessagePersistenceService {
private async processStickers(
params: CreateMessageParams,
authorId: UserID | null,
isNSFWAllowed?: boolean,
): Promise<Array<MessageStickerItem>> {
if (!params.stickerIds || params.stickerIds.length === 0) {
return [];
@@ -315,37 +304,9 @@ export class MessagePersistenceService {
userId: authorId,
guildId: params.guildId,
hasPermission: params.hasPermission,
isNSFWAllowed: isNSFWAllowed ?? true,
});
}
private async enforceNsfwEmojiRestrictions(content: string): Promise<void> {
const nsfwIds = await this.collectNsfwEmojiIds(content);
if (nsfwIds.size > 0) {
throw new NsfwEmojiStickerBlockedError();
}
}
private async collectNsfwEmojiIds(content: string): Promise<Set<EmojiID>> {
const CUSTOM_EMOJI_REGEX = /<a?:[^:]+:(\d+)>/g;
const emojiIds = new Set<EmojiID>();
let match: RegExpExecArray | null;
while ((match = CUSTOM_EMOJI_REGEX.exec(content)) !== null) {
emojiIds.add(createEmojiID(BigInt(match[1])));
}
if (emojiIds.size === 0) {
return new Set();
}
const lookups = await Promise.all([...emojiIds].map((id) => this.guildRepository.getEmojiById(id)));
const nsfwEmojiIds = new Set<EmojiID>();
for (const emoji of lookups) {
if (emoji?.isNsfw) {
nsfwEmojiIds.add(emoji.id);
}
}
return nsfwEmojiIds;
}
private async runPostPersistenceOperations(context: {
message: Message;
params: CreateMessageParams;
@@ -431,9 +392,6 @@ export class MessagePersistenceService {
if (data.content !== undefined && data.content !== message.content) {
let sanitizedContent = data.content && hasVisibleContent(data.content) ? data.content : '';
if (sanitizedContent) {
if (!isNSFWAllowed) {
await this.enforceNsfwEmojiRestrictions(sanitizedContent);
}
sanitizedContent = await this.contentService.sanitizeCustomEmojis({
content: sanitizedContent,
userId: message.authorId ?? null,
@@ -96,7 +96,6 @@ function makeMessage(messageId: bigint = 2n, content: string = '', channelId: bi
message_reference: null,
message_snapshots: null,
call: null,
nsfw_emojis: null,
has_reaction: null,
version: 1,
});
@@ -48,7 +48,6 @@ function makeMessage(channelId: ChannelID, messageId: MessageID): Message {
message_reference: null,
message_snapshots: null,
call: null,
nsfw_emojis: null,
has_reaction: null,
version: 1,
});
@@ -18,7 +18,7 @@ import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/F
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {SlowmodeRateLimitError} from '@fluxer/errors/src/domains/core/SlowmodeRateLimitError';
import {NsfwEmojiStickerBlockedError} from '@fluxer/errors/src/domains/moderation/NsfwEmojiStickerBlockedError';
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
@@ -845,7 +845,7 @@ export class MessageSendService {
const guildNsfw = guild != null && guild.nsfw_level === GuildNSFWLevel.AGE_RESTRICTED;
const destAllowsNsfw = channel.isNsfw || guildNsfw;
if (!destAllowsNsfw) {
throw new NsfwEmojiStickerBlockedError();
throw new NsfwContentRequiresAgeVerificationError();
}
}
if (data.message_reference && guild && !isForwardMessage) {
@@ -4,7 +4,6 @@ import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {NsfwEmojiStickerBlockedError} from '@fluxer/errors/src/domains/moderation/NsfwEmojiStickerBlockedError';
import type {GuildID, StickerID, UserID} from '../../../BrandedTypes';
import type {MessageStickerItem} from '../../../database/types/MessageTypes';
import type {IGuildRepositoryAggregate} from '../../../guild/repositories/IGuildRepositoryAggregate';
@@ -25,9 +24,8 @@ export class MessageStickerService {
userId: UserID | null;
guildId: GuildID | null;
hasPermission?: (permission: bigint) => Promise<boolean>;
isNSFWAllowed?: boolean;
}): Promise<Array<MessageStickerItem>> {
const {stickerIds, userId, guildId, hasPermission, isNSFWAllowed = true} = params;
const {stickerIds, userId, guildId, hasPermission} = params;
let hasGlobalExpressions = 0;
if (userId) {
const user = await this.userRepository.findUnique(userId);
@@ -49,26 +47,18 @@ export class MessageStickerService {
if (!stickerFromAnyGuild) {
throw InputValidationError.fromCode('sticker', ValidationErrorCodes.CUSTOM_STICKER_NOT_FOUND);
}
if (!isNSFWAllowed && stickerFromAnyGuild.isNsfw) {
throw new NsfwEmojiStickerBlockedError();
}
return {
sticker_id: stickerFromAnyGuild.id,
name: stickerFromAnyGuild.name,
animated: stickerFromAnyGuild.animated,
...(stickerFromAnyGuild.isNsfw ? {nsfw: true} : {}),
};
}
const guildSticker = await this.guildRepository.getSticker(stickerId, guildId);
if (guildSticker) {
if (!isNSFWAllowed && guildSticker.isNsfw) {
throw new NsfwEmojiStickerBlockedError();
}
return {
sticker_id: guildSticker.id,
name: guildSticker.name,
animated: guildSticker.animated,
...(guildSticker.isNsfw ? {nsfw: true} : {}),
};
}
const stickerFromOtherGuild = await this.guildRepository.getStickerById(stickerId);
@@ -87,14 +77,10 @@ export class MessageStickerService {
throw new MissingPermissionsError();
}
}
if (!isNSFWAllowed && stickerFromOtherGuild.isNsfw) {
throw new NsfwEmojiStickerBlockedError();
}
return {
sticker_id: stickerFromOtherGuild.id,
name: stickerFromOtherGuild.name,
animated: stickerFromOtherGuild.animated,
...(stickerFromOtherGuild.isNsfw ? {nsfw: true} : {}),
};
}),
);
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "إذا كنت مستهلكًا في الاتحاد الأوروبي/المنطقة الاقتصادية الأوروبية، فإنني أوافق صراحةً على تقديم المحتوى الرقمي لـ {product_name} {premium_tier_name} فورًا، وأقرّ بأنني أفقد حقي القانوني في الانسحاب بمجرد تقديم الوصول. لا يؤثر هذا على حقوق المستهلك الإلزامية الأخرى. اطّلع على [شروط الخدمة]({terms_url}).",
"bulk_message_deletion.complete": "انتهينا من حذف رسائلك. أزلنا {message_count, plural, =0 {0 رسالة} one {رسالة واحدة} two {رسالتين} few {# رسائل} many {# رسالة} other {# رسالة}} من {channel_count, plural, =0 {0 مكان} one {مكان واحد} two {مكانين} few {# أماكن} many {# مكانًا} other {# مكان}}.",
"content.virus_detected": "تم الإبلاغ عن هذا الملف على أنه قد يكون غير آمن وتمت إزالته.",
"guild.bulk_create.emoji_limit": "تم الوصول إلى الحد الأقصى لعدد الرموز التعبيرية ({limit}).",
"guild.bulk_create.sticker_limit": "تم الوصول إلى الحد الأقصى لعدد الملصقات ({limit}).",
"guild.bulk_create.unknown_error": "حدث خطأ غير معروف.",
"guild.default_category_text": "القنوات النصية",
"guild.default_category_voice": "القنوات الصوتية",
"guild.default_channel_text": "عام",
"guild.default_channel_voice": "عام"
"billing.eu_withdrawal_waiver_checkout": "إذا كنت مستهلكًا في الاتحاد الأوروبي/المنطقة الاقتصادية الأوروبية، فإنني أوافق صراحةً على تقديم المحتوى الرقمي لـ {product_name} {premium_tier_name} فورًا، وأقرّ بأنني أفقد حقي القانوني في الانسحاب بمجرد تقديم الوصول. لا يؤثر هذا على حقوق المستهلك الإلزامية الأخرى. اطّلع على [شروط الخدمة]({terms_url}).",
"bulk_message_deletion.complete": "انتهينا من حذف رسائلك. أزلنا {message_count, plural, =0 {0 رسالة} one {رسالة واحدة} two {رسالتين} few {# رسائل} many {# رسالة} other {# رسالة}} من {channel_count, plural, =0 {0 مكان} one {مكان واحد} two {مكانين} few {# أماكن} many {# مكانًا} other {# مكان}}.",
"content.virus_detected": "تم الإبلاغ عن هذا الملف على أنه قد يكون غير آمن وتمت إزالته.",
"guild.bulk_create.emoji_limit": "تم الوصول إلى الحد الأقصى لعدد الرموز التعبيرية ({limit}).",
"guild.bulk_create.sticker_limit": "تم الوصول إلى الحد الأقصى لعدد الملصقات ({limit}).",
"guild.bulk_create.unknown_error": "حدث خطأ غير معروف.",
"guild.default_category_text": "القنوات النصية",
"guild.default_category_voice": "القنوات الصوتية",
"guild.default_channel_text": "عام",
"guild.default_channel_voice": "عام"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Ако съм потребител от ЕС/ЕИП, изрично се съгласявам цифровото съдържание на {product_name} {premium_tier_name} да бъде предоставено незабавно и признавам, че губя законното си право на отказ, след като достъпът бъде предоставен. Това не засяга други задължителни потребителски права. Виж [Условията за ползване]({terms_url}).",
"bulk_message_deletion.complete": "Изтриването на съобщенията ти приключи. Премахнахме {message_count, plural, =0 {0 съобщения} one {# съобщение} other {# съобщения}} от {channel_count, plural, =0 {0 места} one {# място} other {# места}}.",
"content.virus_detected": "Този файл беше маркиран като потенциално опасен и е премахнат.",
"guild.bulk_create.emoji_limit": "Постигнат е максималният брой емотикони ({limit}).",
"guild.bulk_create.sticker_limit": "Постигнат е максималният брой стикери ({limit}).",
"guild.bulk_create.unknown_error": "Възникна неизвестна грешка.",
"guild.default_category_text": "Текстови канали",
"guild.default_category_voice": "Гласови канали",
"guild.default_channel_text": "общ",
"guild.default_channel_voice": "Общ"
"billing.eu_withdrawal_waiver_checkout": "Ако съм потребител от ЕС/ЕИП, изрично се съгласявам цифровото съдържание на {product_name} {premium_tier_name} да бъде предоставено незабавно и признавам, че губя законното си право на отказ, след като достъпът бъде предоставен. Това не засяга други задължителни потребителски права. Виж [Условията за ползване]({terms_url}).",
"bulk_message_deletion.complete": "Изтриването на съобщенията ти приключи. Премахнахме {message_count, plural, =0 {0 съобщения} one {# съобщение} other {# съобщения}} от {channel_count, plural, =0 {0 места} one {# място} other {# места}}.",
"content.virus_detected": "Този файл беше маркиран като потенциално опасен и е премахнат.",
"guild.bulk_create.emoji_limit": "Постигнат е максималният брой емотикони ({limit}).",
"guild.bulk_create.sticker_limit": "Постигнат е максималният брой стикери ({limit}).",
"guild.bulk_create.unknown_error": "Възникна неизвестна грешка.",
"guild.default_category_text": "Текстови канали",
"guild.default_category_voice": "Гласови канали",
"guild.default_channel_text": "общ",
"guild.default_channel_voice": "Общ"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Pokud jsem spotřebitelem z EU/EHP, výslovně souhlasím s tím, že digitální obsah {product_name} {premium_tier_name} bude poskytnut okamžitě, a beru na vědomí, že po poskytnutí přístupu ztrácím zákonné právo na odstoupení od smlouvy. Tím nejsou dotčena další povinná práva spotřebitele. Viz [Podmínky služby]({terms_url}).",
"bulk_message_deletion.complete": "Dokončili jsme mazání tvých zpráv. Odstranili jsme {message_count, plural, =0 {0 zpráv} one {# zprávu} few {# zprávy} many {# zpráv} other {# zpráv}} z {channel_count, plural, =0 {0 míst} one {# místa} few {# míst} many {# míst} other {# míst}}.",
"content.virus_detected": "Tento soubor byl označen jako potenciálně nebezpečný a byl odstraněn.",
"guild.bulk_create.emoji_limit": "Dosáhl jste maximálního počtu emoji ({limit}).",
"guild.bulk_create.sticker_limit": "Dosáhl jste maximálního počtu nálepek ({limit}).",
"guild.bulk_create.unknown_error": "Nastala neznámá chyba.",
"guild.default_category_text": "Textové kanály",
"guild.default_category_voice": "Hlasové kanály",
"guild.default_channel_text": "obecný",
"guild.default_channel_voice": "Obecný"
"billing.eu_withdrawal_waiver_checkout": "Pokud jsem spotřebitelem z EU/EHP, výslovně souhlasím s tím, že digitální obsah {product_name} {premium_tier_name} bude poskytnut okamžitě, a beru na vědomí, že po poskytnutí přístupu ztrácím zákonné právo na odstoupení od smlouvy. Tím nejsou dotčena další povinná práva spotřebitele. Viz [Podmínky služby]({terms_url}).",
"bulk_message_deletion.complete": "Dokončili jsme mazání tvých zpráv. Odstranili jsme {message_count, plural, =0 {0 zpráv} one {# zprávu} few {# zprávy} many {# zpráv} other {# zpráv}} z {channel_count, plural, =0 {0 míst} one {# místa} few {# míst} many {# míst} other {# míst}}.",
"content.virus_detected": "Tento soubor byl označen jako potenciálně nebezpečný a byl odstraněn.",
"guild.bulk_create.emoji_limit": "Dosáhl jste maximálního počtu emoji ({limit}).",
"guild.bulk_create.sticker_limit": "Dosáhl jste maximálního počtu nálepek ({limit}).",
"guild.bulk_create.unknown_error": "Nastala neznámá chyba.",
"guild.default_category_text": "Textové kanály",
"guild.default_category_voice": "Hlasové kanály",
"guild.default_channel_text": "obecný",
"guild.default_channel_voice": "Obecný"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Hvis jeg er en EU/EØS-forbruger, giver jeg udtrykkeligt samtykke til, at {product_name}s {premium_tier_name}-abonnement digitalt indhold leveres øjeblikkeligt, og jeg anerkender, at jeg mister min lovbestemte fortrydelsesret, når adgangen gives. Dette påvirker ikke andre ufravigelige forbrugerrettigheder. Se [servicevilkårene]({terms_url}).",
"bulk_message_deletion.complete": "Vi er færdige med at slette dine beskeder. Vi fjernede {message_count, plural, =0 {0 beskeder} one {# besked} other {# beskeder}} fra {channel_count, plural, =0 {0 steder} one {# sted} other {# steder}}.",
"content.virus_detected": "Den fil blev markeret som potentielt usikker og er blevet fjernet.",
"guild.bulk_create.emoji_limit": "Maksimalt antal emoji nået ({limit}).",
"guild.bulk_create.sticker_limit": "Maksimalt antal klistermærker nået ({limit}).",
"guild.bulk_create.unknown_error": "Ukendt fejl.",
"guild.default_category_text": "Tekstkanaler",
"guild.default_category_voice": "Stemmekanaler",
"guild.default_channel_text": "generelt",
"guild.default_channel_voice": "Generelt"
"billing.eu_withdrawal_waiver_checkout": "Hvis jeg er en EU/EØS-forbruger, giver jeg udtrykkeligt samtykke til, at {product_name}s {premium_tier_name}-abonnement digitalt indhold leveres øjeblikkeligt, og jeg anerkender, at jeg mister min lovbestemte fortrydelsesret, når adgangen gives. Dette påvirker ikke andre ufravigelige forbrugerrettigheder. Se [servicevilkårene]({terms_url}).",
"bulk_message_deletion.complete": "Vi er færdige med at slette dine beskeder. Vi fjernede {message_count, plural, =0 {0 beskeder} one {# besked} other {# beskeder}} fra {channel_count, plural, =0 {0 steder} one {# sted} other {# steder}}.",
"content.virus_detected": "Den fil blev markeret som potentielt usikker og er blevet fjernet.",
"guild.bulk_create.emoji_limit": "Maksimalt antal emoji nået ({limit}).",
"guild.bulk_create.sticker_limit": "Maksimalt antal klistermærker nået ({limit}).",
"guild.bulk_create.unknown_error": "Ukendt fejl.",
"guild.default_category_text": "Tekstkanaler",
"guild.default_category_voice": "Stemmekanaler",
"guild.default_channel_text": "generelt",
"guild.default_channel_voice": "Generelt"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Als EU/EWR-Verbraucher stimmst du ausdrücklich zu, dass {product_name} {premium_tier_name} digitale Inhalte sofort bereitgestellt werden, und erkennst an, dass dein gesetzliches Widerrufsrecht erlischt, sobald der Zugriff gewährt wird. Dies berührt nicht andere zwingende Verbraucherrechte. Sieh dir die [Nutzungsbedingungen]({terms_url}) an.",
"bulk_message_deletion.complete": "Wir haben deine Nachrichten gelöscht. Entfernt wurden {message_count, plural, =0 {0 Nachrichten} one {# Nachricht} other {# Nachrichten}} aus {channel_count, plural, =0 {0 Orten} one {# Ort} other {# Orten}}.",
"content.virus_detected": "Diese Datei wurde als potenziell unsicher eingestuft und entfernt.",
"guild.bulk_create.emoji_limit": "Maximale Anzahl an Emojis erreicht ({limit}).",
"guild.bulk_create.sticker_limit": "Maximale Anzahl an Stickern erreicht ({limit}).",
"guild.bulk_create.unknown_error": "Unbekannter Fehler.",
"guild.default_category_text": "Textkanäle",
"guild.default_category_voice": "Sprachkanäle",
"guild.default_channel_text": "Allgemein",
"guild.default_channel_voice": "Allgemein"
"billing.eu_withdrawal_waiver_checkout": "Als EU/EWR-Verbraucher stimmst du ausdrücklich zu, dass {product_name} {premium_tier_name} digitale Inhalte sofort bereitgestellt werden, und erkennst an, dass dein gesetzliches Widerrufsrecht erlischt, sobald der Zugriff gewährt wird. Dies berührt nicht andere zwingende Verbraucherrechte. Sieh dir die [Nutzungsbedingungen]({terms_url}) an.",
"bulk_message_deletion.complete": "Wir haben deine Nachrichten gelöscht. Entfernt wurden {message_count, plural, =0 {0 Nachrichten} one {# Nachricht} other {# Nachrichten}} aus {channel_count, plural, =0 {0 Orten} one {# Ort} other {# Orten}}.",
"content.virus_detected": "Diese Datei wurde als potenziell unsicher eingestuft und entfernt.",
"guild.bulk_create.emoji_limit": "Maximale Anzahl an Emojis erreicht ({limit}).",
"guild.bulk_create.sticker_limit": "Maximale Anzahl an Stickern erreicht ({limit}).",
"guild.bulk_create.unknown_error": "Unbekannter Fehler.",
"guild.default_category_text": "Textkanäle",
"guild.default_category_voice": "Sprachkanäle",
"guild.default_channel_text": "Allgemein",
"guild.default_channel_voice": "Allgemein"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Αν είμαι καταναλωτής ΕΕ/ΕΟΧ, συναινώ ρητά στην άμεση παροχή του ψηφιακού περιεχομένου του πλάνου {premium_tier_name} του {product_name} και αναγνωρίζω ότι χάνω το νόμιμο δικαίωμα υπαναχώρησης μόλις παρασχεθεί η πρόσβαση. Αυτό δεν επηρεάζει άλλα υποχρεωτικά δικαιώματα των καταναλωτών. Ανατρέξτε στους [Όρους Παροχής Υπηρεσιών]({terms_url}).",
"bulk_message_deletion.complete": "Ολοκληρώσαμε τη διαγραφή των μηνυμάτων σου. Αφαιρέσαμε {message_count, plural, =0 {0 μηνύματα} one {# μήνυμα} other {# μηνύματα}} από {channel_count, plural, =0 {0 τοποθεσίες} one {# τοποθεσία} other {# τοποθεσίες}}.",
"content.virus_detected": "Αυτό το αρχείο επισημάνθηκε ως δυνητικά μη ασφαλές και έχει αφαιρεθεί.",
"guild.bulk_create.emoji_limit": "Επιτεύχθηκε το μέγιστο όριο emoji ({limit}).",
"guild.bulk_create.sticker_limit": "Έφτασε το μέγιστο όριο αυτοκόλλητων ({limit}).",
"guild.bulk_create.unknown_error": "Άγνωστο σφάλμα.",
"guild.default_category_text": "Κανάλια κειμένου",
"guild.default_category_voice": "Φωνητικά κανάλια",
"guild.default_channel_text": "γενικό",
"guild.default_channel_voice": "Γενικό"
"billing.eu_withdrawal_waiver_checkout": "Αν είμαι καταναλωτής ΕΕ/ΕΟΧ, συναινώ ρητά στην άμεση παροχή του ψηφιακού περιεχομένου του πλάνου {premium_tier_name} του {product_name} και αναγνωρίζω ότι χάνω το νόμιμο δικαίωμα υπαναχώρησης μόλις παρασχεθεί η πρόσβαση. Αυτό δεν επηρεάζει άλλα υποχρεωτικά δικαιώματα των καταναλωτών. Ανατρέξτε στους [Όρους Παροχής Υπηρεσιών]({terms_url}).",
"bulk_message_deletion.complete": "Ολοκληρώσαμε τη διαγραφή των μηνυμάτων σου. Αφαιρέσαμε {message_count, plural, =0 {0 μηνύματα} one {# μήνυμα} other {# μηνύματα}} από {channel_count, plural, =0 {0 τοποθεσίες} one {# τοποθεσία} other {# τοποθεσίες}}.",
"content.virus_detected": "Αυτό το αρχείο επισημάνθηκε ως δυνητικά μη ασφαλές και έχει αφαιρεθεί.",
"guild.bulk_create.emoji_limit": "Επιτεύχθηκε το μέγιστο όριο emoji ({limit}).",
"guild.bulk_create.sticker_limit": "Έφτασε το μέγιστο όριο αυτοκόλλητων ({limit}).",
"guild.bulk_create.unknown_error": "Άγνωστο σφάλμα.",
"guild.default_category_text": "Κανάλια κειμένου",
"guild.default_category_voice": "Φωνητικά κανάλια",
"guild.default_channel_text": "γενικό",
"guild.default_channel_voice": "Γενικό"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "If I am an EU/EEA consumer, I expressly consent to {product_name} {premium_tier_name} digital content being provided immediately and acknowledge that I lose my statutory withdrawal right once access is provided. This does not affect other mandatory consumer rights. See the [Terms of Service]({terms_url}).",
"bulk_message_deletion.complete": "We've finished deleting your messages. We removed {message_count, plural, =0 {0 messages} one {# message} other {# messages}} from {channel_count, plural, =0 {0 places} one {# place} other {# places}}.",
"content.virus_detected": "That file was flagged as potentially unsafe and has been removed.",
"guild.bulk_create.emoji_limit": "Maximum emojis reached ({limit}).",
"guild.bulk_create.sticker_limit": "Maximum stickers reached ({limit}).",
"guild.bulk_create.unknown_error": "Unknown error.",
"guild.default_category_text": "Text channels",
"guild.default_category_voice": "Voice channels",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
"billing.eu_withdrawal_waiver_checkout": "If I am an EU/EEA consumer, I expressly consent to {product_name} {premium_tier_name} digital content being provided immediately and acknowledge that I lose my statutory withdrawal right once access is provided. This does not affect other mandatory consumer rights. See the [Terms of Service]({terms_url}).",
"bulk_message_deletion.complete": "We've finished deleting your messages. We removed {message_count, plural, =0 {0 messages} one {# message} other {# messages}} from {channel_count, plural, =0 {0 places} one {# place} other {# places}}.",
"content.virus_detected": "That file was flagged as potentially unsafe and has been removed.",
"guild.bulk_create.emoji_limit": "Maximum emojis reached ({limit}).",
"guild.bulk_create.sticker_limit": "Maximum stickers reached ({limit}).",
"guild.bulk_create.unknown_error": "Unknown error.",
"guild.default_category_text": "Text channels",
"guild.default_category_voice": "Voice channels",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
"guild.bulk_create.unknown_error": "Error desconocido.",
"guild.default_category_text": "Canales de texto",
"guild.default_category_voice": "Canales de voz",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
"guild.bulk_create.unknown_error": "Error desconocido.",
"guild.default_category_text": "Canales de texto",
"guild.default_category_voice": "Canales de voz",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se conceda el acceso. Esto no afecta a otros derechos de consumo obligatorios. Consulta los [Términos del servicio]({terms_url}).",
"bulk_message_deletion.complete": "Hemos terminado de eliminar tus mensajes. Hemos eliminado {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 sitios} one {# sitio} other {# sitios}}.",
"content.virus_detected": "Ese archivo se ha marcado como potencialmente inseguro y se ha eliminado.",
"guild.bulk_create.emoji_limit": "Se ha alcanzado el límite máximo de emojis ({limit}).",
"guild.bulk_create.sticker_limit": "Se ha alcanzado el límite máximo de stickers ({limit}).",
"guild.bulk_create.unknown_error": "Error desconocido.",
"guild.default_category_text": "Canales de texto",
"guild.default_category_voice": "Canales de voz",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se conceda el acceso. Esto no afecta a otros derechos de consumo obligatorios. Consulta los [Términos del servicio]({terms_url}).",
"bulk_message_deletion.complete": "Hemos terminado de eliminar tus mensajes. Hemos eliminado {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 sitios} one {# sitio} other {# sitios}}.",
"content.virus_detected": "Ese archivo se ha marcado como potencialmente inseguro y se ha eliminado.",
"guild.bulk_create.emoji_limit": "Se ha alcanzado el límite máximo de emojis ({limit}).",
"guild.bulk_create.sticker_limit": "Se ha alcanzado el límite máximo de stickers ({limit}).",
"guild.bulk_create.unknown_error": "Error desconocido.",
"guild.default_category_text": "Canales de texto",
"guild.default_category_voice": "Canales de voz",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Jos olen EU/ETA-kuluttaja, annan nimenomaisen suostumukseni sille, että {product_name}-tuotteen {premium_tier_name} digitaalinen sisältö toimitetaan välittömästi, ja hyväksyn, että menetän lakisääteisen peruutusoikeuteni, kun pääsy on myönnetty. Tämä ei vaikuta muihin pakollisiin kuluttajaoikeuksiin. Katso [käyttöehdot]({terms_url}).",
"bulk_message_deletion.complete": "Olemme poistaneet viestisi. Poistimme {message_count, plural, =0 {0 viestiä} one {# viestin} other {# viestiä}} {channel_count, plural, =0 {0 paikasta} one {# paikasta} other {# paikasta}}.",
"content.virus_detected": "Tiedosto merkittiin mahdollisesti vaaralliseksi ja se on poistettu.",
"guild.bulk_create.emoji_limit": "Enimmäismäärä emoji-kuvakkeita on saavutettu ({limit}).",
"guild.bulk_create.sticker_limit": "Saavutettu enimmäismäärä tarroja ({limit}).",
"guild.bulk_create.unknown_error": "Tuntematon virhe.",
"guild.default_category_text": "Tekstikanavat",
"guild.default_category_voice": "Äänikanavat",
"guild.default_channel_text": "yleinen",
"guild.default_channel_voice": "Yleinen"
"billing.eu_withdrawal_waiver_checkout": "Jos olen EU/ETA-kuluttaja, annan nimenomaisen suostumukseni sille, että {product_name}-tuotteen {premium_tier_name} digitaalinen sisältö toimitetaan välittömästi, ja hyväksyn, että menetän lakisääteisen peruutusoikeuteni, kun pääsy on myönnetty. Tämä ei vaikuta muihin pakollisiin kuluttajaoikeuksiin. Katso [käyttöehdot]({terms_url}).",
"bulk_message_deletion.complete": "Olemme poistaneet viestisi. Poistimme {message_count, plural, =0 {0 viestiä} one {# viestin} other {# viestiä}} {channel_count, plural, =0 {0 paikasta} one {# paikasta} other {# paikasta}}.",
"content.virus_detected": "Tiedosto merkittiin mahdollisesti vaaralliseksi ja se on poistettu.",
"guild.bulk_create.emoji_limit": "Enimmäismäärä emoji-kuvakkeita on saavutettu ({limit}).",
"guild.bulk_create.sticker_limit": "Saavutettu enimmäismäärä tarroja ({limit}).",
"guild.bulk_create.unknown_error": "Tuntematon virhe.",
"guild.default_category_text": "Tekstikanavat",
"guild.default_category_voice": "Äänikanavat",
"guild.default_channel_text": "yleinen",
"guild.default_channel_voice": "Yleinen"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "En tant que consommateur UE/EEE, je consens expressément à ce que le contenu numérique {product_name} {premium_tier_name} soit fourni immédiatement et reconnais perdre mon droit de rétractation légal dès l'accès fourni. Cela n'affecte pas les autres droits impératifs des consommateurs. Voir les [Conditions d'utilisation]({terms_url}).",
"bulk_message_deletion.complete": "Nous avons terminé la suppression de tes messages. Nous avons retiré {message_count, plural, =0 {0 message} one {# message} other {# messages}} dans {channel_count, plural, =0 {0 endroit} one {# endroit} other {# endroits}}.",
"content.virus_detected": "Ce fichier a été signalé comme potentiellement dangereux et a été supprimé.",
"guild.bulk_create.emoji_limit": "Nombre maximal d’émojis atteint ({limit}).",
"guild.bulk_create.sticker_limit": "Nombre maximal d’autocollants atteint ({limit}).",
"guild.bulk_create.unknown_error": "Erreur inconnue.",
"guild.default_category_text": "Salons textuels",
"guild.default_category_voice": "Salons vocaux",
"guild.default_channel_text": "général",
"guild.default_channel_voice": "Général"
"billing.eu_withdrawal_waiver_checkout": "En tant que consommateur UE/EEE, je consens expressément à ce que le contenu numérique {product_name} {premium_tier_name} soit fourni immédiatement et reconnais perdre mon droit de rétractation légal dès l'accès fourni. Cela n'affecte pas les autres droits impératifs des consommateurs. Voir les [Conditions d'utilisation]({terms_url}).",
"bulk_message_deletion.complete": "Nous avons terminé la suppression de tes messages. Nous avons retiré {message_count, plural, =0 {0 message} one {# message} other {# messages}} dans {channel_count, plural, =0 {0 endroit} one {# endroit} other {# endroits}}.",
"content.virus_detected": "Ce fichier a été signalé comme potentiellement dangereux et a été supprimé.",
"guild.bulk_create.emoji_limit": "Nombre maximal d’émojis atteint ({limit}).",
"guild.bulk_create.sticker_limit": "Nombre maximal d’autocollants atteint ({limit}).",
"guild.bulk_create.unknown_error": "Erreur inconnue.",
"guild.default_category_text": "Salons textuels",
"guild.default_category_voice": "Salons vocaux",
"guild.default_channel_text": "général",
"guild.default_channel_voice": "Général"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "אם אני צרכן באיחוד האירופי/EEA, אני מסכים במפורש לכך שתוכן דיגיטלי של {product_name} {premium_tier_name} יסופק באופן מיידי ומאשר שאני מאבד את זכות הביטול הסטטוטורית שלי מרגע שהגישה ניתנת. אין בכך כדי לפגוע בזכויות צרכניות מחייבות אחרות. ראו [תנאי השירות]({terms_url}).",
"bulk_message_deletion.complete": "סיימנו למחוק את ההודעות שלך. הסרנו {message_count, plural, =0 {0 הודעות} one {הודעה אחת} two {שתי הודעות} other {# הודעות}} מתוך {channel_count, plural, =0 {0 מקומות} one {מקום אחד} two {שני מקומות} other {# מקומות}}.",
"content.virus_detected": "הקובץ הזה סומן כחשוד והוסר.",
"guild.bulk_create.emoji_limit": "הגעת למספר האמוג'י המקסימלי ({limit}).",
"guild.bulk_create.sticker_limit": "הגעת למספר המדבקות המקסימלי ({limit}).",
"guild.bulk_create.unknown_error": "שגיאה לא ידועה.",
"guild.default_category_text": "ערוצי טקסט",
"guild.default_category_voice": "ערוצים קוליים",
"guild.default_channel_text": "כללי",
"guild.default_channel_voice": "כללי"
"billing.eu_withdrawal_waiver_checkout": "אם אני צרכן באיחוד האירופי/EEA, אני מסכים במפורש לכך שתוכן דיגיטלי של {product_name} {premium_tier_name} יסופק באופן מיידי ומאשר שאני מאבד את זכות הביטול הסטטוטורית שלי מרגע שהגישה ניתנת. אין בכך כדי לפגוע בזכויות צרכניות מחייבות אחרות. ראו [תנאי השירות]({terms_url}).",
"bulk_message_deletion.complete": "סיימנו למחוק את ההודעות שלך. הסרנו {message_count, plural, =0 {0 הודעות} one {הודעה אחת} two {שתי הודעות} other {# הודעות}} מתוך {channel_count, plural, =0 {0 מקומות} one {מקום אחד} two {שני מקומות} other {# מקומות}}.",
"content.virus_detected": "הקובץ הזה סומן כחשוד והוסר.",
"guild.bulk_create.emoji_limit": "הגעת למספר האמוג'י המקסימלי ({limit}).",
"guild.bulk_create.sticker_limit": "הגעת למספר המדבקות המקסימלי ({limit}).",
"guild.bulk_create.unknown_error": "שגיאה לא ידועה.",
"guild.default_category_text": "ערוצי טקסט",
"guild.default_category_voice": "ערוצים קוליים",
"guild.default_channel_text": "כללי",
"guild.default_channel_voice": "כללי"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "अगर मैं EU/EEA का उपभोक्ता हूँ, तो मैं {product_name} {premium_tier_name} डिजिटल कंटेंट को तुरंत उपलब्ध कराने के लिए स्पष्ट रूप से सहमति देता हूँ और स्वीकार करता हूँ कि एक्सेस मिलने के बाद मेरा वैधानिक वापसी का अधिकार समाप्त हो जाता है। इससे अन्य अनिवार्य उपभोक्ता अधिकार प्रभावित नहीं होते। [सेवा की शर्तें]({terms_url}) देखें।",
"bulk_message_deletion.complete": "हमने आपके संदेश हटाने का काम पूरा कर लिया है। हमने {channel_count, plural, =0 {0 जगहों से} one {# जगह से} other {# जगहों से}} {message_count, plural, =0 {0 संदेश} one {# संदेश} other {# संदेश}} हटा दिए।",
"content.virus_detected": "वह फाइल संभावित रूप से असुरक्षित पाई गई और उसे हटा दिया गया है।",
"guild.bulk_create.emoji_limit": "अधिकतम इमोजी की सीमा पहुँच गई ({limit})।",
"guild.bulk_create.sticker_limit": "अधिकतम स्टिकर की संख्या पूरी हो गई ({limit})।",
"guild.bulk_create.unknown_error": "अज्ञात त्रुटि।",
"guild.default_category_text": "टेक्स्ट चैनल",
"guild.default_category_voice": "वॉइस चैनल",
"guild.default_channel_text": "जनरल",
"guild.default_channel_voice": "जनरल"
"billing.eu_withdrawal_waiver_checkout": "अगर मैं EU/EEA का उपभोक्ता हूँ, तो मैं {product_name} {premium_tier_name} डिजिटल कंटेंट को तुरंत उपलब्ध कराने के लिए स्पष्ट रूप से सहमति देता हूँ और स्वीकार करता हूँ कि एक्सेस मिलने के बाद मेरा वैधानिक वापसी का अधिकार समाप्त हो जाता है। इससे अन्य अनिवार्य उपभोक्ता अधिकार प्रभावित नहीं होते। [सेवा की शर्तें]({terms_url}) देखें।",
"bulk_message_deletion.complete": "हमने आपके संदेश हटाने का काम पूरा कर लिया है। हमने {channel_count, plural, =0 {0 जगहों से} one {# जगह से} other {# जगहों से}} {message_count, plural, =0 {0 संदेश} one {# संदेश} other {# संदेश}} हटा दिए।",
"content.virus_detected": "वह फाइल संभावित रूप से असुरक्षित पाई गई और उसे हटा दिया गया है।",
"guild.bulk_create.emoji_limit": "अधिकतम इमोजी की सीमा पहुँच गई ({limit})।",
"guild.bulk_create.sticker_limit": "अधिकतम स्टिकर की संख्या पूरी हो गई ({limit})।",
"guild.bulk_create.unknown_error": "अज्ञात त्रुटि।",
"guild.default_category_text": "टेक्स्ट चैनल",
"guild.default_category_voice": "वॉइस चैनल",
"guild.default_channel_text": "जनरल",
"guild.default_channel_voice": "जनरल"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Ako si potrošač iz EU/EEA, izričito pristaješ na trenutačnu isporuku digitalnog sadržaja {product_name} pretplate {premium_tier_name} i prihvaćaš da gubiš zakonsko pravo na odustanak čim ti se omogući pristup. To ne utječe na ostala obvezna potrošačka prava. Pogledaj [Uvjete pružanja usluge]({terms_url}).",
"bulk_message_deletion.complete": "Završili smo s brisanjem tvojih poruka. Uklonili smo {message_count, plural, =0 {0 poruka} one {# poruku} few {# poruke} other {# poruka}} iz {channel_count, plural, =0 {0 mjesta} one {# mjesta} few {# mjesta} other {# mjesta}}.",
"content.virus_detected": "Ta je datoteka označena kao potencijalno nesigurna i uklonjena je.",
"guild.bulk_create.emoji_limit": "Dostignut maksimalan broj emojija ({limit}).",
"guild.bulk_create.sticker_limit": "Dostignut maksimalan broj naljepnica ({limit}).",
"guild.bulk_create.unknown_error": "Nešto je pošlo po zlu.",
"guild.default_category_text": "Tekstualni kanali",
"guild.default_category_voice": "Glasovni kanali",
"guild.default_channel_text": "općenito",
"guild.default_channel_voice": "Općenito"
"billing.eu_withdrawal_waiver_checkout": "Ako si potrošač iz EU/EEA, izričito pristaješ na trenutačnu isporuku digitalnog sadržaja {product_name} pretplate {premium_tier_name} i prihvaćaš da gubiš zakonsko pravo na odustanak čim ti se omogući pristup. To ne utječe na ostala obvezna potrošačka prava. Pogledaj [Uvjete pružanja usluge]({terms_url}).",
"bulk_message_deletion.complete": "Završili smo s brisanjem tvojih poruka. Uklonili smo {message_count, plural, =0 {0 poruka} one {# poruku} few {# poruke} other {# poruka}} iz {channel_count, plural, =0 {0 mjesta} one {# mjesta} few {# mjesta} other {# mjesta}}.",
"content.virus_detected": "Ta je datoteka označena kao potencijalno nesigurna i uklonjena je.",
"guild.bulk_create.emoji_limit": "Dostignut maksimalan broj emojija ({limit}).",
"guild.bulk_create.sticker_limit": "Dostignut maksimalan broj naljepnica ({limit}).",
"guild.bulk_create.unknown_error": "Nešto je pošlo po zlu.",
"guild.default_category_text": "Tekstualni kanali",
"guild.default_category_voice": "Glasovni kanali",
"guild.default_channel_text": "općenito",
"guild.default_channel_voice": "Općenito"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Ha EU/EGT-fogyasztó vagyok, kifejezetten hozzájárulok ahhoz, hogy a {product_name} {premium_tier_name} digitális tartalmat azonnal biztosítsák, és tudomásul veszem, hogy a hozzáférés megadásával elveszítem a törvényes elállási jogomat. Ez nem érinti a többi kötelező fogyasztói jogot. Lásd a [Felhasználási feltételeket]({terms_url}).",
"bulk_message_deletion.complete": "Befejeztük az üzeneteid törlését. Eltávolítottunk {message_count, plural, =0 {0 üzenetet} one {# üzenetet} other {# üzenetet}} {channel_count, plural, =0 {0 helyről} one {# helyről} other {# helyről}}.",
"content.virus_detected": "A fájl potenciálisan veszélyesnek minősült, ezért eltávolítottuk.",
"guild.bulk_create.emoji_limit": "A maximális emoji szám elérve ({limit}).",
"guild.bulk_create.sticker_limit": "A maximális matricák száma elérve ({limit}).",
"guild.bulk_create.unknown_error": "Ismeretlen hiba.",
"guild.default_category_text": "Szöveges Csatornák",
"guild.default_category_voice": "Hangcsatornák",
"guild.default_channel_text": "általános",
"guild.default_channel_voice": "Általános"
"billing.eu_withdrawal_waiver_checkout": "Ha EU/EGT-fogyasztó vagyok, kifejezetten hozzájárulok ahhoz, hogy a {product_name} {premium_tier_name} digitális tartalmat azonnal biztosítsák, és tudomásul veszem, hogy a hozzáférés megadásával elveszítem a törvényes elállási jogomat. Ez nem érinti a többi kötelező fogyasztói jogot. Lásd a [Felhasználási feltételeket]({terms_url}).",
"bulk_message_deletion.complete": "Befejeztük az üzeneteid törlését. Eltávolítottunk {message_count, plural, =0 {0 üzenetet} one {# üzenetet} other {# üzenetet}} {channel_count, plural, =0 {0 helyről} one {# helyről} other {# helyről}}.",
"content.virus_detected": "A fájl potenciálisan veszélyesnek minősült, ezért eltávolítottuk.",
"guild.bulk_create.emoji_limit": "A maximális emoji szám elérve ({limit}).",
"guild.bulk_create.sticker_limit": "A maximális matricák száma elérve ({limit}).",
"guild.bulk_create.unknown_error": "Ismeretlen hiba.",
"guild.default_category_text": "Szöveges Csatornák",
"guild.default_category_voice": "Hangcsatornák",
"guild.default_channel_text": "általános",
"guild.default_channel_voice": "Általános"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Jika saya konsumen EU/EEA, saya setuju konten digital {product_name} paket {premium_tier_name} diberikan segera dan mengakui bahwa saya kehilangan hak pembatalan begitu akses diberikan. Ini tidak memengaruhi hak konsumen wajib lainnya. Lihat [Ketentuan Layanan]({terms_url}).",
"bulk_message_deletion.complete": "Kami sudah selesai menghapus pesanmu. Kami menghapus {message_count, plural, =0 {0 pesan} other {# pesan}} dari {channel_count, plural, =0 {0 tempat} other {# tempat}}.",
"content.virus_detected": "File itu ditandai sebagai tidak aman dan berbahaya dan sudah dihapus.",
"guild.bulk_create.emoji_limit": "Maksimal emoji telah mencapai ({limit}).",
"guild.bulk_create.sticker_limit": "Maksimal stiker sudah mencapai ({limit}).",
"guild.bulk_create.unknown_error": "Kesalahan tidak diketahui.",
"guild.default_category_text": "Saluran teks",
"guild.default_category_voice": "Saluran suara",
"guild.default_channel_text": "umum",
"guild.default_channel_voice": "Umum"
"billing.eu_withdrawal_waiver_checkout": "Jika saya konsumen EU/EEA, saya setuju konten digital {product_name} paket {premium_tier_name} diberikan segera dan mengakui bahwa saya kehilangan hak pembatalan begitu akses diberikan. Ini tidak memengaruhi hak konsumen wajib lainnya. Lihat [Ketentuan Layanan]({terms_url}).",
"bulk_message_deletion.complete": "Kami sudah selesai menghapus pesanmu. Kami menghapus {message_count, plural, =0 {0 pesan} other {# pesan}} dari {channel_count, plural, =0 {0 tempat} other {# tempat}}.",
"content.virus_detected": "File itu ditandai sebagai tidak aman dan berbahaya dan sudah dihapus.",
"guild.bulk_create.emoji_limit": "Maksimal emoji telah mencapai ({limit}).",
"guild.bulk_create.sticker_limit": "Maksimal stiker sudah mencapai ({limit}).",
"guild.bulk_create.unknown_error": "Kesalahan tidak diketahui.",
"guild.default_category_text": "Saluran teks",
"guild.default_category_voice": "Saluran suara",
"guild.default_channel_text": "umum",
"guild.default_channel_voice": "Umum"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Se sono un consumatore UE/SEE, acconsento espressamente alla fornitura immediata del contenuto digitale {product_name} {premium_tier_name} e riconosco di perdere il mio diritto di recesso legale una volta che l'accesso viene fornito. Ciò non pregiudica altri diritti dei consumatori previsti dalla legge. Vedi i [Termini di servizio]({terms_url}).",
"bulk_message_deletion.complete": "Abbiamo finito di eliminare i tuoi messaggi. Abbiamo rimosso {message_count, plural, =0 {0 messaggi} one {# messaggio} other {# messaggi}} da {channel_count, plural, =0 {0 posti} one {# posto} other {# posti}}.",
"content.virus_detected": "Il file è stato segnalato come potenzialmente pericoloso ed è stato rimosso.",
"guild.bulk_create.emoji_limit": "Raggiunto il numero massimo di emoji ({limit}).",
"guild.bulk_create.sticker_limit": "Raggiunto il numero massimo di sticker ({limit}).",
"guild.bulk_create.unknown_error": "Si è verificato un errore sconosciuto.",
"guild.default_category_text": "Canali testuali",
"guild.default_category_voice": "Canali vocali",
"guild.default_channel_text": "generale",
"guild.default_channel_voice": "Generale"
"billing.eu_withdrawal_waiver_checkout": "Se sono un consumatore UE/SEE, acconsento espressamente alla fornitura immediata del contenuto digitale {product_name} {premium_tier_name} e riconosco di perdere il mio diritto di recesso legale una volta che l'accesso viene fornito. Ciò non pregiudica altri diritti dei consumatori previsti dalla legge. Vedi i [Termini di servizio]({terms_url}).",
"bulk_message_deletion.complete": "Abbiamo finito di eliminare i tuoi messaggi. Abbiamo rimosso {message_count, plural, =0 {0 messaggi} one {# messaggio} other {# messaggi}} da {channel_count, plural, =0 {0 posti} one {# posto} other {# posti}}.",
"content.virus_detected": "Il file è stato segnalato come potenzialmente pericoloso ed è stato rimosso.",
"guild.bulk_create.emoji_limit": "Raggiunto il numero massimo di emoji ({limit}).",
"guild.bulk_create.sticker_limit": "Raggiunto il numero massimo di sticker ({limit}).",
"guild.bulk_create.unknown_error": "Si è verificato un errore sconosciuto.",
"guild.default_category_text": "Canali testuali",
"guild.default_category_voice": "Canali vocali",
"guild.default_channel_text": "generale",
"guild.default_channel_voice": "Generale"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "私が EU/EEA の消費者である場合、{product_name} {premium_tier_name} のデジタルコンテンツが直ちに提供されることに明示的に同意し、アクセスが提供された時点で法定のクーリングオフ権を失うことを承諾します。これはその他の強制的な消費者権利に影響を与えません。[利用規約]({terms_url})をご確認ください。",
"bulk_message_deletion.complete": "メッセージの削除が完了しました。{channel_count, plural, =0 {0 件の場所から} other {# 件の場所から}}{message_count, plural, =0 {0 件のメッセージを削除しました} other {# 件のメッセージを削除しました}}。",
"content.virus_detected": "そのファイルは安全でないと判断されたため、削除されました。",
"guild.bulk_create.emoji_limit": "最大絵文字数に達しました({limit})。",
"guild.bulk_create.sticker_limit": "ステッカーの最大数に達しました ({limit})。",
"guild.bulk_create.unknown_error": "不明なエラーが発生しました。",
"guild.default_category_text": "テキストチャンネル",
"guild.default_category_voice": "ボイスチャンネル",
"guild.default_channel_text": "一般",
"guild.default_channel_voice": "一般"
"billing.eu_withdrawal_waiver_checkout": "私が EU/EEA の消費者である場合、{product_name} {premium_tier_name} のデジタルコンテンツが直ちに提供されることに明示的に同意し、アクセスが提供された時点で法定のクーリングオフ権を失うことを承諾します。これはその他の強制的な消費者権利に影響を与えません。[利用規約]({terms_url})をご確認ください。",
"bulk_message_deletion.complete": "メッセージの削除が完了しました。{channel_count, plural, =0 {0 件の場所から} other {# 件の場所から}}{message_count, plural, =0 {0 件のメッセージを削除しました} other {# 件のメッセージを削除しました}}。",
"content.virus_detected": "そのファイルは安全でないと判断されたため、削除されました。",
"guild.bulk_create.emoji_limit": "最大絵文字数に達しました({limit})。",
"guild.bulk_create.sticker_limit": "ステッカーの最大数に達しました ({limit})。",
"guild.bulk_create.unknown_error": "不明なエラーが発生しました。",
"guild.default_category_text": "テキストチャンネル",
"guild.default_category_voice": "ボイスチャンネル",
"guild.default_channel_text": "一般",
"guild.default_channel_voice": "一般"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "EU/EEA 소비자로서 {product_name} {premium_tier_name} 디지털 콘텐츠를 즉시 제공받는 데 명시적으로 동의하며, 액세스가 제공된 후에는 법정 철회권을 상실함을 인정합니다. 이는 다른 필수 소비자 권리에 영향을 미치지 않습니다. [서비스 약관]({terms_url})을 확인하세요.",
"bulk_message_deletion.complete": "메시지 삭제를 완료했습니다. {channel_count, plural, =0 {0 곳에서} other {# 곳에서}} {message_count, plural, =0 {0 개의 메시지를 삭제했습니다} other {# 개의 메시지를 삭제했습니다}}.",
"content.virus_detected": "해당 파일은 잠재적으로 안전하지 않은 것으로 확인되어 삭제되었어요.",
"guild.bulk_create.emoji_limit": "이모지 최대 개수에 도달했습니다({limit}).",
"guild.bulk_create.sticker_limit": "최대 스티커 개수에 도달했습니다({limit}).",
"guild.bulk_create.unknown_error": "알 수 없는 오류가 발생했습니다.",
"guild.default_category_text": "텍스트 채널",
"guild.default_category_voice": "음성 채널",
"guild.default_channel_text": "일반",
"guild.default_channel_voice": "일반"
"billing.eu_withdrawal_waiver_checkout": "EU/EEA 소비자로서 {product_name} {premium_tier_name} 디지털 콘텐츠를 즉시 제공받는 데 명시적으로 동의하며, 액세스가 제공된 후에는 법정 철회권을 상실함을 인정합니다. 이는 다른 필수 소비자 권리에 영향을 미치지 않습니다. [서비스 약관]({terms_url})을 확인하세요.",
"bulk_message_deletion.complete": "메시지 삭제를 완료했습니다. {channel_count, plural, =0 {0 곳에서} other {# 곳에서}} {message_count, plural, =0 {0 개의 메시지를 삭제했습니다} other {# 개의 메시지를 삭제했습니다}}.",
"content.virus_detected": "해당 파일은 잠재적으로 안전하지 않은 것으로 확인되어 삭제되었어요.",
"guild.bulk_create.emoji_limit": "이모지 최대 개수에 도달했습니다({limit}).",
"guild.bulk_create.sticker_limit": "최대 스티커 개수에 도달했습니다({limit}).",
"guild.bulk_create.unknown_error": "알 수 없는 오류가 발생했습니다.",
"guild.default_category_text": "텍스트 채널",
"guild.default_category_voice": "음성 채널",
"guild.default_channel_text": "일반",
"guild.default_channel_voice": "일반"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Jei esu ES/EEE vartotojas, aiškiai sutinku, kad {product_name} {premium_tier_name} skaitmeninis turinys būtų suteiktas nedelsiant ir pripažįstu, kad, suteikus prieigą, netenku teisės atsisakyti sutarties. Tai neturi įtakos kitoms privalomoms vartotojų teisėms. Žr. [Paslaugų teikimo sąlygas]({terms_url}).",
"bulk_message_deletion.complete": "Baigėme trinti tavo žinutes. Pašalinome {message_count, plural, =0 {0 žinučių} one {# žinutę} few {# žinutes} many {# žinutės} other {# žinučių}} iš {channel_count, plural, =0 {0 vietų} one {# vietos} few {# vietų} many {# vietos} other {# vietų}}.",
"content.virus_detected": "Failas buvo pažymėtas kaip galimai nesaugus ir pašalintas.",
"guild.bulk_create.emoji_limit": "Pasiektas didžiausias leistinų emoji skaičius ({limit}).",
"guild.bulk_create.sticker_limit": "Pasiektas maksimalus lipdukų skaičius ({limit}).",
"guild.bulk_create.unknown_error": "Nežinoma klaida.",
"guild.default_category_text": "Teksto kanalai",
"guild.default_category_voice": "Balso kanalai",
"guild.default_channel_text": "bendras",
"guild.default_channel_voice": "Bendras"
"billing.eu_withdrawal_waiver_checkout": "Jei esu ES/EEE vartotojas, aiškiai sutinku, kad {product_name} {premium_tier_name} skaitmeninis turinys būtų suteiktas nedelsiant ir pripažįstu, kad, suteikus prieigą, netenku teisės atsisakyti sutarties. Tai neturi įtakos kitoms privalomoms vartotojų teisėms. Žr. [Paslaugų teikimo sąlygas]({terms_url}).",
"bulk_message_deletion.complete": "Baigėme trinti tavo žinutes. Pašalinome {message_count, plural, =0 {0 žinučių} one {# žinutę} few {# žinutes} many {# žinutės} other {# žinučių}} iš {channel_count, plural, =0 {0 vietų} one {# vietos} few {# vietų} many {# vietos} other {# vietų}}.",
"content.virus_detected": "Failas buvo pažymėtas kaip galimai nesaugus ir pašalintas.",
"guild.bulk_create.emoji_limit": "Pasiektas didžiausias leistinų emoji skaičius ({limit}).",
"guild.bulk_create.sticker_limit": "Pasiektas maksimalus lipdukų skaičius ({limit}).",
"guild.bulk_create.unknown_error": "Nežinoma klaida.",
"guild.default_category_text": "Teksto kanalai",
"guild.default_category_voice": "Balso kanalai",
"guild.default_channel_text": "bendras",
"guild.default_channel_voice": "Bendras"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Als ik een EU/EER-consument ben, geef ik uitdrukkelijk toestemming dat de digitale inhoud van {product_name} {premium_tier_name} onmiddellijk wordt geleverd en erken ik dat ik mijn wettelijke herroepingsrecht verlies zodra toegang wordt verleend. Dit heeft geen invloed op andere dwingende consumentenrechten. Zie de [Servicevoorwaarden]({terms_url}).",
"bulk_message_deletion.complete": "We zijn klaar met het verwijderen van je berichten. We hebben {message_count, plural, =0 {0 berichten} one {# bericht} other {# berichten}} verwijderd uit {channel_count, plural, =0 {0 plekken} one {# plek} other {# plekken}}.",
"content.virus_detected": "Dit bestand is gemarkeerd als mogelijk onveilig en is verwijderd.",
"guild.bulk_create.emoji_limit": "Maximaal aantal emoji bereikt ({limit}).",
"guild.bulk_create.sticker_limit": "Maximaal aantal stickers bereikt ({limit}).",
"guild.bulk_create.unknown_error": "Onbekende fout.",
"guild.default_category_text": "Tekstkanalen",
"guild.default_category_voice": "Spraakkanalen",
"guild.default_channel_text": "algemeen",
"guild.default_channel_voice": "Algemeen"
"billing.eu_withdrawal_waiver_checkout": "Als ik een EU/EER-consument ben, geef ik uitdrukkelijk toestemming dat de digitale inhoud van {product_name} {premium_tier_name} onmiddellijk wordt geleverd en erken ik dat ik mijn wettelijke herroepingsrecht verlies zodra toegang wordt verleend. Dit heeft geen invloed op andere dwingende consumentenrechten. Zie de [Servicevoorwaarden]({terms_url}).",
"bulk_message_deletion.complete": "We zijn klaar met het verwijderen van je berichten. We hebben {message_count, plural, =0 {0 berichten} one {# bericht} other {# berichten}} verwijderd uit {channel_count, plural, =0 {0 plekken} one {# plek} other {# plekken}}.",
"content.virus_detected": "Dit bestand is gemarkeerd als mogelijk onveilig en is verwijderd.",
"guild.bulk_create.emoji_limit": "Maximaal aantal emoji bereikt ({limit}).",
"guild.bulk_create.sticker_limit": "Maximaal aantal stickers bereikt ({limit}).",
"guild.bulk_create.unknown_error": "Onbekende fout.",
"guild.default_category_text": "Tekstkanalen",
"guild.default_category_voice": "Spraakkanalen",
"guild.default_channel_text": "algemeen",
"guild.default_channel_voice": "Algemeen"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Hvis jeg er en EU/EØS-forbruker, samtykker jeg uttrykkelig til at {product_name} {premium_tier_name} digitalt innhold leveres umiddelbart, og erkjenner at jeg mister min lovbestemte angrerett når tilgangen er gitt. Dette påvirker ikke andre lovpålagte forbrukerrettigheter. Se [tjenestevilkårene]({terms_url}).",
"bulk_message_deletion.complete": "Vi er ferdige med å slette meldingene dine. Vi fjernet {message_count, plural, =0 {0 meldinger} one {# melding} other {# meldinger}} fra {channel_count, plural, =0 {0 steder} one {# sted} other {# steder}}.",
"content.virus_detected": "Filen ble flagget som potensielt utrygg og er fjernet.",
"guild.bulk_create.emoji_limit": "Maksimalt antall emojier nådd ({limit}).",
"guild.bulk_create.sticker_limit": "Maksimalt antall klistremerker nådd ({limit}).",
"guild.bulk_create.unknown_error": "Ukjent feil.",
"guild.default_category_text": "Tekstkanaler",
"guild.default_category_voice": "Talekanaler",
"guild.default_channel_text": "generelt",
"guild.default_channel_voice": "Generelt"
"billing.eu_withdrawal_waiver_checkout": "Hvis jeg er en EU/EØS-forbruker, samtykker jeg uttrykkelig til at {product_name} {premium_tier_name} digitalt innhold leveres umiddelbart, og erkjenner at jeg mister min lovbestemte angrerett når tilgangen er gitt. Dette påvirker ikke andre lovpålagte forbrukerrettigheter. Se [tjenestevilkårene]({terms_url}).",
"bulk_message_deletion.complete": "Vi er ferdige med å slette meldingene dine. Vi fjernet {message_count, plural, =0 {0 meldinger} one {# melding} other {# meldinger}} fra {channel_count, plural, =0 {0 steder} one {# sted} other {# steder}}.",
"content.virus_detected": "Filen ble flagget som potensielt utrygg og er fjernet.",
"guild.bulk_create.emoji_limit": "Maksimalt antall emojier nådd ({limit}).",
"guild.bulk_create.sticker_limit": "Maksimalt antall klistremerker nådd ({limit}).",
"guild.bulk_create.unknown_error": "Ukjent feil.",
"guild.default_category_text": "Tekstkanaler",
"guild.default_category_voice": "Talekanaler",
"guild.default_channel_text": "generelt",
"guild.default_channel_voice": "Generelt"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Jako konsument z UE/EOG zgadzam się na natychmiastowe dostarczenie treści cyfrowych {product_name} {premium_tier_name} i przyjmuję do wiadomości, że po uzyskaniu dostępu tracę ustawowe prawo do odstąpienia od umowy. Nie wpływa to na inne obowiązkowe prawa konsumenta. Zobacz [Warunki użytkowania]({terms_url}).",
"bulk_message_deletion.complete": "Zakończyliśmy usuwanie Twoich wiadomości. Usunęliśmy {message_count, plural, =0 {0 wiadomości} one {# wiadomość} few {# wiadomości} many {# wiadomości} other {# wiadomości}} z {channel_count, plural, =0 {0 miejsc} one {# miejsca} few {# miejsc} many {# miejsc} other {# miejsc}}.",
"content.virus_detected": "Ten plik został oznaczony jako potencjalnie niebezpieczny i został usunięty.",
"guild.bulk_create.emoji_limit": "Osiągnięto maksymalną liczbę emoji ({limit}).",
"guild.bulk_create.sticker_limit": "Osiągnięto maksymalną liczbę naklejek ({limit}).",
"guild.bulk_create.unknown_error": "Wystąpił nieznany błąd.",
"guild.default_category_text": "Kanały tekstowe",
"guild.default_category_voice": "Kanały głosowe",
"guild.default_channel_text": "Ogólny",
"guild.default_channel_voice": "Ogólny"
"billing.eu_withdrawal_waiver_checkout": "Jako konsument z UE/EOG zgadzam się na natychmiastowe dostarczenie treści cyfrowych {product_name} {premium_tier_name} i przyjmuję do wiadomości, że po uzyskaniu dostępu tracę ustawowe prawo do odstąpienia od umowy. Nie wpływa to na inne obowiązkowe prawa konsumenta. Zobacz [Warunki użytkowania]({terms_url}).",
"bulk_message_deletion.complete": "Zakończyliśmy usuwanie Twoich wiadomości. Usunęliśmy {message_count, plural, =0 {0 wiadomości} one {# wiadomość} few {# wiadomości} many {# wiadomości} other {# wiadomości}} z {channel_count, plural, =0 {0 miejsc} one {# miejsca} few {# miejsc} many {# miejsc} other {# miejsc}}.",
"content.virus_detected": "Ten plik został oznaczony jako potencjalnie niebezpieczny i został usunięty.",
"guild.bulk_create.emoji_limit": "Osiągnięto maksymalną liczbę emoji ({limit}).",
"guild.bulk_create.sticker_limit": "Osiągnięto maksymalną liczbę naklejek ({limit}).",
"guild.bulk_create.unknown_error": "Wystąpił nieznany błąd.",
"guild.default_category_text": "Kanały tekstowe",
"guild.default_category_voice": "Kanały głosowe",
"guild.default_channel_text": "Ogólny",
"guild.default_channel_voice": "Ogólny"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Se eu for um consumidor da UE/EEE, consinto expressamente que o conteúdo digital {product_name} {premium_tier_name} seja fornecido imediatamente e reconheço que perco meu direito legal de arrependimento assim que o acesso for concedido. Isso não afeta outros direitos obrigatórios do consumidor. Consulte os [Termos de Serviço]({terms_url}).",
"bulk_message_deletion.complete": "Terminamos de excluir suas mensagens. Removemos {message_count, plural, =0 {0 mensagens} one {# mensagem} other {# mensagens}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
"content.virus_detected": "Esse arquivo foi sinalizado como potencialmente inseguro e foi removido.",
"guild.bulk_create.emoji_limit": "Número máximo de emojis atingido ({limit}).",
"guild.bulk_create.sticker_limit": "Número máximo de adesivos atingido ({limit}).",
"guild.bulk_create.unknown_error": "Erro desconhecido.",
"guild.default_category_text": "Canais de texto",
"guild.default_category_voice": "Canais de voz",
"guild.default_channel_text": "geral",
"guild.default_channel_voice": "Geral"
"billing.eu_withdrawal_waiver_checkout": "Se eu for um consumidor da UE/EEE, consinto expressamente que o conteúdo digital {product_name} {premium_tier_name} seja fornecido imediatamente e reconheço que perco meu direito legal de arrependimento assim que o acesso for concedido. Isso não afeta outros direitos obrigatórios do consumidor. Consulte os [Termos de Serviço]({terms_url}).",
"bulk_message_deletion.complete": "Terminamos de excluir suas mensagens. Removemos {message_count, plural, =0 {0 mensagens} one {# mensagem} other {# mensagens}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
"content.virus_detected": "Esse arquivo foi sinalizado como potencialmente inseguro e foi removido.",
"guild.bulk_create.emoji_limit": "Número máximo de emojis atingido ({limit}).",
"guild.bulk_create.sticker_limit": "Número máximo de adesivos atingido ({limit}).",
"guild.bulk_create.unknown_error": "Erro desconhecido.",
"guild.default_category_text": "Canais de texto",
"guild.default_category_voice": "Canais de voz",
"guild.default_channel_text": "geral",
"guild.default_channel_voice": "Geral"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Dacă sunt un consumator din UE/SEE, îmi exprim consimțământul expres ca conținutul digital {product_name} {premium_tier_name} să fie furnizat imediat și recunosc că pierd dreptul legal de retragere odată ce accesul este acordat. Acest lucru nu afectează alte drepturi obligatorii ale consumatorilor. Vezi [Termenii și condițiile]({terms_url}).",
"bulk_message_deletion.complete": "Am terminat de șters mesajele tale. Am eliminat {message_count, plural, =0 {0 de mesaje} one {# mesaj} few {# mesaje} other {# de mesaje}} din {channel_count, plural, =0 {0 de locuri} one {# loc} few {# locuri} other {# de locuri}}.",
"content.virus_detected": "Acest fișier a fost marcat ca potențial nesigur și a fost eliminat.",
"guild.bulk_create.emoji_limit": "S-a atins limita maximă de emojiuri ({limit}).",
"guild.bulk_create.sticker_limit": "Numărul maxim de stickere a fost atins ({limit}).",
"guild.bulk_create.unknown_error": "A apărut o eroare necunoscută.",
"guild.default_category_text": "Canale text",
"guild.default_category_voice": "Canale de voce",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
"billing.eu_withdrawal_waiver_checkout": "Dacă sunt un consumator din UE/SEE, îmi exprim consimțământul expres ca conținutul digital {product_name} {premium_tier_name} să fie furnizat imediat și recunosc că pierd dreptul legal de retragere odată ce accesul este acordat. Acest lucru nu afectează alte drepturi obligatorii ale consumatorilor. Vezi [Termenii și condițiile]({terms_url}).",
"bulk_message_deletion.complete": "Am terminat de șters mesajele tale. Am eliminat {message_count, plural, =0 {0 de mesaje} one {# mesaj} few {# mesaje} other {# de mesaje}} din {channel_count, plural, =0 {0 de locuri} one {# loc} few {# locuri} other {# de locuri}}.",
"content.virus_detected": "Acest fișier a fost marcat ca potențial nesigur și a fost eliminat.",
"guild.bulk_create.emoji_limit": "S-a atins limita maximă de emojiuri ({limit}).",
"guild.bulk_create.sticker_limit": "Numărul maxim de stickere a fost atins ({limit}).",
"guild.bulk_create.unknown_error": "A apărut o eroare necunoscută.",
"guild.default_category_text": "Canale text",
"guild.default_category_voice": "Canale de voce",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Если ты являешься потребителем из ЕС/ЕЭЗ, ты даёшь явное согласие на немедленное предоставление цифрового контента {product_name} {premium_tier_name} и понимаешь, что теряешь законное право на отмены после получения доступа. Это не затрагивает другие обязательные права потребителя. См. [Условия предоставления услуг]({terms_url}).",
"bulk_message_deletion.complete": "Мы завершили удаление ваших сообщений. Удалено {message_count, plural, =0 {0 сообщений} one {# сообщение} few {# сообщения} many {# сообщений} other {# сообщений}} из {channel_count, plural, =0 {0 мест} one {# места} few {# мест} many {# мест} other {# мест}}.",
"content.virus_detected": "Этот файл был отмечен как потенциально опасный и удалён.",
"guild.bulk_create.emoji_limit": "Достигнут максимальный лимит эмодзи ({limit}).",
"guild.bulk_create.sticker_limit": "Достигнут максимальный лимит стикеров ({limit}).",
"guild.bulk_create.unknown_error": "Произошла неизвестная ошибка.",
"guild.default_category_text": "Текстовые каналы",
"guild.default_category_voice": "Голосовые каналы",
"guild.default_channel_text": "общий",
"guild.default_channel_voice": "Общий"
"billing.eu_withdrawal_waiver_checkout": "Если ты являешься потребителем из ЕС/ЕЭЗ, ты даёшь явное согласие на немедленное предоставление цифрового контента {product_name} {premium_tier_name} и понимаешь, что теряешь законное право на отмены после получения доступа. Это не затрагивает другие обязательные права потребителя. См. [Условия предоставления услуг]({terms_url}).",
"bulk_message_deletion.complete": "Мы завершили удаление ваших сообщений. Удалено {message_count, plural, =0 {0 сообщений} one {# сообщение} few {# сообщения} many {# сообщений} other {# сообщений}} из {channel_count, plural, =0 {0 мест} one {# места} few {# мест} many {# мест} other {# мест}}.",
"content.virus_detected": "Этот файл был отмечен как потенциально опасный и удалён.",
"guild.bulk_create.emoji_limit": "Достигнут максимальный лимит эмодзи ({limit}).",
"guild.bulk_create.sticker_limit": "Достигнут максимальный лимит стикеров ({limit}).",
"guild.bulk_create.unknown_error": "Произошла неизвестная ошибка.",
"guild.default_category_text": "Текстовые каналы",
"guild.default_category_voice": "Голосовые каналы",
"guild.default_channel_text": "общий",
"guild.default_channel_voice": "Общий"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Om jag är en EU/EES-konsument samtycker jag uttryckligen till att {product_name} {premium_tier_name} digitalt innehåll tillhandahålls omedelbart och bekräftar att jag förlorar min lagstadgade ångerrätt när åtkomst ges. Detta påverkar inte andra tvingande konsumenträttigheter. Se [användarvillkoren]({terms_url}).",
"bulk_message_deletion.complete": "Vi har raderat dina meddelanden. Vi tog bort {message_count, plural, =0 {0 meddelanden} one {# meddelande} other {# meddelanden}} från {channel_count, plural, =0 {0 platser} one {# plats} other {# platser}}.",
"content.virus_detected": "Filen flaggades som potentiellt osäker och har tagits bort.",
"guild.bulk_create.emoji_limit": "Maximalt antal emojis nått ({limit}).",
"guild.bulk_create.sticker_limit": "Maximalt antal klistermärken nått ({limit}).",
"guild.bulk_create.unknown_error": "Okänt fel.",
"guild.default_category_text": "Textkanaler",
"guild.default_category_voice": "Röstkanaler",
"guild.default_channel_text": "allmänt",
"guild.default_channel_voice": "Allmänt"
"billing.eu_withdrawal_waiver_checkout": "Om jag är en EU/EES-konsument samtycker jag uttryckligen till att {product_name} {premium_tier_name} digitalt innehåll tillhandahålls omedelbart och bekräftar att jag förlorar min lagstadgade ångerrätt när åtkomst ges. Detta påverkar inte andra tvingande konsumenträttigheter. Se [användarvillkoren]({terms_url}).",
"bulk_message_deletion.complete": "Vi har raderat dina meddelanden. Vi tog bort {message_count, plural, =0 {0 meddelanden} one {# meddelande} other {# meddelanden}} från {channel_count, plural, =0 {0 platser} one {# plats} other {# platser}}.",
"content.virus_detected": "Filen flaggades som potentiellt osäker och har tagits bort.",
"guild.bulk_create.emoji_limit": "Maximalt antal emojis nått ({limit}).",
"guild.bulk_create.sticker_limit": "Maximalt antal klistermärken nått ({limit}).",
"guild.bulk_create.unknown_error": "Okänt fel.",
"guild.default_category_text": "Textkanaler",
"guild.default_category_voice": "Röstkanaler",
"guild.default_channel_text": "allmänt",
"guild.default_channel_voice": "Allmänt"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "หากฉันเป็นผู้บริโภคใน EU/EEA ฉันยินยอมให้มีการให้บริการเนื้อหาดิจิทัล {product_name} {premium_tier_name} ทันที และรับทราบว่าฉันสูญเสียสิทธิ์ในการถอนตัวตามกฎหมายเมื่อมีการให้สิทธิ์การเข้าถึงแล้ว ซึ่งไม่กระทบต่อสิทธิ์ผู้บริโภคอื่นๆ ที่จำเป็น ดู[ข้อกำหนดในการให้บริการ]({terms_url})",
"bulk_message_deletion.complete": "ลบข้อความของคุณเรียบร้อยแล้ว เราลบ {message_count, plural, =0 {0 ข้อความ} other {# ข้อความ}} จาก {channel_count, plural, =0 {0 ที่} other {# ที่}}",
"content.virus_detected": "ไฟล์นั้นถูกตรวจพบว่าอาจไม่ปลอดภัยและถูกลบออกแล้ว",
"guild.bulk_create.emoji_limit": "จำนวนอีโมจิสูงสุดที่อนุญาตคือ {limit}",
"guild.bulk_create.sticker_limit": "จำนวนสติกเกอร์สูงสุดที่สามารถเพิ่มได้คือ {limit}",
"guild.bulk_create.unknown_error": "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ",
"guild.default_category_text": "ช่องข้อความ",
"guild.default_category_voice": "ช่องเสียง",
"guild.default_channel_text": "ทั่วไป",
"guild.default_channel_voice": "ทั่วไป"
"billing.eu_withdrawal_waiver_checkout": "หากฉันเป็นผู้บริโภคใน EU/EEA ฉันยินยอมให้มีการให้บริการเนื้อหาดิจิทัล {product_name} {premium_tier_name} ทันที และรับทราบว่าฉันสูญเสียสิทธิ์ในการถอนตัวตามกฎหมายเมื่อมีการให้สิทธิ์การเข้าถึงแล้ว ซึ่งไม่กระทบต่อสิทธิ์ผู้บริโภคอื่นๆ ที่จำเป็น ดู[ข้อกำหนดในการให้บริการ]({terms_url})",
"bulk_message_deletion.complete": "ลบข้อความของคุณเรียบร้อยแล้ว เราลบ {message_count, plural, =0 {0 ข้อความ} other {# ข้อความ}} จาก {channel_count, plural, =0 {0 ที่} other {# ที่}}",
"content.virus_detected": "ไฟล์นั้นถูกตรวจพบว่าอาจไม่ปลอดภัยและถูกลบออกแล้ว",
"guild.bulk_create.emoji_limit": "จำนวนอีโมจิสูงสุดที่อนุญาตคือ {limit}",
"guild.bulk_create.sticker_limit": "จำนวนสติกเกอร์สูงสุดที่สามารถเพิ่มได้คือ {limit}",
"guild.bulk_create.unknown_error": "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ",
"guild.default_category_text": "ช่องข้อความ",
"guild.default_category_voice": "ช่องเสียง",
"guild.default_channel_text": "ทั่วไป",
"guild.default_channel_voice": "ทั่วไป"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "AB/EEA tüketicisiysem, {product_name} {premium_tier_name} dijital içeriğinin hemen sağlanmasına açıkça onay veriyorum ve erişim sağlandıktan sonra yasal cayma hakkımı kaybettiğimi kabul ediyorum. Bu, diğer zorunlu tüketici haklarını etkilemez. [Hizmet Şartları]({terms_url}) sayfasına bakın.",
"bulk_message_deletion.complete": "Mesajlarını silme işlemini tamamladık. {channel_count, plural, =0 {0 yerden} one {# yerden} other {# yerden}} {message_count, plural, =0 {0 mesaj} one {# mesaj} other {# mesaj}} kaldırdık.",
"content.virus_detected": "Bu dosya potansiyel olarak güvensiz olarak işaretlendi ve kaldırıldı.",
"guild.bulk_create.emoji_limit": "Maksimum emoji sayısına ulaşıldı ({limit}).",
"guild.bulk_create.sticker_limit": "Maksimum çıkartma sayısına ulaşıldı ({limit}).",
"guild.bulk_create.unknown_error": "Bilinmeyen bir hata oluştu.",
"guild.default_category_text": "Metin kanalları",
"guild.default_category_voice": "Sesli kanallar",
"guild.default_channel_text": "genel",
"guild.default_channel_voice": "Genel"
"billing.eu_withdrawal_waiver_checkout": "AB/EEA tüketicisiysem, {product_name} {premium_tier_name} dijital içeriğinin hemen sağlanmasına açıkça onay veriyorum ve erişim sağlandıktan sonra yasal cayma hakkımı kaybettiğimi kabul ediyorum. Bu, diğer zorunlu tüketici haklarını etkilemez. [Hizmet Şartları]({terms_url}) sayfasına bakın.",
"bulk_message_deletion.complete": "Mesajlarını silme işlemini tamamladık. {channel_count, plural, =0 {0 yerden} one {# yerden} other {# yerden}} {message_count, plural, =0 {0 mesaj} one {# mesaj} other {# mesaj}} kaldırdık.",
"content.virus_detected": "Bu dosya potansiyel olarak güvensiz olarak işaretlendi ve kaldırıldı.",
"guild.bulk_create.emoji_limit": "Maksimum emoji sayısına ulaşıldı ({limit}).",
"guild.bulk_create.sticker_limit": "Maksimum çıkartma sayısına ulaşıldı ({limit}).",
"guild.bulk_create.unknown_error": "Bilinmeyen bir hata oluştu.",
"guild.default_category_text": "Metin kanalları",
"guild.default_category_voice": "Sesli kanallar",
"guild.default_channel_text": "genel",
"guild.default_channel_voice": "Genel"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Якщо я є споживачем з ЄС/ЄЕЗ, я надаю явну згоду на негайне надання цифрового контенту {product_name} {premium_tier_name} і визнаю, що втрачаю своє законне право на відмову після отримання доступу. Це не впливає на інші обов'язкові права споживача. Див. [Умови надання послуг]({terms_url}).",
"bulk_message_deletion.complete": "Ми завершили видалення твоїх повідомлень. Видалено {message_count, plural, =0 {0 повідомлень} one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлень}} з {channel_count, plural, =0 {0 місць} one {# місця} few {# місць} many {# місць} other {# місць}}.",
"content.virus_detected": "Цей файл позначено як потенційно небезпечний, і його вилучено.",
"guild.bulk_create.emoji_limit": "Досягнуто максимальної кількості емодзі ({limit}).",
"guild.bulk_create.sticker_limit": "Досягнуто максимальну кількість стікерів ({limit}).",
"guild.bulk_create.unknown_error": "Невідома помилка.",
"guild.default_category_text": "Текстові канали",
"guild.default_category_voice": "Голосові канали",
"guild.default_channel_text": "загальний",
"guild.default_channel_voice": "Загальний"
"billing.eu_withdrawal_waiver_checkout": "Якщо я є споживачем з ЄС/ЄЕЗ, я надаю явну згоду на негайне надання цифрового контенту {product_name} {premium_tier_name} і визнаю, що втрачаю своє законне право на відмову після отримання доступу. Це не впливає на інші обов'язкові права споживача. Див. [Умови надання послуг]({terms_url}).",
"bulk_message_deletion.complete": "Ми завершили видалення твоїх повідомлень. Видалено {message_count, plural, =0 {0 повідомлень} one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлень}} з {channel_count, plural, =0 {0 місць} one {# місця} few {# місць} many {# місць} other {# місць}}.",
"content.virus_detected": "Цей файл позначено як потенційно небезпечний, і його вилучено.",
"guild.bulk_create.emoji_limit": "Досягнуто максимальної кількості емодзі ({limit}).",
"guild.bulk_create.sticker_limit": "Досягнуто максимальну кількість стікерів ({limit}).",
"guild.bulk_create.unknown_error": "Невідома помилка.",
"guild.default_category_text": "Текстові канали",
"guild.default_category_voice": "Голосові канали",
"guild.default_channel_text": "загальний",
"guild.default_channel_voice": "Загальний"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "Nếu tôi là người tiêu dùng EU/EEA, tôi đồng ý rõ ràng rằng nội dung kỹ thuật số {product_name} {premium_tier_name} được cung cấp ngay lập tức và thừa nhận rằng tôi mất quyền hủy bỏ theo luật định sau khi có quyền truy cập. Điều này không ảnh hưởng đến các quyền lợi người tiêu dùng bắt buộc khác. Xem [Điều khoản dịch vụ]({terms_url}).",
"bulk_message_deletion.complete": "Chúng tôi đã xóa xong tin nhắn của bạn. Đã xóa {message_count, plural, =0 {0 tin nhắn} other {# tin nhắn}} khỏi {channel_count, plural, =0 {0 nơi} other {# nơi}}.",
"content.virus_detected": "Tệp đó bị gắn cờ là có khả năng không an toàn và đã bị xóa.",
"guild.bulk_create.emoji_limit": "Đã đạt đến số lượng biểu tượng cảm xúc tối đa ({limit}).",
"guild.bulk_create.sticker_limit": "Đã đạt đến số lượng tối đa nhãn dán ({limit}).",
"guild.bulk_create.unknown_error": "Đã xảy ra lỗi không xác định.",
"guild.default_category_text": "Kênh văn bản",
"guild.default_category_voice": "Kênh thoại",
"guild.default_channel_text": "chung",
"guild.default_channel_voice": "Chung"
"billing.eu_withdrawal_waiver_checkout": "Nếu tôi là người tiêu dùng EU/EEA, tôi đồng ý rõ ràng rằng nội dung kỹ thuật số {product_name} {premium_tier_name} được cung cấp ngay lập tức và thừa nhận rằng tôi mất quyền hủy bỏ theo luật định sau khi có quyền truy cập. Điều này không ảnh hưởng đến các quyền lợi người tiêu dùng bắt buộc khác. Xem [Điều khoản dịch vụ]({terms_url}).",
"bulk_message_deletion.complete": "Chúng tôi đã xóa xong tin nhắn của bạn. Đã xóa {message_count, plural, =0 {0 tin nhắn} other {# tin nhắn}} khỏi {channel_count, plural, =0 {0 nơi} other {# nơi}}.",
"content.virus_detected": "Tệp đó bị gắn cờ là có khả năng không an toàn và đã bị xóa.",
"guild.bulk_create.emoji_limit": "Đã đạt đến số lượng biểu tượng cảm xúc tối đa ({limit}).",
"guild.bulk_create.sticker_limit": "Đã đạt đến số lượng tối đa nhãn dán ({limit}).",
"guild.bulk_create.unknown_error": "Đã xảy ra lỗi không xác định.",
"guild.default_category_text": "Kênh văn bản",
"guild.default_category_voice": "Kênh thoại",
"guild.default_channel_text": "chung",
"guild.default_channel_voice": "Chung"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "如果我是欧盟/欧洲经济区消费者,我明确同意立即提供 {product_name} {premium_tier_name} 数字内容,并知晓一旦获得访问权限,我将失去法定撤销权。这不影响其他强制性消费者权利。请参阅[服务条款]({terms_url})。",
"bulk_message_deletion.complete": "我们已删除你的消息。从 {channel_count, plural, =0 {0 个位置} other {# 个位置}} 中移除了 {message_count, plural, =0 {0 条消息} other {# 条消息}}。",
"content.virus_detected": "该文件被标记为可能存在风险,已被移除。",
"guild.bulk_create.emoji_limit": "已达到最大表情符号数量 ({limit})。",
"guild.bulk_create.sticker_limit": "已达到最大贴纸数量 ({limit})。",
"guild.bulk_create.unknown_error": "发生未知错误。",
"guild.default_category_text": "文字频道",
"guild.default_category_voice": "语音频道",
"guild.default_channel_text": "综合",
"guild.default_channel_voice": "综合"
"billing.eu_withdrawal_waiver_checkout": "如果我是欧盟/欧洲经济区消费者,我明确同意立即提供 {product_name} {premium_tier_name} 数字内容,并知晓一旦获得访问权限,我将失去法定撤销权。这不影响其他强制性消费者权利。请参阅[服务条款]({terms_url})。",
"bulk_message_deletion.complete": "我们已删除你的消息。从 {channel_count, plural, =0 {0 个位置} other {# 个位置}} 中移除了 {message_count, plural, =0 {0 条消息} other {# 条消息}}。",
"content.virus_detected": "该文件被标记为可能存在风险,已被移除。",
"guild.bulk_create.emoji_limit": "已达到最大表情符号数量 ({limit})。",
"guild.bulk_create.sticker_limit": "已达到最大贴纸数量 ({limit})。",
"guild.bulk_create.unknown_error": "发生未知错误。",
"guild.default_category_text": "文字频道",
"guild.default_category_voice": "语音频道",
"guild.default_channel_text": "综合",
"guild.default_channel_voice": "综合"
}
@@ -1,12 +1,12 @@
{
"billing.eu_withdrawal_waiver_checkout": "若我是歐盟/歐洲經濟區消費者,我明確同意立即提供 {product_name} {premium_tier_name} 數位內容,並了解一旦取得存取權,即喪失法定撤回權。這不影響其他強制性消費者權益。請參閱[服務條款]({terms_url})。",
"bulk_message_deletion.complete": "我們已刪除你的訊息。從 {channel_count, plural, =0 {0 個位置} other {# 個位置}} 中移除了 {message_count, plural, =0 {0 則訊息} other {# 則訊息}}。",
"content.virus_detected": "該檔案已被標記為可能不安全,並已移除。",
"guild.bulk_create.emoji_limit": "已達最大表情符號數量限制 ({limit})。",
"guild.bulk_create.sticker_limit": "已達最大貼圖數量 ({limit})。",
"guild.bulk_create.unknown_error": "發生了未知的錯誤。",
"guild.default_category_text": "文字頻道",
"guild.default_category_voice": "語音頻道",
"guild.default_channel_text": "一般",
"guild.default_channel_voice": "一般"
"billing.eu_withdrawal_waiver_checkout": "若我是歐盟/歐洲經濟區消費者,我明確同意立即提供 {product_name} {premium_tier_name} 數位內容,並了解一旦取得存取權,即喪失法定撤回權。這不影響其他強制性消費者權益。請參閱[服務條款]({terms_url})。",
"bulk_message_deletion.complete": "我們已刪除你的訊息。從 {channel_count, plural, =0 {0 個位置} other {# 個位置}} 中移除了 {message_count, plural, =0 {0 則訊息} other {# 則訊息}}。",
"content.virus_detected": "該檔案已被標記為可能不安全,並已移除。",
"guild.bulk_create.emoji_limit": "已達最大表情符號數量限制 ({limit})。",
"guild.bulk_create.sticker_limit": "已達最大貼圖數量 ({limit})。",
"guild.bulk_create.unknown_error": "發生了未知的錯誤。",
"guild.default_category_text": "文字頻道",
"guild.default_category_voice": "語音頻道",
"guild.default_channel_text": "一般",
"guild.default_channel_voice": "一般"
}
@@ -28,7 +28,7 @@ describe('assertNoUndefinedParams', () => {
created_at: new Date(0),
blob: Buffer.from('x'),
mention_users: [1n, 2n, 3n],
nsfw_emojis: new Set(['a', 'b']),
mention_roles: new Set(['a', 'b']),
reactions: new Map([['a', 1]]),
embeds: {title: 'a', fields: [{name: 'n', value: 'v'}], footer: {text: null}},
}),
@@ -44,7 +44,7 @@ describe('assertNoUndefinedParams', () => {
});
it('reports the dotted path of an undefined set member', () => {
expect(guardError({nsfw_emojis: new Set(['a', undefined])})).toBe(messageFor(':nsfw_emojis{set:1}'));
expect(guardError({mention_roles: new Set(['a', undefined])})).toBe(messageFor(':mention_roles{set:1}'));
});
it('reports the dotted path of an undefined map key', () => {
@@ -240,7 +240,6 @@ export interface GuildEmojiRow {
name: string;
creator_id: UserID;
animated: boolean;
nsfw: boolean | null;
version: number;
}
@@ -250,7 +249,6 @@ export const GUILD_EMOJI_COLUMNS = [
'name',
'creator_id',
'animated',
'nsfw',
'version',
] as const satisfies ReadonlyArray<keyof GuildEmojiRow>;
export const GUILD_EMOJI_BY_EMOJI_ID_COLUMNS = [
@@ -259,7 +257,6 @@ export const GUILD_EMOJI_BY_EMOJI_ID_COLUMNS = [
'name',
'creator_id',
'animated',
'nsfw',
] as const satisfies ReadonlyArray<keyof GuildEmojiRow>;
export interface GuildStickerRow {
@@ -268,7 +265,6 @@ export interface GuildStickerRow {
name: string;
description: Nullish<string>;
animated: boolean;
nsfw: boolean | null;
tags: Nullish<Array<string>>;
creator_id: UserID;
version: number;
@@ -280,7 +276,6 @@ export const GUILD_STICKER_COLUMNS = [
'name',
'description',
'animated',
'nsfw',
'tags',
'creator_id',
'version',
@@ -291,7 +286,6 @@ export const GUILD_STICKER_BY_STICKER_ID_COLUMNS = [
'name',
'description',
'animated',
'nsfw',
'tags',
'creator_id',
] as const satisfies ReadonlyArray<keyof GuildStickerRow>;
@@ -96,7 +96,6 @@ export interface MessageStickerItem {
sticker_id: StickerID;
name: string;
animated?: boolean;
nsfw?: boolean;
}
export interface MessageReference {
@@ -148,7 +147,6 @@ export interface MessageRow {
message_reference: Nullish<MessageReference>;
message_snapshots: Nullish<Array<MessageSnapshot>>;
call: Nullish<MessageCall>;
nsfw_emojis: Nullish<Set<EmojiID>>;
has_reaction: Nullish<boolean>;
version: number;
}
@@ -176,7 +174,6 @@ export const MESSAGE_COLUMNS = [
'message_reference',
'message_snapshots',
'call',
'nsfw_emojis',
'has_reaction',
'version',
] as const satisfies ReadonlyArray<keyof MessageRow>;
+4 -4
View File
@@ -116,7 +116,7 @@ export function mapGuildEmojiToResponse(emoji: GuildEmoji): z.infer<typeof Guild
id,
name: emoji.name,
animated: emoji.isAnimated,
nsfw: emoji.isNsfw,
nsfw: false,
};
}
@@ -128,7 +128,7 @@ export function mapGuildStickerToResponse(sticker: GuildSticker): z.infer<typeof
description: sticker.description ?? '',
tags: sticker.tags,
animated: sticker.animated,
nsfw: sticker.isNsfw,
nsfw: false,
};
}
@@ -180,7 +180,7 @@ function mapEmojiWithUser(
id,
name: emoji.name,
animated: emoji.isAnimated,
nsfw: emoji.isNsfw,
nsfw: false,
user: userPartial,
};
}
@@ -207,7 +207,7 @@ function mapStickerWithUser(
description: sticker.description ?? '',
tags: sticker.tags,
animated: sticker.animated,
nsfw: sticker.isNsfw,
nsfw: false,
user: userPartial,
};
}
@@ -152,6 +152,7 @@ export function GuildEmojiController(app: HonoApp) {
app.delete(
'/guilds/:guild_id/emojis/:emoji_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_DELETE),
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_DELETE_DAILY),
LoginRequired,
Validator('param', GuildIdEmojiIdParam),
Validator('query', PurgeQuery),
@@ -165,6 +165,7 @@ export function GuildStickerController(app: HonoApp) {
app.delete(
'/guilds/:guild_id/stickers/:sticker_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_DELETE),
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_DELETE_DAILY),
LoginRequired,
Validator('param', GuildIdStickerIdParam),
Validator('query', PurgeQuery),
@@ -109,12 +109,7 @@ export class EmojiService {
if (allEmojis.length >= maxEmojis) {
throw new MaxGuildEmojisError(maxEmojis);
}
const {
animated,
imageBuffer,
contentType,
nsfw: isNsfw,
} = await this.avatarService.processEmoji({
const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({
errorPath: 'image',
base64Image: image,
});
@@ -131,7 +126,6 @@ export class EmojiService {
name,
creator_id: user.id,
animated,
nsfw: isNsfw,
version: 1,
});
const updatedEmojis = [...allEmojis, emoji];
@@ -184,7 +178,6 @@ export class EmojiService {
name: sourceEmoji.name,
creator_id: user.id,
animated: sourceEmoji.isAnimated,
nsfw: sourceEmoji.hasNsfwClassification ? sourceEmoji.isNsfw : null,
version: 1,
});
const updatedEmojis = [...allEmojis, emoji];
@@ -247,12 +240,7 @@ export class EmojiService {
});
continue;
}
const {
animated,
imageBuffer,
contentType,
nsfw: isNsfw,
} = await this.avatarService.processEmoji({
const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({
errorPath: `emojis[${success.length + failed.length}].image`,
base64Image: emojiData.image,
});
@@ -268,7 +256,6 @@ export class EmojiService {
emoji_id: emojiId,
name: emojiData.name,
animated,
nsfw: isNsfw,
creator_id: user.id,
version: 1,
});
@@ -126,11 +126,7 @@ export class StickerService {
if (stickerCount >= maxStickers) {
throw new MaxGuildStickersStaticError(maxStickers);
}
const {
animated,
imageBuffer,
nsfw: isNsfw,
} = await this.avatarService.processSticker({
const {animated, imageBuffer} = await this.avatarService.processSticker({
errorPath: 'image',
base64Image: image,
});
@@ -142,7 +138,6 @@ export class StickerService {
name,
description: description ?? null,
animated,
nsfw: isNsfw,
tags,
creator_id: user.id,
version: 1,
@@ -198,7 +193,6 @@ export class StickerService {
name: sourceSticker.name,
description: sourceSticker.description,
animated: sourceSticker.animated,
nsfw: sourceSticker.hasNsfwClassification ? sourceSticker.isNsfw : null,
tags: sourceSticker.tags,
creator_id: user.id,
version: 1,
@@ -275,11 +269,7 @@ export class StickerService {
contentModerationService.scanText(stickerData.name, bulkStickerModCtx);
contentModerationService.scanText(stickerData.description, bulkStickerModCtx);
contentModerationService.scanText(stickerData.tags.join(' '), bulkStickerModCtx);
const {
animated,
imageBuffer,
nsfw: isNsfw,
} = await this.avatarService.processSticker({
const {animated, imageBuffer} = await this.avatarService.processSticker({
errorPath: `stickers[${success.length + failed.length}].image`,
base64Image: stickerData.image,
});
@@ -292,7 +282,6 @@ export class StickerService {
description: stickerData.description ?? null,
tags: stickerData.tags,
animated,
nsfw: isNsfw,
creator_id: user.id,
version: 1,
});
@@ -204,8 +204,6 @@ export class AvatarService {
animated: boolean;
format: string;
contentType: string;
nsfw: boolean;
nsfwProbability?: number;
}> {
const {errorPath, base64Image} = params;
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
@@ -226,7 +224,7 @@ export class AvatarService {
type: 'base64',
base64: base64Data,
version: 2,
nsfw: 'flag',
nsfw: 'allow',
}),
kind: 'emoji',
errorPath,
@@ -237,8 +235,6 @@ export class AvatarService {
animated,
format: metadata.format,
contentType: metadata.content_type,
nsfw: metadata.nsfw,
nsfwProbability: metadata.nsfw_probability,
};
}
@@ -275,8 +271,6 @@ export class AvatarService {
animated: boolean;
format: string;
contentType: string;
nsfw: boolean;
nsfwProbability?: number;
}> {
const {errorPath, base64Image} = params;
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
@@ -297,7 +291,7 @@ export class AvatarService {
type: 'base64',
base64: base64Data,
version: 2,
nsfw: 'flag',
nsfw: 'allow',
}),
kind: 'sticker',
errorPath,
@@ -308,8 +302,6 @@ export class AvatarService {
animated,
format: metadata.format,
contentType: metadata.content_type,
nsfw: metadata.nsfw,
nsfwProbability: metadata.nsfw_probability,
};
}
@@ -348,7 +340,7 @@ export class AvatarService {
bucket: Config.s3.buckets.cdn,
key: `stickers/${stickerId}`,
version: 2,
nsfw: 'block',
nsfw: 'allow',
});
return metadata?.animated ?? null;
} catch (_error) {
-5
View File
@@ -9,8 +9,6 @@ export class GuildEmoji {
readonly name: string;
readonly creatorId: UserID;
readonly isAnimated: boolean;
readonly isNsfw: boolean;
readonly hasNsfwClassification: boolean;
readonly version: number;
constructor(row: GuildEmojiRow) {
@@ -19,8 +17,6 @@ export class GuildEmoji {
this.name = row.name;
this.creatorId = row.creator_id;
this.isAnimated = row.animated ?? false;
this.isNsfw = row.nsfw ?? false;
this.hasNsfwClassification = row.nsfw != null;
this.version = row.version;
}
@@ -31,7 +27,6 @@ export class GuildEmoji {
name: this.name,
creator_id: this.creatorId,
animated: this.isAnimated,
nsfw: this.hasNsfwClassification ? this.isNsfw : null,
version: this.version,
};
}
@@ -9,8 +9,6 @@ export class GuildSticker {
readonly name: string;
readonly description: string | null;
readonly animated: boolean;
readonly isNsfw: boolean;
readonly hasNsfwClassification: boolean;
readonly tags: Array<string>;
readonly creatorId: UserID;
readonly version: number;
@@ -21,8 +19,6 @@ export class GuildSticker {
this.name = row.name;
this.description = row.description ?? null;
this.animated = row.animated;
this.isNsfw = row.nsfw ?? false;
this.hasNsfwClassification = row.nsfw !== null && row.nsfw !== undefined;
this.tags = row.tags ?? [];
this.creatorId = row.creator_id;
this.version = row.version;
@@ -35,7 +31,6 @@ export class GuildSticker {
name: this.name,
description: this.description,
animated: this.animated,
nsfw: this.hasNsfwClassification ? this.isNsfw : null,
tags: this.tags.length > 0 ? this.tags : null,
creator_id: this.creatorId,
version: this.version,
+1 -4
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MessageTypeValue} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelID, EmojiID, MessageID, RoleID, UserID, WebhookID} from '../BrandedTypes';
import type {ChannelID, MessageID, RoleID, UserID, WebhookID} from '../BrandedTypes';
import type {MessageRow} from '../database/types/MessageTypes';
import {Attachment} from './Attachment';
import {CallInfo} from './CallInfo';
@@ -33,7 +33,6 @@ export class Message {
readonly reference: MessageRef | null;
readonly messageSnapshots: Array<MessageSnapshot>;
readonly call: CallInfo | null;
readonly nsfwEmojis: Set<EmojiID>;
readonly hasReaction: boolean | null;
readonly version: number;
@@ -66,7 +65,6 @@ export class Message {
this.reference = row.message_reference ? new MessageRef(row.message_reference) : null;
this.messageSnapshots = (row.message_snapshots ?? []).map((snapshot) => new MessageSnapshot(snapshot));
this.call = row.call ? new CallInfo(row.call) : null;
this.nsfwEmojis = row.nsfw_emojis ?? new Set();
this.hasReaction = row.has_reaction ?? null;
this.version = row.version;
}
@@ -96,7 +94,6 @@ export class Message {
message_snapshots:
this.messageSnapshots.length > 0 ? this.messageSnapshots.map((snapshot) => snapshot.toMessageSnapshot()) : null,
call: this.call?.toMessageCall() ?? null,
nsfw_emojis: this.nsfwEmojis.size > 0 ? this.nsfwEmojis : null,
has_reaction: this.hasReaction ?? null,
version: this.version,
};
-3
View File
@@ -7,13 +7,11 @@ export class StickerItem {
readonly id: StickerID;
readonly name: string;
readonly animated: boolean;
readonly nsfw: boolean;
constructor(sticker: MessageStickerItem) {
this.id = sticker.sticker_id;
this.name = sticker.name;
this.animated = sticker.animated ?? false;
this.nsfw = sticker.nsfw ?? false;
}
toMessageStickerItem(): MessageStickerItem {
@@ -21,7 +19,6 @@ export class StickerItem {
sticker_id: this.id,
name: this.name,
animated: this.animated,
...(this.nsfw ? {nsfw: true} : {}),
};
}
}
@@ -5,32 +5,15 @@ import {createStickerID} from '../../BrandedTypes';
import {StickerItem} from '../StickerItem';
describe('StickerItem', () => {
it('omits nsfw when it is false', () => {
it('defaults animated to false when it is omitted', () => {
const sticker = new StickerItem({
sticker_id: createStickerID(1n),
name: 'party-parrot',
animated: false,
});
const serialized = sticker.toMessageStickerItem();
expect(serialized).toEqual({
sticker_id: createStickerID(1n),
name: 'party-parrot',
animated: false,
});
expect(serialized).not.toHaveProperty('nsfw');
});
it('preserves nsfw when it is true', () => {
const sticker = new StickerItem({
sticker_id: createStickerID(2n),
name: 'spicy-parrot',
animated: true,
nsfw: true,
});
expect(sticker.toMessageStickerItem()).toEqual({
sticker_id: createStickerID(2n),
name: 'spicy-parrot',
animated: true,
nsfw: true,
sticker_id: createStickerID(1n),
name: 'party-parrot',
animated: false,
});
});
});
+17 -28
View File
@@ -23552,7 +23552,6 @@
"NOT_FRIENDS_WITH_USER",
"NOT_OWNER_OF_ADMIN_API_KEY",
"NSFW_CONTENT_AGE_RESTRICTED",
"NSFW_EMOJI_STICKER_BLOCKED",
"PASSKEY_AUTHENTICATION_FAILED",
"PASSKEYS_DISABLED",
"PHONE_ADD_NOT_ELIGIBLE",
@@ -23861,7 +23860,6 @@
"You're not friends with this user.",
"You don't have permission to modify this admin API key.",
"NSFW content is age restricted.",
"This emoji or sticker is classified as NSFW and cannot be used in this context.",
"Passkey authentication failed.",
"Passkeys are disabled.",
"You are not eligible to add a phone number to your account.",
@@ -24812,11 +24810,6 @@
],
"description": "The stickers sent with the message"
},
"nsfw_emojis": {
"type": "array",
"items": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "IDs of custom emojis in this message that are classified as NSFW"
},
"reactions": {
"anyOf": [
{"type": "array", "items": {"$ref": "#/components/schemas/MessageReactionResponse"}},
@@ -24990,11 +24983,6 @@
],
"description": "The stickers sent with the message"
},
"nsfw_emojis": {
"type": "array",
"items": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "IDs of custom emojis in this message that are classified as NSFW"
},
"reactions": {
"anyOf": [
{"type": "array", "items": {"$ref": "#/components/schemas/MessageReactionResponse"}},
@@ -25484,8 +25472,7 @@
"description": "The unique identifier of the sticker"
},
"name": {"type": "string", "description": "The name of the sticker"},
"animated": {"type": "boolean", "description": "Whether the sticker is animated"},
"nsfw": {"type": "boolean", "description": "Whether this sticker is classified as NSFW"}
"animated": {"type": "boolean", "description": "Whether the sticker is animated"}
},
"required": ["id", "name", "animated"]
},
@@ -27050,11 +27037,6 @@
],
"description": "The stickers sent with the message"
},
"nsfw_emojis": {
"type": "array",
"items": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "IDs of custom emojis in this message that are classified as NSFW"
},
"message_reference": {
"anyOf": [
{
@@ -27944,7 +27926,10 @@
},
"name": {"type": "string", "description": "The name of the emoji"},
"animated": {"type": "boolean", "description": "Whether this emoji is animated"},
"nsfw": {"type": "boolean", "description": "Whether this emoji is classified as NSFW"}
"nsfw": {
"type": "boolean",
"description": "Deprecated; always false. Retained for compatibility with older clients"
}
},
"required": ["id", "name", "animated", "nsfw"]
},
@@ -27965,7 +27950,10 @@
"description": "Autocomplete/suggestion tags for the sticker"
},
"animated": {"type": "boolean", "description": "Whether this sticker is animated"},
"nsfw": {"type": "boolean", "description": "Whether this sticker is classified as NSFW"}
"nsfw": {
"type": "boolean",
"description": "Deprecated; always false. Retained for compatibility with older clients"
}
},
"required": ["id", "name", "description", "tags", "animated", "nsfw"]
},
@@ -28951,7 +28939,10 @@
},
"name": {"type": "string", "description": "The name of the emoji"},
"animated": {"type": "boolean", "description": "Whether this emoji is animated"},
"nsfw": {"type": "boolean", "description": "Whether this emoji is classified as NSFW"},
"nsfw": {
"type": "boolean",
"description": "Deprecated; always false. Retained for compatibility with older clients"
},
"user": {"$ref": "#/components/schemas/UserPartialResponse"}
},
"required": ["id", "name", "animated", "nsfw", "user"]
@@ -29451,7 +29442,10 @@
"description": "Autocomplete/suggestion tags for the sticker"
},
"animated": {"type": "boolean", "description": "Whether this sticker is animated"},
"nsfw": {"type": "boolean", "description": "Whether this sticker is classified as NSFW"},
"nsfw": {
"type": "boolean",
"description": "Deprecated; always false. Retained for compatibility with older clients"
},
"user": {"$ref": "#/components/schemas/UserPartialResponse"}
},
"required": ["id", "name", "description", "tags", "animated", "nsfw", "user"]
@@ -31504,11 +31498,6 @@
],
"description": "The stickers sent with the message"
},
"nsfw_emojis": {
"type": "array",
"items": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "IDs of custom emojis in this message that are classified as NSFW"
},
"reactions": {
"anyOf": [
{"type": "array", "items": {"$ref": "#/components/schemas/MessageReactionResponse"}},
@@ -110,7 +110,7 @@ export const GuildRateLimitConfigs = {
} as RouteRateLimitConfig,
GUILD_EMOJI_CREATE: {
bucket: 'guild:emoji:create::guild_id',
config: {limit: 20, windowMs: ms('10 seconds')},
config: {limit: 10, windowMs: ms('30 seconds')},
} as RouteRateLimitConfig,
GUILD_EMOJI_BULK_CREATE: {
bucket: 'guild:emoji:bulk_create::guild_id',
@@ -118,7 +118,7 @@ export const GuildRateLimitConfigs = {
} as RouteRateLimitConfig,
GUILD_EMOJI_CLONE: {
bucket: 'guild:emoji:clone::guild_id',
config: {limit: 20, windowMs: ms('10 seconds')},
config: {limit: 10, windowMs: ms('30 seconds')},
} as RouteRateLimitConfig,
GUILD_EMOJI_UPDATE: {
bucket: 'guild:emoji:update::guild_id',
@@ -126,7 +126,11 @@ export const GuildRateLimitConfigs = {
} as RouteRateLimitConfig,
GUILD_EMOJI_DELETE: {
bucket: 'guild:emoji:delete::guild_id',
config: {limit: 20, windowMs: ms('10 seconds')},
config: {limit: 10, windowMs: ms('30 seconds')},
} as RouteRateLimitConfig,
GUILD_EMOJI_DELETE_DAILY: {
bucket: 'guild:emoji:delete:daily::guild_id',
config: {limit: 300, windowMs: ms('1 day')},
} as RouteRateLimitConfig,
GUILD_EMOJI_METADATA: {
bucket: 'guild:emoji:metadata::user_id',
@@ -138,7 +142,7 @@ export const GuildRateLimitConfigs = {
} as RouteRateLimitConfig,
GUILD_STICKER_CREATE: {
bucket: 'guild:sticker:create::guild_id',
config: {limit: 20, windowMs: ms('10 seconds')},
config: {limit: 10, windowMs: ms('30 seconds')},
} as RouteRateLimitConfig,
GUILD_STICKER_BULK_CREATE: {
bucket: 'guild:sticker:bulk_create::guild_id',
@@ -146,7 +150,7 @@ export const GuildRateLimitConfigs = {
} as RouteRateLimitConfig,
GUILD_STICKER_CLONE: {
bucket: 'guild:sticker:clone::guild_id',
config: {limit: 20, windowMs: ms('10 seconds')},
config: {limit: 10, windowMs: ms('30 seconds')},
} as RouteRateLimitConfig,
GUILD_STICKER_UPDATE: {
bucket: 'guild:sticker:update::guild_id',
@@ -154,7 +158,11 @@ export const GuildRateLimitConfigs = {
} as RouteRateLimitConfig,
GUILD_STICKER_DELETE: {
bucket: 'guild:sticker:delete::guild_id',
config: {limit: 20, windowMs: ms('10 seconds')},
config: {limit: 10, windowMs: ms('30 seconds')},
} as RouteRateLimitConfig,
GUILD_STICKER_DELETE_DAILY: {
bucket: 'guild:sticker:delete:daily::guild_id',
config: {limit: 300, windowMs: ms('1 day')},
} as RouteRateLimitConfig,
GUILD_STICKER_METADATA: {
bucket: 'guild:sticker:metadata::user_id',
-1
View File
@@ -307,7 +307,6 @@ export class RpcService {
name: sticker.name,
description: sticker.description,
animated,
nsfw: sticker.isNsfw,
tags: sticker.tags,
creator_id: sticker.creatorId,
version: sticker.version,
@@ -84,7 +84,6 @@ describe('MessageSearchSerializer', () => {
message_reference: null,
message_snapshots: [],
call: null,
nsfw_emojis: null,
has_reaction: null,
version: 1,
};
@@ -176,7 +175,6 @@ describe('MessageSearchSerializer', () => {
},
],
call: null,
nsfw_emojis: null,
has_reaction: null,
version: 1,
};
@@ -255,7 +253,6 @@ describe('MessageSearchSerializer', () => {
},
],
call: null,
nsfw_emojis: null,
has_reaction: null,
version: 1,
};
@@ -2261,7 +2261,6 @@ export function TestHarnessController(app: HonoApp) {
message_reference: null,
message_snapshots: null,
call: null,
nsfw_emojis: null,
has_reaction: false,
version: 1,
};
@@ -255,7 +255,6 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
embeds: this.mapEmbeds(message.embeds, message),
attachments: await this.mapAttachments(message.attachments, message),
stickers: this.mapStickers(message.stickers),
nsfw_emojis: message.nsfwEmojis.size > 0 ? [...message.nsfwEmojis].map((id) => id.toString()) : undefined,
reactions,
message_reference: message.reference
? {
@@ -481,7 +480,6 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
id: sticker.id.toString(),
name: sticker.name,
animated: sticker.animated,
nsfw: sticker.nsfw,
}));
}
@@ -516,7 +516,6 @@ export class UserChannelService {
message_reference: null,
message_snapshots: null,
call: null,
nsfw_emojis: null,
has_reaction: false,
version: 1,
});
@@ -70,7 +70,6 @@ function makeMessage(channelId: ChannelID, messageId: MessageID): Message {
message_reference: null,
message_snapshots: null,
call: null,
nsfw_emojis: null,
has_reaction: null,
version: 1,
});
@@ -316,7 +316,6 @@ export async function processUserDeletion(
message_reference: null,
message_snapshots: null,
call: null,
nsfw_emojis: null,
has_reaction: false,
version: 1,
});
+142 -35
View File
@@ -26,8 +26,13 @@ const DLQ_SUBJECT_PREFIX = 'dlq.';
const DLQ_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const STREAM_MAX_MSGS = 2_000_000;
const STREAM_MAX_BYTES = 8 * 1024 * 1024 * 1024;
const STREAM_MIN_BYTES = 64 * 1024 * 1024;
const STREAM_MAX_MSGS_PER_SUBJECT = 250_000;
const STREAM_STORE_ERR_CODE = 10077;
const DLQ_MAX_BYTES = 64 * 1024 * 1024;
const DLQ_MIN_BYTES = 8 * 1024 * 1024;
const STREAM_FULL_ERR_CODES = new Set([10023, 10077]);
const STREAM_NO_STORAGE_ERR_CODE = 10047;
const STREAM_NAME_IN_USE_ERR_CODE = 10058;
const STREAM_LIMITS = {
max_msgs: STREAM_MAX_MSGS,
@@ -37,12 +42,19 @@ const STREAM_LIMITS = {
discard_new_per_subject: true,
} satisfies Partial<StreamConfig>;
function jsErrorCode(error: unknown): number | null {
if (!(error instanceof NatsError)) {
return null;
}
return error.jsError()?.err_code ?? null;
}
function describeStreamRejection(error: unknown): string | null {
if (!(error instanceof NatsError)) {
return null;
}
const apiError = error.jsError();
if (apiError?.err_code !== STREAM_STORE_ERR_CODE) {
if (apiError === null || !STREAM_FULL_ERR_CODES.has(apiError.err_code ?? 0)) {
return null;
}
return apiError.description ?? 'stream rejected the publish';
@@ -63,45 +75,116 @@ export class JetStreamWorkerQueue {
return;
}
const jsm = await this.connectionManager.getJetStreamManager();
let existingConfig: StreamConfig | null = null;
try {
existingConfig = (await jsm.streams.info(STREAM_NAME)).config;
} catch {
existingConfig = null;
}
const existingConfig = await this.readStreamConfig(jsm, STREAM_NAME);
if (existingConfig === null) {
await jsm.streams.add({
name: STREAM_NAME,
subjects: [`${SUBJECT_PREFIX}>`],
retention: RetentionPolicy.Workqueue,
storage: StorageType.File,
max_age: nanos(MAX_AGE_MS),
duplicate_window: nanos(2 * 60 * 1000),
num_replicas: 1,
...STREAM_LIMITS,
});
await this.addStream(jsm);
} else if (!this.hasStreamLimits(existingConfig)) {
await this.applyStreamLimits(jsm);
await this.applyStreamLimits(jsm, existingConfig);
}
this.streamReady = true;
}
private async readStreamConfig(jsm: JetStreamManager, name: string): Promise<StreamConfig | null> {
try {
return (await jsm.streams.info(name)).config;
} catch {
return null;
}
}
private async fitToStorageBudget(
startBytes: number,
minBytes: number,
apply: (maxBytes: number) => Promise<unknown>,
): Promise<number> {
let maxBytes = startBytes;
for (;;) {
try {
await apply(maxBytes);
return maxBytes;
} catch (error) {
if (jsErrorCode(error) !== STREAM_NO_STORAGE_ERR_CODE || maxBytes <= minBytes) {
throw error;
}
maxBytes = Math.floor(maxBytes / 2);
}
}
}
private async adoptConcurrentStream(jsm: JetStreamManager, name: string, error: unknown): Promise<boolean> {
if (jsErrorCode(error) !== STREAM_NAME_IN_USE_ERR_CODE) {
return false;
}
const config = await this.readStreamConfig(jsm, name);
if (config === null) {
return false;
}
Logger.info({stream: name, max_bytes: config.max_bytes}, 'Stream was created concurrently, adopting it');
return true;
}
private async addStream(jsm: JetStreamManager): Promise<void> {
let maxBytes: number;
try {
maxBytes = await this.fitToStorageBudget(STREAM_MAX_BYTES, STREAM_MIN_BYTES, (bytes) =>
jsm.streams.add({
name: STREAM_NAME,
subjects: [`${SUBJECT_PREFIX}>`],
retention: RetentionPolicy.Workqueue,
storage: StorageType.File,
max_age: nanos(MAX_AGE_MS),
duplicate_window: nanos(2 * 60 * 1000),
num_replicas: 1,
...STREAM_LIMITS,
max_bytes: bytes,
}),
);
} catch (error) {
if (await this.adoptConcurrentStream(jsm, STREAM_NAME, error)) {
return;
}
if (jsErrorCode(error) === STREAM_NO_STORAGE_ERR_CODE) {
Logger.error(
{err: error, stream: STREAM_NAME, max_bytes: STREAM_MIN_BYTES},
'Jobs stream does not fit the JetStream storage budget at its smallest size, free disk space on the NATS store',
);
}
throw error;
}
if (maxBytes !== STREAM_MAX_BYTES) {
Logger.warn(
{stream: STREAM_NAME, max_bytes: maxBytes},
'Created the jobs stream below its target size to fit the JetStream storage budget',
);
}
}
private hasStreamLimits(config: StreamConfig): boolean {
return (
config.max_msgs === STREAM_MAX_MSGS &&
config.max_bytes === STREAM_MAX_BYTES &&
config.max_bytes > 0 &&
config.max_msgs_per_subject === STREAM_MAX_MSGS_PER_SUBJECT &&
config.discard === DiscardPolicy.New &&
config.discard_new_per_subject
);
}
private async applyStreamLimits(jsm: JetStreamManager): Promise<void> {
private async applyStreamLimits(jsm: JetStreamManager, existingConfig: StreamConfig): Promise<void> {
const startBytes = existingConfig.max_bytes > 0 ? existingConfig.max_bytes : STREAM_MAX_BYTES;
try {
await jsm.streams.update(STREAM_NAME, {...STREAM_LIMITS});
Logger.info({stream: STREAM_NAME, ...STREAM_LIMITS}, 'Applied jobs stream limits');
const maxBytes = await this.fitToStorageBudget(startBytes, STREAM_MIN_BYTES, (bytes) =>
jsm.streams.update(STREAM_NAME, {...STREAM_LIMITS, max_bytes: bytes}),
);
Logger.info({stream: STREAM_NAME, ...STREAM_LIMITS, max_bytes: maxBytes}, 'Applied jobs stream limits');
} catch (error) {
Logger.error({err: error, stream: STREAM_NAME}, 'Failed to apply jobs stream limits, stream stays unbounded');
if (jsErrorCode(error) !== STREAM_NO_STORAGE_ERR_CODE) {
Logger.error({err: error, stream: STREAM_NAME}, 'Failed to apply jobs stream limits');
return;
}
Logger.warn(
{err: error, stream: STREAM_NAME, max_bytes: existingConfig.max_bytes},
'JetStream has no room to bound the jobs stream, it keeps the limits it already has',
);
}
}
@@ -110,21 +193,45 @@ export class JetStreamWorkerQueue {
return;
}
const jsm = await this.connectionManager.getJetStreamManager();
try {
await jsm.streams.info(DLQ_STREAM_NAME);
} catch {
await jsm.streams.add({
name: DLQ_STREAM_NAME,
subjects: [`${DLQ_SUBJECT_PREFIX}>`],
retention: RetentionPolicy.Limits,
storage: StorageType.File,
max_age: nanos(DLQ_MAX_AGE_MS),
num_replicas: 1,
});
if ((await this.readStreamConfig(jsm, DLQ_STREAM_NAME)) === null) {
if (!(await this.addDlqStream(jsm))) {
return;
}
}
this.dlqStreamReady = true;
}
private async addDlqStream(jsm: JetStreamManager): Promise<boolean> {
try {
const maxBytes = await this.fitToStorageBudget(DLQ_MAX_BYTES, DLQ_MIN_BYTES, (bytes) =>
jsm.streams.add({
name: DLQ_STREAM_NAME,
subjects: [`${DLQ_SUBJECT_PREFIX}>`],
retention: RetentionPolicy.Limits,
storage: StorageType.File,
max_age: nanos(DLQ_MAX_AGE_MS),
num_replicas: 1,
max_bytes: bytes,
discard: DiscardPolicy.Old,
}),
);
Logger.info({stream: DLQ_STREAM_NAME, max_bytes: maxBytes}, 'Dead-letter stream created');
return true;
} catch (error) {
if (await this.adoptConcurrentStream(jsm, DLQ_STREAM_NAME, error)) {
return true;
}
if (jsErrorCode(error) !== STREAM_NO_STORAGE_ERR_CODE) {
throw error;
}
Logger.warn(
{err: error, stream: DLQ_STREAM_NAME},
'JetStream has no room for the dead-letter stream, failed jobs stay in the jobs stream until they expire',
);
return false;
}
}
async ensureConsumers(lanes: ReadonlyArray<WorkerLaneDefinition>): Promise<void> {
if (this.consumersReady) {
return;
@@ -7,9 +7,12 @@ import {JetStreamWorkerQueue} from '../JetStreamWorkerQueue';
import {WORKER_LANES} from '../WorkerLaneConfig';
import {WorkerQueueOverflowError} from '../WorkerQueueOverflowError';
const GIB = 1024 * 1024 * 1024;
const MIB = 1024 * 1024;
const EXPECTED_LIMITS = {
max_msgs: 2_000_000,
max_bytes: 8 * 1024 * 1024 * 1024,
max_bytes: 8 * GIB,
max_msgs_per_subject: 250_000,
discard: DiscardPolicy.New,
discard_new_per_subject: true,
@@ -38,6 +41,22 @@ function streamLimitError(description: string): NatsError {
return error;
}
function serverResourceError(): NatsError {
const error = new NatsError('503', '503');
error.api_error = {code: 503, err_code: 10023, description: 'insufficient resources'};
return error;
}
function noStorageError(): NatsError {
const error = new NatsError('503', '503');
error.api_error = {code: 503, err_code: 10047, description: 'insufficient storage resources available'};
return error;
}
function storageBudget(budget: number): (config: Partial<StreamConfig>) => Error | null {
return (config) => ((config.max_bytes ?? 0) > budget ? noStorageError() : null);
}
function boundedPublisher(maxPerSubject: number): (subject: string) => {seq: number} {
const counts = new Map<string, number>();
let seq = 0;
@@ -54,15 +73,19 @@ function boundedPublisher(maxPerSubject: number): (subject: string) => {seq: num
function createQueue(params: {
existing?: StreamConfig | null;
dlqExists?: boolean;
reject?: (config: Partial<StreamConfig>) => Error | null;
updateError?: Error;
publish?: (subject: string) => {seq: number};
}): {
queue: JetStreamWorkerQueue;
added: Array<Partial<StreamConfig>>;
dlqAdded: Array<Partial<StreamConfig>>;
updated: Array<Partial<StreamConfig>>;
consumerAdds: Array<ConsumerAddConfig>;
} {
const added: Array<Partial<StreamConfig>> = [];
const dlqAdded: Array<Partial<StreamConfig>> = [];
const updated: Array<Partial<StreamConfig>> = [];
const consumerAdds: Array<ConsumerAddConfig> = [];
const connectionManager = {
@@ -77,14 +100,23 @@ function createQueue(params: {
info: () => Promise.reject(new Error('consumer not found')),
},
streams: {
info: () => {
info: (name: string) => {
if (name === 'JOBS_DLQ') {
return params.dlqExists
? Promise.resolve({config: {name} as StreamConfig})
: Promise.reject(new Error('stream not found'));
}
if (!params.existing) {
return Promise.reject(new Error('stream not found'));
}
return Promise.resolve({config: params.existing});
},
add: (config: Partial<StreamConfig>) => {
added.push(config);
(config.name === 'JOBS_DLQ' ? dlqAdded : added).push(config);
const rejection = params.reject?.(config) ?? null;
if (rejection !== null) {
return Promise.reject(rejection);
}
return Promise.resolve({config});
},
update: (_name: string, config: Partial<StreamConfig>) => {
@@ -92,6 +124,10 @@ function createQueue(params: {
return Promise.reject(params.updateError);
}
updated.push(config);
const rejection = params.reject?.(config) ?? null;
if (rejection !== null) {
return Promise.reject(rejection);
}
return Promise.resolve({config});
},
},
@@ -103,7 +139,7 @@ function createQueue(params: {
},
}),
} as unknown as JetStreamConnectionManager;
return {queue: new JetStreamWorkerQueue(connectionManager), added, updated, consumerAdds};
return {queue: new JetStreamWorkerQueue(connectionManager), added, dlqAdded, updated, consumerAdds};
}
describe('jobs stream limits', () => {
@@ -116,6 +152,28 @@ describe('jobs stream limits', () => {
expect(added[0]).toMatchObject({retention: RetentionPolicy.Workqueue, storage: StorageType.File});
});
it('lowers the new stream until the server storage budget accepts it', async () => {
const {queue, added} = createQueue({existing: null, reject: storageBudget(2 * GIB)});
await queue.ensureStream();
expect(added.map((config) => config.max_bytes)).toEqual([8 * GIB, 4 * GIB, 2 * GIB]);
expect(added[2]).toMatchObject({...EXPECTED_LIMITS, max_bytes: 2 * GIB});
expect(added[2]).toMatchObject({retention: RetentionPolicy.Workqueue, storage: StorageType.File});
});
it('fails the boot when even the smallest jobs stream does not fit', async () => {
const {queue, added} = createQueue({existing: null, reject: storageBudget(0)});
await expect(queue.ensureStream()).rejects.toBeInstanceOf(NatsError);
expect(added).toHaveLength(8);
expect(added[7]?.max_bytes).toBe(64 * MIB);
});
it('rethrows stream creation failures that are not storage rejections', async () => {
const failure = new Error('stream name already in use');
const {queue, added} = createQueue({existing: null, reject: () => failure});
await expect(queue.ensureStream()).rejects.toBe(failure);
expect(added).toHaveLength(1);
});
it('applies the limits to an existing unbounded stream', async () => {
const {queue, added, updated} = createQueue({existing: LEGACY_CONFIG});
await queue.ensureStream();
@@ -124,6 +182,12 @@ describe('jobs stream limits', () => {
expect(updated[0]).toEqual(EXPECTED_LIMITS);
});
it('lowers the limit update until the server storage budget accepts it', async () => {
const {queue, updated} = createQueue({existing: LEGACY_CONFIG, reject: storageBudget(1 * GIB)});
await queue.ensureStream();
expect(updated.map((config) => config.max_bytes)).toEqual([8 * GIB, 4 * GIB, 2 * GIB, 1 * GIB]);
});
it('leaves an already bounded stream alone', async () => {
const {queue, added, updated} = createQueue({existing: {...LEGACY_CONFIG, ...EXPECTED_LIMITS}});
await queue.ensureStream();
@@ -131,10 +195,39 @@ describe('jobs stream limits', () => {
expect(updated).toHaveLength(0);
});
it('leaves a stream that was lowered to fit the server alone', async () => {
const {queue, added, updated} = createQueue({
existing: {...LEGACY_CONFIG, ...EXPECTED_LIMITS, max_bytes: 1536 * MIB},
});
await queue.ensureStream();
expect(added).toHaveLength(0);
expect(updated).toHaveLength(0);
});
it('keeps startup alive when the limit update is rejected', async () => {
const {queue} = createQueue({existing: LEGACY_CONFIG, updateError: new Error('stream update rejected')});
await expect(queue.ensureStream()).resolves.toBeUndefined();
});
it('keeps startup alive when no limit update fits the server', async () => {
const {queue, updated} = createQueue({existing: LEGACY_CONFIG, reject: storageBudget(0)});
await expect(queue.ensureStream()).resolves.toBeUndefined();
expect(updated).toHaveLength(8);
});
});
describe('dead-letter stream', () => {
it('keeps startup alive when the dead-letter stream does not fit', async () => {
const {queue, dlqAdded} = createQueue({dlqExists: false, reject: () => noStorageError()});
await expect(queue.ensureDlqStream()).resolves.toBeUndefined();
expect(dlqAdded).toHaveLength(4);
});
it('rethrows dead-letter creation failures that are not storage rejections', async () => {
const failure = new Error('no responders');
const {queue} = createQueue({dlqExists: false, reject: () => failure});
await expect(queue.ensureDlqStream()).rejects.toBe(failure);
});
});
describe('jobs stream enqueue shedding', () => {
@@ -152,6 +245,16 @@ describe('jobs stream enqueue shedding', () => {
await expect(queue.enqueue('handleMentions', {})).resolves.toBe('2');
});
it('sheds enqueues the server refuses for lack of resources', async () => {
const {queue} = createQueue({
existing: LEGACY_CONFIG,
publish: () => {
throw serverResourceError();
},
});
await expect(queue.enqueue('handleMentions', {})).rejects.toBeInstanceOf(WorkerQueueOverflowError);
});
it('rethrows publish failures that are not stream limits', async () => {
const failure = new Error('no responders');
const {queue} = createQueue({
@@ -16,6 +16,7 @@ import {NullSpaceDropIndicator} from '@app/features/app/components/layout/NullSp
import {ScrollIndicatorOverlay} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
import {
shouldShowCategoryWhenHidingMutedChannels,
shouldShowChannelInCollapsedCategory,
shouldShowChannelWhenHidingMutedChannels,
} from '@app/features/app/components/layout/utils/ChannelListVisibility';
@@ -471,8 +472,10 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
if (
hideMutedChannels &&
group.category &&
filteredTextChannels.length === 0 &&
filteredVoiceChannels.length === 0
!shouldShowCategoryWhenHidingMutedChannels({
hasChannels: group.textChannels.length > 0 || group.voiceChannels.length > 0,
hasVisibleChannels: filteredTextChannels.length > 0 || filteredVoiceChannels.length > 0,
})
) {
continue;
}
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {shouldShowChannelInCollapsedCategory, shouldShowChannelWhenHidingMutedChannels} from './ChannelListVisibility';
import {
shouldShowCategoryWhenHidingMutedChannels,
shouldShowChannelInCollapsedCategory,
shouldShowChannelWhenHidingMutedChannels,
} from './ChannelListVisibility';
describe('shouldShowChannelWhenHidingMutedChannels', () => {
it('keeps muted channels with visible unread state so mentions are findable', () => {
@@ -57,6 +61,35 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
});
});
describe('shouldShowCategoryWhenHidingMutedChannels', () => {
it('keeps a category that has no channels at all', () => {
expect(
shouldShowCategoryWhenHidingMutedChannels({
hasChannels: false,
hasVisibleChannels: false,
}),
).toBe(true);
});
it('hides a category whose channels were all filtered out as muted', () => {
expect(
shouldShowCategoryWhenHidingMutedChannels({
hasChannels: true,
hasVisibleChannels: false,
}),
).toBe(false);
});
it('keeps a category that still has visible channels', () => {
expect(
shouldShowCategoryWhenHidingMutedChannels({
hasChannels: true,
hasVisibleChannels: true,
}),
).toBe(true);
});
});
describe('shouldShowChannelInCollapsedCategory', () => {
it('keeps visible unread channels reachable in collapsed categories', () => {
expect(
@@ -16,6 +16,18 @@ export function shouldShowChannelWhenHidingMutedChannels({
return isSelected || isConnected || !isMuted || hasVisibleUnread;
}
export interface HiddenMutedCategoryVisibilityInput {
hasChannels: boolean;
hasVisibleChannels: boolean;
}
export function shouldShowCategoryWhenHidingMutedChannels({
hasChannels,
hasVisibleChannels,
}: HiddenMutedCategoryVisibilityInput): boolean {
return !hasChannels || hasVisibleChannels;
}
export interface CollapsedCategoryChannelVisibilityInput {
isCategoryMuted: boolean;
isSelected: boolean;
@@ -1,78 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {useMatureMedia} from '@app/features/messaging/hooks/useMatureMedia';
import styles from '@app/features/theme/styles/MatureBlur.module.css';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type {ReactElement} from 'react';
import React, {useCallback} from 'react';
const REVEAL_SENSITIVE_EMOJI_DESCRIPTOR = msg({
message: 'Reveal sensitive emoji',
comment: 'Accessible label for a blurred emoji button. Activating it reveals the emoji when allowed.',
});
interface MatureEmojiWrapperProps {
mature: boolean;
channelId: string | undefined;
children: ReactElement<{className?: string}>;
}
export const MatureEmojiWrapper = observer(
({mature, channelId, children}: MatureEmojiWrapperProps): ReactElement | null => {
const {i18n} = useLingui();
const {shouldBlur, shouldBlock, canReveal, reveal} = useMatureMedia(mature, channelId);
const handleReveal = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
if (shouldBlur && canReveal) {
e.preventDefault();
e.stopPropagation();
reveal();
}
},
[shouldBlur, canReveal, reveal],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isKeyboardActivationKey(e.key)) {
return;
}
handleReveal(e);
},
[handleReveal],
);
if (shouldBlock) {
return null;
}
if (shouldBlur) {
if (!canReveal) {
return (
<span className={styles.matureBlurContainer} data-flx="app.mature-emoji-wrapper.mature-blur-container">
{React.cloneElement(children, {
className: clsx(children.props.className, styles.matureBlurred),
})}
</span>
);
}
return (
<span
className={styles.matureBlurContainer}
onClick={handleReveal}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-label={i18n._(REVEAL_SENSITIVE_EMOJI_DESCRIPTOR)}
data-flx="app.mature-emoji-wrapper.mature-blur-container.reveal"
>
{React.cloneElement(children, {
className: clsx(children.props.className, styles.matureBlurred),
})}
</span>
);
}
return children;
},
);
@@ -86,6 +86,12 @@ export function useConnectionNotice(): ConnectionNotice | null {
}, BOOT_NOTICE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [booting]);
useEffect(() => {
if (!reconnecting) {
return;
}
StatusPage.refreshForConnectionIssue();
}, [reconnecting]);
if (Nagbar.forceHideConnectionNotice) {
return null;
}
@@ -461,8 +461,7 @@ const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: S
});
const stickerRecord = Sticker.getStickerById(sticker.id);
const guild = stickerRecord?.guildId ? Guilds.getGuild(stickerRecord.guildId) : null;
const isMature = !!stickerRecord?.nsfw || !!sticker.nsfw;
const {shouldBlur, shouldBlock, canReveal, reveal} = useMatureMedia(isMature, message.channelId);
const {shouldBlur, shouldBlock, canReveal, reveal} = useMatureMedia(false, message.channelId);
const tooltipContent = () => (
<div className={styles.stickerTooltip} data-flx="channel.message-attachments.tooltip-content.sticker-tooltip">
<span className={styles.stickerName} data-flx="channel.message-attachments.tooltip-content.sticker-name">
@@ -502,7 +501,6 @@ const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: S
tags: [],
url: stickerUrl,
animated: sticker.animated,
nsfw: Boolean(sticker.nsfw),
user: undefined,
};
ContextMenuCommands.openFromEvent(e, ({onClose}) => (
@@ -136,11 +136,7 @@ const MessageReactionItem = observer(
};
const emojiName = getEmojiName(reaction.emoji);
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering: isHovering || tooltipHovering});
const isMature = !!emojiRecord?.nsfw;
const {shouldBlur: reactionShouldBlur, shouldBlock: reactionShouldBlock} = useMatureMedia(
isMature,
message.channelId,
);
const {shouldBlur: reactionShouldBlur, shouldBlock: reactionShouldBlock} = useMatureMedia(false, message.channelId);
const variants = {
up: {y: -20, opacity: 0},
down: {y: 20, opacity: 0},
@@ -437,7 +437,6 @@ export const EmojiListItem: React.FC<{
allNamesString: `:${emoji.name}:`,
url: emojiUrl,
animated: emoji.animated,
nsfw: emoji.nsfw,
user: emoji.user,
};
const handleContextMenu = (event: React.MouseEvent) => {
@@ -125,7 +125,6 @@ export const StickerGridItem = observer(function StickerGridItem({
tags: sticker.tags,
url: stickerUrl,
animated: sticker.animated,
nsfw: sticker.nsfw,
user: sticker.user,
};
const handleContextMenu = (event: React.MouseEvent) => {
@@ -10,7 +10,6 @@ export interface GuildEmojiShape {
allNamesString: string;
url: string;
animated: boolean;
nsfw?: boolean;
user?: UserPartial;
}
@@ -12,7 +12,6 @@ export class GuildEmoji {
readonly allNamesString: string;
readonly url: string;
readonly animated: boolean;
readonly nsfw: boolean;
readonly user?: UserPartial;
constructor(guildId: string, data: WireGuildEmoji) {
@@ -23,7 +22,6 @@ export class GuildEmoji {
this.allNamesString = `:${data.name}:`;
this.url = buildCustomEmojiURL({id: data.id, animated: data.animated});
this.animated = data.animated;
this.nsfw = data.nsfw;
this.user = data.user;
}
@@ -32,7 +30,6 @@ export class GuildEmoji {
id: updates.id ?? this.id,
name: updates.name ?? this.name,
animated: updates.animated ?? this.animated,
nsfw: updates.nsfw ?? this.nsfw,
user: updates.user ?? this.user,
});
}
@@ -43,7 +40,6 @@ export class GuildEmoji {
this.guildId === other.guildId &&
this.name === other.name &&
this.animated === other.animated &&
this.nsfw === other.nsfw &&
this.user?.id === other.user?.id
);
}
@@ -53,7 +49,6 @@ export class GuildEmoji {
id: this.id,
name: this.name,
animated: this.animated,
nsfw: this.nsfw,
user: this.user,
};
}
@@ -12,7 +12,6 @@ export class GuildSticker {
readonly tags: ReadonlyArray<string>;
readonly url: string;
readonly animated: boolean;
readonly nsfw: boolean;
readonly user?: UserPartial;
constructor(guildId: string, data: WireGuildSticker) {
@@ -27,7 +26,6 @@ export class GuildSticker {
size: 320,
});
this.animated = data.animated;
this.nsfw = data.nsfw;
this.user = data.user;
}
@@ -38,7 +36,6 @@ export class GuildSticker {
description: updates.description ?? this.description,
tags: updates.tags ?? [...this.tags],
animated: updates.animated ?? this.animated,
nsfw: updates.nsfw ?? this.nsfw,
user: updates.user ?? this.user,
});
}
@@ -51,7 +48,6 @@ export class GuildSticker {
this.description === other.description &&
JSON.stringify(this.tags) === JSON.stringify(other.tags) &&
this.animated === other.animated &&
this.nsfw === other.nsfw &&
this.user?.id === other.user?.id
);
}
@@ -63,7 +59,6 @@ export class GuildSticker {
description: this.description,
tags: [...this.tags],
animated: this.animated,
nsfw: this.nsfw,
user: this.user,
};
}
@@ -32,6 +32,7 @@ const GATEWAY_TIMEOUTS = {
ResumeWindow: 180000,
MinReconnect: 1000,
MaxReconnect: 60000,
ReconnectSpread: 2000,
Hello: 20000,
} as const;
export const GatewayState = {
@@ -1034,7 +1035,7 @@ export class GatewaySocket extends EventEmitter<GatewaySocketEvents> {
}
const allowImmediate = options.allowImmediate ?? true;
const wasImmediate = allowImmediate && this.shouldReconnectImmediately;
const delay = wasImmediate ? 0 : this.nextReconnectDelay();
const delay = wasImmediate ? this.reconnectSpread() : this.nextReconnectDelay();
this.shouldReconnectImmediately = false;
this.log.info(`Scheduling reconnect in ${delay}ms${wasImmediate ? ' (immediate)' : ''}`);
this.reconnectTimeoutId = window.setTimeout(() => {
@@ -1047,12 +1048,16 @@ export class GatewaySocket extends EventEmitter<GatewaySocketEvents> {
}, delay);
}
private reconnectSpread(): number {
return Math.floor(Math.random() * GATEWAY_TIMEOUTS.ReconnectSpread);
}
private nextReconnectDelay(): number {
const now = Date.now();
const elapsed = now - this.lastReconnectAt;
if (elapsed < GATEWAY_TIMEOUTS.MinReconnect) {
this.log.debug(`Last reconnect ${elapsed}ms ago, enforcing minimum delay (${GATEWAY_TIMEOUTS.MinReconnect}ms)`);
return GATEWAY_TIMEOUTS.MinReconnect;
return GATEWAY_TIMEOUTS.MinReconnect + this.reconnectSpread();
}
this.lastReconnectAt = now;
const delay = this.reconnectBackoff.next();
File diff suppressed because it is too large Load Diff
@@ -1371,6 +1371,9 @@
{
"msgid": "Capture card"
},
{
"msgid": "Capture entire system audio"
},
{
"msgid": "Change stream"
},
@@ -1857,6 +1860,12 @@
{
"msgid": "Share stream audio"
},
{
"msgid": "Shared window"
},
{
"msgid": "Shared window audio"
},
{
"msgid": "Sharper text at 720p, 30 FPS"
},
@@ -1971,6 +1980,9 @@
{
"msgid": "Turn off audio sharing for this source or try again in a moment."
},
{
"msgid": "Unable to send verification code"
},
{
"msgid": "Unavailable"
},
@@ -1992,6 +2004,9 @@
{
"msgid": "Volume sliders"
},
{
"msgid": "We'll send a verification code to your email before you can view your backup codes."
},
{
"msgid": "Welcome"
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More