feat(message): remove the unfinished scheduled messages feature (#2233)

This commit is contained in:
Hampus
2026-08-31 13:32:55 +02:00
committed by GitHub
parent eeea391b63
commit 3c8466d714
125 changed files with 17 additions and 3730 deletions
-2
View File
@@ -70,7 +70,6 @@
"jose": "catalog:", "jose": "catalog:",
"livekit-server-sdk": "catalog:", "livekit-server-sdk": "catalog:",
"lodash": "catalog:", "lodash": "catalog:",
"luxon": "catalog:",
"maxmind": "catalog:", "maxmind": "catalog:",
"mime": "catalog:", "mime": "catalog:",
"nats": "catalog:", "nats": "catalog:",
@@ -89,7 +88,6 @@
"devDependencies": { "devDependencies": {
"@types/archiver": "catalog:", "@types/archiver": "catalog:",
"@types/lodash": "catalog:", "@types/lodash": "catalog:",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"esbuild": "catalog:", "esbuild": "catalog:",
"msw": "catalog:", "msw": "catalog:",
-7
View File
@@ -305,8 +305,6 @@ import {
type RelationshipRow, type RelationshipRow,
SAVED_MESSAGE_COLUMNS, SAVED_MESSAGE_COLUMNS,
type SavedMessageRow, type SavedMessageRow,
SCHEDULED_MESSAGE_COLUMNS,
type ScheduledMessageRow,
USER_BY_EMAIL_COLUMNS, USER_BY_EMAIL_COLUMNS,
USER_BY_LAST_ACTIVE_IP_COLUMNS, USER_BY_LAST_ACTIVE_IP_COLUMNS,
USER_BY_LAST_ACTIVE_IP_TRUST_KEY_COLUMNS, USER_BY_LAST_ACTIVE_IP_TRUST_KEY_COLUMNS,
@@ -684,11 +682,6 @@ export const SavedMessages = defineTable<SavedMessageRow, 'user_id' | 'message_i
columns: SAVED_MESSAGE_COLUMNS, columns: SAVED_MESSAGE_COLUMNS,
primaryKey: ['user_id', 'message_id'], primaryKey: ['user_id', 'message_id'],
}); });
export const ScheduledMessages = defineTable<ScheduledMessageRow, 'user_id' | 'scheduled_message_id'>({
name: 'scheduled_messages',
columns: SCHEDULED_MESSAGE_COLUMNS,
primaryKey: ['user_id', 'scheduled_message_id'],
});
export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 'subscription_id'>({ export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 'subscription_id'>({
name: 'push_subscriptions', name: 'push_subscriptions',
columns: PUSH_SUBSCRIPTION_COLUMNS, columns: PUSH_SUBSCRIPTION_COLUMNS,
-2
View File
@@ -66,8 +66,6 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
const origin = 'origin' in issue ? String(issue.origin) : undefined; const origin = 'origin' in issue ? String(issue.origin) : undefined;
if (origin === 'string') { if (origin === 'string') {
errorCode = ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH; errorCode = ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH;
} else if (origin === 'date') {
errorCode = ValidationErrorCodes.SCHEDULED_TIME_MUST_BE_FUTURE;
} else { } else {
errorCode = ValidationErrorCodes.INVALID_FORMAT; errorCode = ValidationErrorCodes.INVALID_FORMAT;
} }
@@ -1,55 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
ScheduledMessageRequestSchema,
ScheduledMessageResponseSchema,
} from '@fluxer/schema/src/domains/message/ScheduledMessageSchemas';
import {createChannelID} from '../../BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
import {parseScheduledMessageInput} from './ScheduledMessageParsing';
export function ScheduledMessageController(app: HonoApp) {
app.post(
'/channels/:channel_id/messages/schedule',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_CREATE),
LoginRequired,
DefaultUserOnly,
Validator('param', ChannelIdParam),
OpenAPI({
operationId: 'schedule_message',
summary: 'Schedule a message to send later',
description:
'Schedules a message to be sent at a specified time. Only available for regular user accounts. Requires permission to send messages in the target channel. Message is sent automatically at the scheduled time. Returns the scheduled message object with delivery time.',
responseSchema: ScheduledMessageResponseSchema,
requestSchema: ScheduledMessageRequestSchema,
requestFormSchema: ScheduledMessageRequestSchema,
statusCode: 201,
security: ['bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const user = ctx.get('user');
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const scheduledMessageService = ctx.get('scheduledMessageService');
const {message, scheduledLocalAt, timezone} = await parseScheduledMessageInput({
ctx,
user,
channelId,
});
const scheduledMessage = await scheduledMessageService.createScheduledMessage({
user,
channelId,
data: message,
scheduledLocalAt,
timezone,
});
return ctx.json(scheduledMessage.toResponse(), 201);
},
);
}
@@ -1,78 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MessageRequestSchema} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import {ScheduledMessageRequestSchema} from '@fluxer/schema/src/domains/message/ScheduledMessageSchemas';
import type {Context} from 'hono';
import {ms} from 'itty-time';
import type {ChannelID} from '../../BrandedTypes';
import type {User} from '../../models/User';
import type {HonoEnv} from '../../types/HonoEnv';
import {parseJsonPreservingLargeIntegers} from '../../utils/LosslessJsonParser';
import type {MessageRequest} from '../MessageTypes';
import {normalizeMessageRequestPayload} from '../services/message/MessageRequestCompatibility';
import {parseMultipartMessageData} from '../services/message/MessageRequestParser';
type ScheduledMessageSchemaType = ScheduledMessageRequestSchema;
function extractScheduleFields(data: ScheduledMessageSchemaType): {
scheduled_local_at: string;
timezone: string;
message: MessageRequest;
} {
const {scheduled_local_at, timezone, ...messageData} = data;
return {
scheduled_local_at,
timezone,
message: messageData as MessageRequest,
};
}
export async function parseScheduledMessageInput({
ctx,
user,
channelId,
}: {
ctx: Context<HonoEnv>;
user: User;
channelId: ChannelID;
}): Promise<{
message: MessageRequest;
scheduledLocalAt: string;
timezone: string;
}> {
const contentType = ctx.req.header('content-type') ?? '';
const isMultipart = contentType.includes('multipart/form-data');
if (isMultipart) {
let parsedPayload: unknown = null;
const message = (await parseMultipartMessageData(ctx, user, channelId, MessageRequestSchema, {
uploadExpiresAt: new Date(Date.now() + ms('32 days')),
onPayloadParsed(payload) {
parsedPayload = payload;
},
})) as MessageRequest;
if (!parsedPayload) {
throw InputValidationError.fromCode('scheduled_message', ValidationErrorCodes.FAILED_TO_PARSE_MULTIPART_PAYLOAD);
}
const validation = ScheduledMessageRequestSchema.safeParse(normalizeMessageRequestPayload(parsedPayload));
if (!validation.success) {
throw InputValidationError.fromCode('scheduled_message', ValidationErrorCodes.INVALID_SCHEDULED_MESSAGE_PAYLOAD);
}
const {scheduled_local_at, timezone} = extractScheduleFields(validation.data);
return {message, scheduledLocalAt: scheduled_local_at, timezone};
}
let body: unknown;
try {
const raw = await ctx.req.text();
body = raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw);
} catch {
throw InputValidationError.fromCode('scheduled_message', ValidationErrorCodes.INVALID_SCHEDULED_MESSAGE_PAYLOAD);
}
const validation = ScheduledMessageRequestSchema.safeParse(normalizeMessageRequestPayload(body));
if (!validation.success) {
throw InputValidationError.fromCode('scheduled_message', ValidationErrorCodes.INVALID_SCHEDULED_MESSAGE_PAYLOAD);
}
const {scheduled_local_at, timezone, message} = extractScheduleFields(validation.data);
return {message, scheduledLocalAt: scheduled_local_at, timezone};
}
@@ -5,14 +5,12 @@ import {CallController} from './CallController';
import {ChannelController} from './ChannelController'; import {ChannelController} from './ChannelController';
import {MessageController} from './MessageController'; import {MessageController} from './MessageController';
import {MessageInteractionController} from './MessageInteractionController'; import {MessageInteractionController} from './MessageInteractionController';
import {ScheduledMessageController} from './ScheduledMessageController';
import {StreamController} from './StreamController'; import {StreamController} from './StreamController';
export function registerChannelControllers(app: HonoApp) { export function registerChannelControllers(app: HonoApp) {
ChannelController(app); ChannelController(app);
MessageInteractionController(app); MessageInteractionController(app);
MessageController(app); MessageController(app);
ScheduledMessageController(app);
CallController(app); CallController(app);
StreamController(app); StreamController(app);
} }
@@ -1,185 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
import {ms, seconds} from 'itty-time';
import {DateTime, IANAZone} from 'luxon';
import type {ChannelID, MessageID, UserID} from '../../BrandedTypes';
import {createMessageID} from '../../BrandedTypes';
import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
import type {ScheduledMessagePayload} from '../../models/ScheduledMessage';
import {ScheduledMessage} from '../../models/ScheduledMessage';
import type {User} from '../../models/User';
import type {ScheduledMessageRepository} from '../../user/repositories/ScheduledMessageRepository';
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
import type {MessageRequest} from '../MessageTypes';
import type {ChannelService} from './ChannelService';
export const SCHEDULED_MESSAGE_TTL_SECONDS = seconds('31 days');
const DEFAULT_TIMEZONE = 'UTC';
const WORKER_TASK_NAME = 'sendScheduledMessage';
interface ScheduleParams {
user: User;
channelId: ChannelID;
data: MessageRequest;
scheduledLocalAt: string;
timezone?: string;
}
interface UpdateScheduleParams extends ScheduleParams {
scheduledMessageId: MessageID;
existing?: ScheduledMessage;
}
interface SendScheduledMessageWorkerPayload extends WorkerJobPayload {
userId: string;
scheduledMessageId: string;
expectedScheduledAt: string;
}
export class ScheduledMessageService {
constructor(
private readonly channelService: ChannelService,
private readonly scheduledMessageRepository: ScheduledMessageRepository,
private readonly workerService: IWorkerService<WorkerTaskName>,
private readonly snowflakeService: ISnowflakeService,
) {}
async listScheduledMessages(userId: UserID): Promise<Array<ScheduledMessage>> {
return await this.scheduledMessageRepository.listScheduledMessages(userId);
}
async getScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<ScheduledMessage | null> {
return await this.scheduledMessageRepository.getScheduledMessage(userId, scheduledMessageId);
}
async createScheduledMessage(params: ScheduleParams): Promise<ScheduledMessage> {
return await this.upsertScheduledMessage({
...params,
scheduledMessageId: await createSnowflake(this.snowflakeService),
});
}
async updateScheduledMessage(params: UpdateScheduleParams): Promise<ScheduledMessage> {
return await this.upsertScheduledMessage({
...params,
existing:
params.existing ?? (await this.getScheduledMessage(params.user.id, params.scheduledMessageId)) ?? undefined,
});
}
async cancelScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<void> {
await this.scheduledMessageRepository.deleteScheduledMessage(userId, scheduledMessageId);
}
async markInvalid(userId: UserID, scheduledMessageId: MessageID, reason: string): Promise<void> {
await this.scheduledMessageRepository.markInvalid(
userId,
scheduledMessageId,
reason,
SCHEDULED_MESSAGE_TTL_SECONDS,
);
}
private async upsertScheduledMessage(params: UpdateScheduleParams): Promise<ScheduledMessage> {
const {user, channelId, data, scheduledLocalAt, timezone} = params;
if ((user.flags & UserFlags.STAFF) === 0n) {
throw new FeatureTemporarilyDisabledError();
}
await this.channelService.messages.send.validateMessageCanBeSent({
user,
channelId,
data,
});
const scheduledAt = this.resolveScheduledAt(scheduledLocalAt, timezone);
const payload = toScheduledPayload(data);
const existing = params.existing;
const message = new ScheduledMessage({
userId: user.id,
id: params.scheduledMessageId,
channelId,
scheduledAt,
scheduledLocalAt,
timezone: timezone ?? DEFAULT_TIMEZONE,
payload,
status: 'pending',
statusReason: null,
createdAt: existing?.createdAt,
invalidatedAt: null,
});
await this.scheduledMessageRepository.upsertScheduledMessage(message, SCHEDULED_MESSAGE_TTL_SECONDS);
await this.scheduleWorker(message);
return message;
}
private resolveScheduledAt(local: string, timezone?: string): Date {
const zone = timezone?.trim() || DEFAULT_TIMEZONE;
if (!IANAZone.isValidZone(zone)) {
throw InputValidationError.fromCode('timezone', ValidationErrorCodes.INVALID_TIMEZONE_IDENTIFIER);
}
const dt = DateTime.fromISO(local, {zone});
if (!dt.isValid) {
throw InputValidationError.fromCode(
'scheduled_local_at',
ValidationErrorCodes.INVALID_DATETIME_FOR_SCHEDULED_SEND,
);
}
const scheduledAt = dt.toJSDate();
const now = Date.now();
const diffMs = scheduledAt.getTime() - now;
if (diffMs <= 0) {
throw InputValidationError.fromCode('scheduled_local_at', ValidationErrorCodes.SCHEDULED_TIME_MUST_BE_FUTURE);
}
if (diffMs > ms('30 days')) {
throw InputValidationError.fromCode('scheduled_local_at', ValidationErrorCodes.SCHEDULED_MESSAGES_MAX_30_DAYS);
}
return scheduledAt;
}
private async scheduleWorker(message: ScheduledMessage): Promise<void> {
const payload: SendScheduledMessageWorkerPayload = {
userId: message.userId.toString(),
scheduledMessageId: message.id.toString(),
expectedScheduledAt: message.scheduledAt.toISOString(),
};
await this.workerService.addJob(WORKER_TASK_NAME, payload, {runAt: message.scheduledAt});
}
}
async function createSnowflake(snowflakeService: ISnowflakeService): Promise<MessageID> {
return createMessageID(await snowflakeService.generate());
}
function toScheduledPayload(data: MessageRequest): ScheduledMessagePayload {
return {
content: data.content ?? null,
embeds: data.embeds,
attachments: data.attachments,
message_reference: data.message_reference
? {
message_id: data.message_reference.message_id.toString(),
channel_id: data.message_reference.channel_id?.toString(),
guild_id: data.message_reference.guild_id?.toString(),
type: data.message_reference.type,
}
: undefined,
allowed_mentions: data.allowed_mentions
? {
parse: data.allowed_mentions.parse,
users: data.allowed_mentions.users?.map((id) => id.toString()),
roles: data.allowed_mentions.roles?.map((id) => id.toString()),
replied_user: data.allowed_mentions.replied_user,
}
: undefined,
flags: data.flags,
nonce: data.nonce,
favorite_meme_id: data.favorite_meme_id?.toString(),
sticker_ids: data.sticker_ids?.map((id) => id.toString()),
tts: data.tts,
};
}
@@ -1,177 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {ScheduledMessageResponseSchema} from '@fluxer/schema/src/domains/message/ScheduledMessageSchemas';
import type {z} from 'zod';
import {ensureSessionStarted} from '../../message/tests/MessageTestUtils';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
type ScheduledMessageResponse = z.infer<typeof ScheduledMessageResponseSchema>;
export async function createGuildChannel(
harness: ApiTestHarness,
token: string,
guildId: string,
name: string,
): Promise<{
id: string;
}> {
const {response, json} = await createBuilder<{
id: string;
}>(harness, token)
.post(`/guilds/${guildId}/channels`)
.body({name, type: 0})
.executeWithResponse();
if (response.status !== 200 && response.status !== 201) {
throw new Error(`Failed to create guild channel: ${response.status}`);
}
return json as {
id: string;
};
}
export async function grantStaffAccess(harness: ApiTestHarness, userId: string): Promise<void> {
await createBuilderWithoutAuth(harness).patch(`/test/users/${userId}/flags`).body({flags: 1}).execute();
}
export async function scheduleMessage(
harness: ApiTestHarness,
channelId: string,
token: string,
content: string,
scheduledAt?: Date,
): Promise<ScheduledMessageResponse> {
const scheduledTime = scheduledAt ?? new Date(Date.now() + 60 * 1000);
await ensureSessionStarted(harness, token);
const {json} = await createBuilder<ScheduledMessageResponse>(harness, token)
.post(`/channels/${channelId}/messages/schedule`)
.body({
content,
scheduled_local_at: scheduledTime.toISOString(),
timezone: 'UTC',
})
.expect(201)
.executeWithResponse();
return json as ScheduledMessageResponse;
}
export async function updateScheduledMessage(
harness: ApiTestHarness,
scheduledMessageId: string,
token: string,
updates: {
content?: string;
scheduled_local_at?: string;
timezone?: string;
},
): Promise<ScheduledMessageResponse> {
const {json} = await createBuilder<ScheduledMessageResponse>(harness, token)
.patch(`/users/@me/scheduled-messages/${scheduledMessageId}`)
.body(updates)
.expect(200)
.executeWithResponse();
return json as ScheduledMessageResponse;
}
export async function getScheduledMessages(
harness: ApiTestHarness,
token: string,
): Promise<Array<ScheduledMessageResponse>> {
const {json} = await createBuilder<Array<ScheduledMessageResponse>>(harness, token)
.get('/users/@me/scheduled-messages')
.expect(200)
.executeWithResponse();
return json;
}
export async function getScheduledMessage(
harness: ApiTestHarness,
scheduledMessageId: string,
token: string,
expectedStatus: 200 | 404 = 200,
): Promise<ScheduledMessageResponse | null> {
const {response, json} = await createBuilder<ScheduledMessageResponse>(harness, token)
.get(`/users/@me/scheduled-messages/${scheduledMessageId}`)
.expect(expectedStatus)
.executeWithResponse();
if (response.status === 404) {
return null;
}
return json as ScheduledMessageResponse;
}
export async function cancelScheduledMessage(
harness: ApiTestHarness,
scheduledMessageId: string,
token: string,
): Promise<void> {
await createBuilder<void>(harness, token)
.delete(`/users/@me/scheduled-messages/${scheduledMessageId}`)
.expect(204)
.execute();
}
export async function triggerScheduledMessageWorker(
harness: ApiTestHarness,
userId: string,
scheduledMessageId: string,
): Promise<void> {
await createBuilder<void>(harness, '')
.post(`/test/worker/send-scheduled-message/${userId}/${scheduledMessageId}`)
.expect(200)
.execute();
}
export async function getChannelMessages(
harness: ApiTestHarness,
channelId: string,
token: string,
limit = 20,
): Promise<Array<MessageResponse>> {
const {json} = await createBuilder<Array<MessageResponse>>(harness, token)
.get(`/channels/${channelId}/messages?limit=${limit}`)
.expect(200)
.executeWithResponse();
return json;
}
export function messageFromAuthorContains(
messages: Array<MessageResponse>,
authorId: string,
content: string,
): boolean {
return messages.some((msg) => msg.author.id === authorId && msg.content === content);
}
export async function createChannelInvite(
harness: ApiTestHarness,
token: string,
channelId: string,
): Promise<{
code: string;
}> {
const {json} = await createBuilder<{
code: string;
}>(harness, token)
.post(`/channels/${channelId}/invites`)
.body({
max_age: 86400,
})
.expect(200)
.executeWithResponse();
return json;
}
export async function joinGuild(harness: ApiTestHarness, token: string, inviteCode: string): Promise<void> {
await createBuilder<void>(harness, token).post(`/invites/${inviteCode}`).body({}).expect(200).execute();
}
export async function removeGuildMember(
harness: ApiTestHarness,
token: string,
guildId: string,
userId: string,
): Promise<void> {
await createBuilder<void>(harness, token).delete(`/guilds/${guildId}/members/${userId}`).expect(204).execute();
}
@@ -1,38 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createGuild} from '../../guild/tests/GuildTestUtils';
import {ensureSessionStarted} from '../../message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createBuilder} from '../../test/TestRequestBuilder';
import {createGuildChannel, grantStaffAccess, scheduleMessage} from './ScheduledMessageTestUtils';
describe('Scheduled message staff gating', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('rejects scheduling message before staff flag granted', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-flag');
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled-channel');
await ensureSessionStarted(harness, owner.token);
await createBuilder(harness, owner.token)
.post(`/channels/${channel.id}/messages/schedule`)
.body({
content: 'trying to schedule',
scheduled_local_at: new Date(Date.now() + 60 * 1000).toISOString(),
timezone: 'UTC',
})
.expect(403)
.execute();
await grantStaffAccess(harness, owner.userId);
const scheduled = await scheduleMessage(harness, channel.id, owner.token, 'enabled now');
expect(scheduled.id).toBeDefined();
expect(scheduled.id).not.toBe('');
});
});
@@ -1,131 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createGuild} from '../../guild/tests/GuildTestUtils';
import {ensureSessionStarted} from '../../message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {generateFutureTimestamp, generatePastTimestamp, HTTP_STATUS, TEST_LIMITS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {createGuildChannel, grantStaffAccess, scheduleMessage} from './ScheduledMessageTestUtils';
describe('Scheduled message validation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('rejects scheduling message with past time', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'sched-validation-past');
await grantStaffAccess(harness, owner.userId);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
await ensureSessionStarted(harness, owner.token);
const pastTime = generatePastTimestamp(1);
const {json} = await createBuilder<{
errors: Array<{
path: string;
message: string;
code: string;
}>;
}>(harness, owner.token)
.post(`/channels/${channel.id}/messages/schedule`)
.body({
content: 'should fail - past time',
scheduled_local_at: pastTime,
timezone: 'UTC',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
const errorJson = json as {
errors: Array<{
path: string;
message: string;
code: string;
}>;
};
const hasScheduledAtError = errorJson.errors.some((e) => e.path === 'scheduled_local_at');
expect(hasScheduledAtError).toBe(true);
});
it('rejects scheduling message exceeding 30 days', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'sched-validation-30day');
await grantStaffAccess(harness, owner.userId);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
await ensureSessionStarted(harness, owner.token);
const futureTime = new Date(Date.now() + TEST_LIMITS.SCHEDULED_MESSAGE_MAX_DELAY_MS).toISOString();
const {json} = await createBuilder<{
errors: Array<{
path: string;
message: string;
code: string;
}>;
}>(harness, owner.token)
.post(`/channels/${channel.id}/messages/schedule`)
.body({
content: 'should fail - exceeds 30 days',
scheduled_local_at: futureTime,
timezone: 'UTC',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
const errorJson = json as {
errors: Array<{
path: string;
message: string;
code: string;
}>;
};
const hasScheduledAtError = errorJson.errors.some((e) => e.path === 'scheduled_local_at');
expect(hasScheduledAtError).toBe(true);
});
it('rejects scheduling message with invalid timezone', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'sched-validation-tz');
await grantStaffAccess(harness, owner.userId);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
await ensureSessionStarted(harness, owner.token);
const futureTime = generateFutureTimestamp(5);
const {json} = await createBuilder<{
errors: Array<{
path: string;
message: string;
code: string;
}>;
}>(harness, owner.token)
.post(`/channels/${channel.id}/messages/schedule`)
.body({
content: 'should fail - invalid timezone',
scheduled_local_at: futureTime,
timezone: 'Invalid/NotATimezone',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
const errorJson = json as {
errors: Array<{
path: string;
message: string;
code: string;
}>;
};
const hasTimezoneError = errorJson.errors.some((e) => e.path === 'timezone');
expect(hasTimezoneError).toBe(true);
});
it('accepts scheduling message at 30 day boundary', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'sched-validation-boundary');
await grantStaffAccess(harness, owner.userId);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
const futureTime = new Date(Date.now() + 29 * 24 * 60 * 60 * 1000 + 23 * 60 * 60 * 1000).toISOString();
const scheduled = await scheduleMessage(
harness,
channel.id,
owner.token,
'should succeed - within 30 days',
new Date(futureTime),
);
expect(scheduled.status).toBe('pending');
});
});
@@ -1,91 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createGuild} from '../../guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {
createChannelInvite,
createGuildChannel,
getChannelMessages,
getScheduledMessage,
grantStaffAccess,
joinGuild,
messageFromAuthorContains,
removeGuildMember,
scheduleMessage,
triggerScheduledMessageWorker,
updateScheduledMessage,
} from './ScheduledMessageTestUtils';
describe('Scheduled message worker lifecycle', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('delivers scheduled message when permissions remain', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-messages');
await grantStaffAccess(harness, owner.userId);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled');
const content = 'scheduled message goes through';
const scheduled = await scheduleMessage(harness, channel.id, owner.token, content);
await triggerScheduledMessageWorker(harness, owner.userId, scheduled.id);
const fetched = await getScheduledMessage(harness, scheduled.id, owner.token, 404);
expect(fetched).toBeNull();
const messages = await getChannelMessages(harness, channel.id, owner.token);
expect(messageFromAuthorContains(messages, owner.userId, content)).toBe(true);
});
it('reschedules pending message before worker execution', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-messages');
await grantStaffAccess(harness, owner.userId);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled');
const content = 'scheduled message initial content';
const scheduled = await scheduleMessage(harness, channel.id, owner.token, content);
const oldScheduledAt = new Date(scheduled.scheduled_at);
const updatedContent = 'scheduled message updated content';
const newLocalTime = new Date(Date.now() + 5 * 60 * 1000);
const newLocalStr = newLocalTime.toISOString();
const updated = await updateScheduledMessage(harness, scheduled.id, owner.token, {
content: updatedContent,
scheduled_local_at: newLocalStr,
timezone: 'America/Los_Angeles',
});
expect(updated.status).toBe('pending');
expect(updated.scheduled_local_at).toBe(newLocalStr);
expect(updated.timezone).toBe('America/Los_Angeles');
const updatedScheduledAt = new Date(updated.scheduled_at);
expect(updatedScheduledAt.getTime()).toBeGreaterThan(oldScheduledAt.getTime());
await triggerScheduledMessageWorker(harness, owner.userId, updated.id);
const respMessages = await getChannelMessages(harness, channel.id, owner.token);
expect(messageFromAuthorContains(respMessages, owner.userId, updatedContent)).toBe(true);
expect(messageFromAuthorContains(respMessages, owner.userId, content)).toBe(false);
const fetched = await getScheduledMessage(harness, updated.id, owner.token, 404);
expect(fetched).toBeNull();
});
it('marks scheduled message invalid when access lost', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-messages');
await grantStaffAccess(harness, member.userId);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled');
const invite = await createChannelInvite(harness, owner.token, channel.id);
await joinGuild(harness, member.token, invite.code);
const content = 'scheduled message invalidation';
const scheduled = await scheduleMessage(harness, channel.id, member.token, content);
await removeGuildMember(harness, owner.token, guild.id, member.userId);
await triggerScheduledMessageWorker(harness, member.userId, scheduled.id);
const fetched = await getScheduledMessage(harness, scheduled.id, member.token);
expect(fetched).not.toBeNull();
expect(fetched!.status).toBe('invalid');
expect(fetched!.status_reason).not.toBeNull();
expect(fetched!.status_reason).not.toBe('');
const messages = await getChannelMessages(harness, channel.id, owner.token);
const hasContent = messages.some((msg) => msg.content === content);
expect(hasContent).toBe(false);
});
});
@@ -1,45 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createGuild} from '../../guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {
createChannelInvite,
createGuildChannel,
getScheduledMessages,
grantStaffAccess,
joinGuild,
removeGuildMember,
scheduleMessage,
triggerScheduledMessageWorker,
} from './ScheduledMessageTestUtils';
describe('Scheduled messages list invalid entry', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('shows invalid scheduled message in list', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-invalid');
await grantStaffAccess(harness, member.userId);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled-invalid');
const invite = await createChannelInvite(harness, owner.token, channel.id);
await joinGuild(harness, member.token, invite.code);
const content = 'invalid scheduled';
const scheduled = await scheduleMessage(harness, channel.id, member.token, content);
await removeGuildMember(harness, owner.token, guild.id, member.userId);
await triggerScheduledMessageWorker(harness, member.userId, scheduled.id);
const list = await getScheduledMessages(harness, member.token);
const entry = list.find((e) => e.id === scheduled.id);
expect(entry).toBeDefined();
expect(entry!.status).toBe('invalid');
expect(entry!.status_reason).not.toBeNull();
expect(entry!.status_reason).not.toBe('');
});
});
@@ -1,38 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createGuild} from '../../guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {
cancelScheduledMessage,
createGuildChannel,
getScheduledMessages,
grantStaffAccess,
scheduleMessage,
} from './ScheduledMessageTestUtils';
describe('Scheduled messages list lifecycle', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('lists scheduled messages and removes after cancel', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-list');
await grantStaffAccess(harness, owner.userId);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled-list');
const content = 'list scheduled';
const scheduled = await scheduleMessage(harness, channel.id, owner.token, content);
const list = await getScheduledMessages(harness, owner.token);
const found = list.some((entry) => entry.id === scheduled.id);
expect(found).toBe(true);
await cancelScheduledMessage(harness, scheduled.id, owner.token);
const listAfterCancel = await getScheduledMessages(harness, owner.token);
const foundAfterCancel = listAfterCancel.some((entry) => entry.id === scheduled.id);
expect(foundAfterCancel).toBe(false);
});
});
@@ -343,34 +343,6 @@ export const SAVED_MESSAGE_COLUMNS = [
'saved_at', 'saved_at',
] as const satisfies ReadonlyArray<keyof SavedMessageRow>; ] as const satisfies ReadonlyArray<keyof SavedMessageRow>;
export interface ScheduledMessageRow {
user_id: UserID;
scheduled_message_id: MessageID;
channel_id: ChannelID;
payload: string;
scheduled_at: Date;
scheduled_local_at: string;
timezone: string;
status: string;
status_reason: string | null;
created_at: Date;
invalidated_at: Date | null;
}
export const SCHEDULED_MESSAGE_COLUMNS = [
'user_id',
'scheduled_message_id',
'channel_id',
'payload',
'scheduled_at',
'scheduled_local_at',
'timezone',
'status',
'status_reason',
'created_at',
'invalidated_at',
] as const satisfies ReadonlyArray<keyof ScheduledMessageRow>;
export interface FavoriteMemeRow { export interface FavoriteMemeRow {
user_id: UserID; user_id: UserID;
meme_id: MemeID; meme_id: MemeID;
@@ -1,118 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
import {Logger} from '../Logger';
export interface ParkedScheduledJob {
jobIdentity: string;
taskType: string;
payload: WorkerJobPayload;
runAtMs: number;
ledgerJobId: string | null;
}
export interface ReadyScheduledJob {
job: ParkedScheduledJob;
member: string;
}
const QUEUE_KEY = 'scheduled_job_queue';
const SECONDARY_KEY_PREFIX = 'scheduled_job_queue:';
export function buildScheduledJobIdentity(
taskType: string,
payload: WorkerJobPayload,
runAtMs: number,
ledgerJobId: string | null,
): string {
if (ledgerJobId !== null) {
return ledgerJobId;
}
return createHash('sha256')
.update(`${taskType}|${runAtMs}|${JSON.stringify(payload)}`)
.digest('hex');
}
export class KVScheduledJobQueueService {
constructor(private readonly kvClient: IKVProvider) {}
private getSecondaryKey(jobIdentity: string): string {
return `${SECONDARY_KEY_PREFIX}${jobIdentity}`;
}
private serializeQueueItem(job: ParkedScheduledJob): string {
return JSON.stringify(job);
}
private deserializeQueueItem(value: string): ParkedScheduledJob {
const parsed: unknown = JSON.parse(value);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('parked scheduled job must be a JSON object');
}
const {jobIdentity, taskType, payload, runAtMs, ledgerJobId} = parsed as Record<string, unknown>;
if (typeof jobIdentity !== 'string' || typeof taskType !== 'string' || typeof runAtMs !== 'number') {
throw new Error('parked scheduled job is missing required fields');
}
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
throw new Error('parked scheduled job payload must be a JSON object');
}
return {
jobIdentity,
taskType,
payload: payload as WorkerJobPayload,
runAtMs,
ledgerJobId: typeof ledgerJobId === 'string' ? ledgerJobId : null,
};
}
async parkJob(job: ParkedScheduledJob, releaseAt: Date): Promise<void> {
try {
const secondaryKey = this.getSecondaryKey(job.jobIdentity);
const value = this.serializeQueueItem(job);
await this.kvClient.removeBulkDeletion(QUEUE_KEY, secondaryKey);
await this.kvClient.scheduleBulkDeletion(QUEUE_KEY, secondaryKey, releaseAt.getTime(), value);
Logger.debug({jobIdentity: job.jobIdentity, taskType: job.taskType, releaseAt}, 'Parked scheduled job');
} catch (error) {
Logger.error({error, jobIdentity: job.jobIdentity, taskType: job.taskType}, 'Failed to park scheduled job');
throw error;
}
}
async claimJob(jobIdentity: string, member = ''): Promise<boolean> {
try {
return await this.kvClient.removeBulkDeletion(QUEUE_KEY, this.getSecondaryKey(jobIdentity), member);
} catch (error) {
Logger.error({error, jobIdentity}, 'Failed to claim parked scheduled job');
throw error;
}
}
async getReadyJobs(nowMs: number, limit: number): Promise<Array<ReadyScheduledJob>> {
try {
const results = await this.kvClient.zrangebyscore(QUEUE_KEY, '-inf', nowMs, 'LIMIT', 0, limit);
const jobs: Array<ReadyScheduledJob> = [];
for (const result of results) {
try {
jobs.push({job: this.deserializeQueueItem(result), member: result});
} catch (error) {
Logger.error({error, result}, 'Failed to parse parked scheduled job entry');
}
}
return jobs;
} catch (error) {
Logger.error({error, nowMs, limit}, 'Failed to fetch ready parked scheduled jobs');
throw error;
}
}
async getQueueSize(): Promise<number> {
try {
return await this.kvClient.zcard(QUEUE_KEY);
} catch (error) {
Logger.error({error}, 'Failed to get parked scheduled job queue size');
throw error;
}
}
}
@@ -21,7 +21,6 @@ import {ChannelRepository} from '../channel/ChannelRepository';
import {ChannelRequestService} from '../channel/services/ChannelRequestService'; import {ChannelRequestService} from '../channel/services/ChannelRequestService';
import {MessageRequestService} from '../channel/services/message/MessageRequestService'; import {MessageRequestService} from '../channel/services/message/MessageRequestService';
import {createMessageResponseDataService} from '../channel/services/message/MessageResponseDataService'; import {createMessageResponseDataService} from '../channel/services/message/MessageResponseDataService';
import {ScheduledMessageService} from '../channel/services/ScheduledMessageService';
import {StreamService} from '../channel/services/StreamService'; import {StreamService} from '../channel/services/StreamService';
import {ConnectionRequestService} from '../connection/ConnectionRequestService'; import {ConnectionRequestService} from '../connection/ConnectionRequestService';
import {ConnectionService} from '../connection/ConnectionService'; import {ConnectionService} from '../connection/ConnectionService';
@@ -148,7 +147,6 @@ import {
getReadStateRequestService, getReadStateRequestService,
getReadStateService, getReadStateService,
getReportRepository, getReportRepository,
getScheduledMessageRepository,
getStorageService, getStorageService,
getStreamPreviewService, getStreamPreviewService,
getSweegoWebhookService, getSweegoWebhookService,
@@ -384,7 +382,6 @@ class RequestServices implements RequestScopedServices {
private cachedUserChannelRequestService: UserChannelRequestService | undefined; private cachedUserChannelRequestService: UserChannelRequestService | undefined;
private cachedUserContentRequestService: UserContentRequestService | undefined; private cachedUserContentRequestService: UserContentRequestService | undefined;
private cachedUserRelationshipRequestService: UserRelationshipRequestService | undefined; private cachedUserRelationshipRequestService: UserRelationshipRequestService | undefined;
private cachedScheduledMessageService: ScheduledMessageService | undefined;
private cachedWebhookService: WebhookService | undefined; private cachedWebhookService: WebhookService | undefined;
private cachedWebhookRequestService: WebhookRequestService | undefined; private cachedWebhookRequestService: WebhookRequestService | undefined;
@@ -1022,16 +1019,6 @@ class RequestServices implements RequestScopedServices {
return this.cachedUserRelationshipRequestService; return this.cachedUserRelationshipRequestService;
} }
get scheduledMessageService(): ScheduledMessageService {
this.cachedScheduledMessageService ??= new ScheduledMessageService(
this.channelService,
getScheduledMessageRepository(),
getWorkerService(),
getSnowflakeService(),
);
return this.cachedScheduledMessageService;
}
get webhookService(): WebhookService { get webhookService(): WebhookService {
this.cachedWebhookService ??= new WebhookService( this.cachedWebhookService ??= new WebhookService(
getWebhookRepository(), getWebhookRepository(),
@@ -60,7 +60,6 @@ import type {IUnfurlerService} from '../infrastructure/IUnfurlerService';
import {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService'; import {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService';
import {KVActivityTracker} from '../infrastructure/KVActivityTracker'; import {KVActivityTracker} from '../infrastructure/KVActivityTracker';
import {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService'; import {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService';
import {KVScheduledJobQueueService} from '../infrastructure/KVScheduledJobQueueService';
import {NatsUnfurlerService} from '../infrastructure/NatsUnfurlerService'; import {NatsUnfurlerService} from '../infrastructure/NatsUnfurlerService';
import {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService'; import {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService';
import {createDownloadsStorageService, createStorageService} from '../infrastructure/StorageServiceFactory'; import {createDownloadsStorageService, createStorageService} from '../infrastructure/StorageServiceFactory';
@@ -88,7 +87,6 @@ import {EntranceSoundRepository} from '../user/entrance_sound/EntranceSoundRepos
import {EntranceSoundService} from '../user/entrance_sound/EntranceSoundService'; import {EntranceSoundService} from '../user/entrance_sound/EntranceSoundService';
import {EmailChangeRepository} from '../user/repositories/auth/EmailChangeRepository'; import {EmailChangeRepository} from '../user/repositories/auth/EmailChangeRepository';
import {PasswordChangeRepository} from '../user/repositories/auth/PasswordChangeRepository'; import {PasswordChangeRepository} from '../user/repositories/auth/PasswordChangeRepository';
import {ScheduledMessageRepository} from '../user/repositories/ScheduledMessageRepository';
import {UserContactChangeLogRepository} from '../user/repositories/UserContactChangeLogRepository'; import {UserContactChangeLogRepository} from '../user/repositories/UserContactChangeLogRepository';
import {UserRepository} from '../user/repositories/UserRepository'; import {UserRepository} from '../user/repositories/UserRepository';
import {VisionarySlotRepository} from '../user/repositories/VisionarySlotRepository'; import {VisionarySlotRepository} from '../user/repositories/VisionarySlotRepository';
@@ -123,7 +121,6 @@ export const getApplicationRepository = singleton(() => new ApplicationRepositor
export const getOAuth2TokenRepository = singleton(() => new OAuth2TokenRepository()); export const getOAuth2TokenRepository = singleton(() => new OAuth2TokenRepository());
export const getPackRepository = singleton(() => new PackRepository()); export const getPackRepository = singleton(() => new PackRepository());
export const getGuildDiscoveryRepository = singleton(() => new GuildDiscoveryRepository()); export const getGuildDiscoveryRepository = singleton(() => new GuildDiscoveryRepository());
export const getScheduledMessageRepository = singleton(() => new ScheduledMessageRepository());
export const getEmailChangeRepository = singleton(() => new EmailChangeRepository()); export const getEmailChangeRepository = singleton(() => new EmailChangeRepository());
export const getPasswordChangeRepository = singleton(() => new PasswordChangeRepository()); export const getPasswordChangeRepository = singleton(() => new PasswordChangeRepository());
const getUserContactChangeLogRepository = singleton(() => new UserContactChangeLogRepository()); const getUserContactChangeLogRepository = singleton(() => new UserContactChangeLogRepository());
@@ -230,18 +227,6 @@ export function getKVBulkMessageDeletionQueue(): KVBulkMessageDeletionQueueServi
return bulkMessageDeletionQueue; return bulkMessageDeletionQueue;
} }
let scheduledJobQueueClient: IKVProvider | null = null;
let scheduledJobQueue: KVScheduledJobQueueService | null = null;
export function getKVScheduledJobQueue(): KVScheduledJobQueueService {
const kvClient = getKVClient();
if (!scheduledJobQueue || scheduledJobQueueClient !== kvClient) {
scheduledJobQueue = new KVScheduledJobQueueService(kvClient);
scheduledJobQueueClient = kvClient;
}
return scheduledJobQueue;
}
let premiumStateQueueClient: IKVProvider | null = null; let premiumStateQueueClient: IKVProvider | null = null;
let premiumStateQueue: PremiumStateReconciliationQueueService | null = null; let premiumStateQueue: PremiumStateReconciliationQueueService | null = null;
@@ -63,7 +63,6 @@ const REQUEST_SERVICE_VARIABLES: ReadonlyArray<keyof HonoEnv['Variables']> = [
'reportRequestService', 'reportRequestService',
'reportService', 'reportService',
'rpcService', 'rpcService',
'scheduledMessageService',
'searchService', 'searchService',
'singleCommunityService', 'singleCommunityService',
'snowflakeService', 'snowflakeService',
@@ -1,220 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MessageReferenceTypeValue} from '@fluxer/constants/src/ChannelConstants';
import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
import type {ChannelID, MessageID, UserID} from '../BrandedTypes';
import type {AttachmentRequestData} from '../channel/AttachmentDTOs';
import type {MessageRequest} from '../channel/MessageTypes';
import type {ScheduledMessageRow} from '../database/types/UserTypes';
import {isJsonRecord, parseJsonWithGuard} from '../utils/JsonBoundaryUtils';
type ScheduledMessageStatus = 'pending' | 'invalid';
export interface ScheduledMessageReference {
message_id: string;
channel_id?: string;
guild_id?: string;
type?: MessageReferenceTypeValue;
}
export interface ScheduledAllowedMentions {
parse?: AllowedMentionsRequest['parse'];
users?: Array<string>;
roles?: Array<string>;
replied_user?: boolean;
}
export interface ScheduledMessagePayload {
content?: string | null;
embeds?: MessageRequest['embeds'];
attachments?: Array<AttachmentRequestData>;
message_reference?: ScheduledMessageReference;
allowed_mentions?: ScheduledAllowedMentions;
flags?: number;
nonce?: string;
favorite_meme_id?: string;
sticker_ids?: Array<string>;
tts?: boolean;
}
interface ScheduledMessageResponse {
id: string;
channel_id: string;
scheduled_at: string;
scheduled_local_at: string;
timezone: string;
status: ScheduledMessageStatus;
status_reason: string | null;
payload: ScheduledMessagePayload;
created_at: string;
invalidated_at: string | null;
}
export class ScheduledMessage {
readonly userId: UserID;
readonly id: MessageID;
readonly channelId: ChannelID;
readonly scheduledAt: Date;
readonly scheduledLocalAt: string;
readonly timezone: string;
readonly payload: ScheduledMessagePayload;
readonly status: ScheduledMessageStatus;
readonly statusReason: string | null;
readonly createdAt: Date;
readonly invalidatedAt: Date | null;
constructor(params: {
userId: UserID;
id: MessageID;
channelId: ChannelID;
scheduledAt: Date;
scheduledLocalAt: string;
timezone: string;
payload: ScheduledMessagePayload;
status?: ScheduledMessageStatus;
statusReason?: string | null;
createdAt?: Date;
invalidatedAt?: Date | null;
}) {
this.userId = params.userId;
this.id = params.id;
this.channelId = params.channelId;
this.scheduledAt = params.scheduledAt;
this.scheduledLocalAt = params.scheduledLocalAt;
this.timezone = params.timezone;
this.payload = params.payload;
this.status = params.status ?? 'pending';
this.statusReason = params.statusReason ?? null;
this.createdAt = params.createdAt ?? new Date();
this.invalidatedAt = params.invalidatedAt ?? null;
}
static fromRow(row: ScheduledMessageRow): ScheduledMessage {
return new ScheduledMessage({
userId: row.user_id,
id: row.scheduled_message_id,
channelId: row.channel_id,
scheduledAt: row.scheduled_at,
scheduledLocalAt: row.scheduled_local_at,
timezone: row.timezone,
payload: parsePayload(row.payload),
status: toStatus(row.status),
statusReason: row.status_reason,
createdAt: row.created_at,
invalidatedAt: row.invalidated_at,
});
}
toRow(): ScheduledMessageRow {
return {
user_id: this.userId,
scheduled_message_id: this.id,
channel_id: this.channelId,
payload: JSON.stringify(this.payload),
scheduled_at: this.scheduledAt,
scheduled_local_at: this.scheduledLocalAt,
timezone: this.timezone,
status: this.status,
status_reason: this.statusReason,
created_at: this.createdAt,
invalidated_at: this.invalidatedAt,
};
}
toResponse(): ScheduledMessageResponse {
return {
id: this.id.toString(),
channel_id: this.channelId.toString(),
scheduled_at: this.scheduledAt.toISOString(),
scheduled_local_at: this.scheduledLocalAt,
timezone: this.timezone,
status: this.status,
status_reason: this.statusReason,
payload: this.payload,
created_at: this.createdAt.toISOString(),
invalidated_at: this.invalidatedAt?.toISOString() ?? null,
};
}
parseToMessageRequest(): MessageRequest {
return hydrateMessageRequest(this.payload);
}
}
function parsePayload(payload: string): ScheduledMessagePayload {
return parseJsonWithGuard(payload, isScheduledMessagePayload) ?? {};
}
function isStringArray(value: unknown): value is Array<string> {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
}
function isScheduledMessageReference(value: unknown): value is ScheduledMessageReference {
if (!isJsonRecord(value)) return false;
return (
typeof value.message_id === 'string' &&
(value.channel_id === undefined || typeof value.channel_id === 'string') &&
(value.guild_id === undefined || typeof value.guild_id === 'string') &&
(value.type === undefined || typeof value.type === 'number')
);
}
function isScheduledAllowedMentions(value: unknown): value is ScheduledAllowedMentions {
if (!isJsonRecord(value)) return false;
return (
(value.parse === undefined || isStringArray(value.parse)) &&
(value.users === undefined || isStringArray(value.users)) &&
(value.roles === undefined || isStringArray(value.roles)) &&
(value.replied_user === undefined || typeof value.replied_user === 'boolean')
);
}
function isScheduledMessagePayload(value: unknown): value is ScheduledMessagePayload {
if (!isJsonRecord(value)) return false;
return (
(value.content === undefined || value.content === null || typeof value.content === 'string') &&
(value.embeds === undefined || Array.isArray(value.embeds)) &&
(value.attachments === undefined || Array.isArray(value.attachments)) &&
(value.message_reference === undefined || isScheduledMessageReference(value.message_reference)) &&
(value.allowed_mentions === undefined || isScheduledAllowedMentions(value.allowed_mentions)) &&
(value.flags === undefined || typeof value.flags === 'number') &&
(value.nonce === undefined || typeof value.nonce === 'string') &&
(value.favorite_meme_id === undefined || typeof value.favorite_meme_id === 'string') &&
(value.sticker_ids === undefined || isStringArray(value.sticker_ids)) &&
(value.tts === undefined || typeof value.tts === 'boolean')
);
}
function toStatus(value: string): ScheduledMessageStatus {
return value === 'invalid' ? 'invalid' : 'pending';
}
function hydrateMessageRequest(payload: ScheduledMessagePayload): MessageRequest {
const message: MessageRequest = {
content: payload.content,
embeds: payload.embeds ?? [],
attachments: payload.attachments,
message_reference: payload.message_reference
? {
...payload.message_reference,
message_id: BigInt(payload.message_reference.message_id),
channel_id: payload.message_reference.channel_id ? BigInt(payload.message_reference.channel_id) : undefined,
guild_id: payload.message_reference.guild_id ? BigInt(payload.message_reference.guild_id) : undefined,
}
: undefined,
allowed_mentions: payload.allowed_mentions
? {
parse: payload.allowed_mentions.parse,
users: payload.allowed_mentions.users?.map((value) => BigInt(value)),
roles: payload.allowed_mentions.roles?.map((value) => BigInt(value)),
replied_user: payload.allowed_mentions.replied_user,
}
: undefined,
flags: payload.flags,
nonce: payload.nonce,
favorite_meme_id: payload.favorite_meme_id ? BigInt(payload.favorite_meme_id) : undefined,
sticker_ids: payload.sticker_ids?.map((value) => BigInt(value)),
tts: payload.tts,
};
return message;
}
-17
View File
@@ -47,27 +47,10 @@ export const TEST_IDS = {
NONEXISTENT_MESSAGE: '999999999999999999', NONEXISTENT_MESSAGE: '999999999999999999',
NONEXISTENT_WEBHOOK: '999999999999999999', NONEXISTENT_WEBHOOK: '999999999999999999',
} as const; } as const;
export const TEST_LIMITS = {
SCHEDULED_MESSAGE_MAX_DAYS: 30,
SCHEDULED_MESSAGE_MIN_DELAY_MS: 5 * 60 * 1000,
SCHEDULED_MESSAGE_MAX_DELAY_MS: 31 * 24 * 60 * 60 * 1000,
MFA_TICKET_SHORT_TTL: 1,
MFA_TICKET_LONG_TTL: 300,
PASSWORD_RESET_TOKEN_LENGTH: 64,
} as const;
export function generateUniquePassword(): string { export function generateUniquePassword(): string {
return `SecurePass-${Date.now()}!`; return `SecurePass-${Date.now()}!`;
} }
export function generateFutureTimestamp(minutesInFuture = 5): string {
return new Date(Date.now() + minutesInFuture * 60 * 1000).toISOString();
}
export function generatePastTimestamp(hoursInPast = 1): string {
return new Date(Date.now() - hoursInPast * 60 * 60 * 1000).toISOString();
}
export function wait(ms: number): Promise<void> { export function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
@@ -81,14 +81,12 @@ import {IpAuthorizationTokens, OAuth2AccessTokensByUser} from '../Tables';
import type {HonoApp, HonoEnv} from '../types/HonoEnv'; import type {HonoApp, HonoEnv} from '../types/HonoEnv';
import {UserSearchRepository} from '../user/repositories/account/crud/UserSearchRepository'; import {UserSearchRepository} from '../user/repositories/account/crud/UserSearchRepository';
import {AuthSessionRepository} from '../user/repositories/auth/AuthSessionRepository'; import {AuthSessionRepository} from '../user/repositories/auth/AuthSessionRepository';
import {ScheduledMessageRepository} from '../user/repositories/ScheduledMessageRepository';
import {UserChannelRepository} from '../user/repositories/UserChannelRepository'; import {UserChannelRepository} from '../user/repositories/UserChannelRepository';
import {UserRepository} from '../user/repositories/UserRepository'; import {UserRepository} from '../user/repositories/UserRepository';
import {processUserDeletion} from '../user/services/UserDeletionService'; import {processUserDeletion} from '../user/services/UserDeletionService';
import {UserHarvestRepository} from '../user/UserHarvestRepository'; import {UserHarvestRepository} from '../user/UserHarvestRepository';
import {getExpiryBucket} from '../utils/AttachmentDecay'; import {getExpiryBucket} from '../utils/AttachmentDecay';
import {parseReportedClientOs} from '../utils/SessionClientIdentity'; import {parseReportedClientOs} from '../utils/SessionClientIdentity';
import {ScheduledMessageExecutor} from '../worker/executors/ScheduledMessageExecutor';
import {processExpiredAttachments} from '../worker/tasks/ExpireAttachments'; import {processExpiredAttachments} from '../worker/tasks/ExpireAttachments';
import {processInactivityDeletionsCore} from '../worker/tasks/ProcessInactivityDeletions'; import {processInactivityDeletionsCore} from '../worker/tasks/ProcessInactivityDeletions';
import {setWorkerDependencies} from '../worker/WorkerContext'; import {setWorkerDependencies} from '../worker/WorkerContext';
@@ -1754,48 +1752,6 @@ export function TestHarnessController(app: HonoApp) {
await snowflakeService.shutdown(); await snowflakeService.shutdown();
} }
}); });
app.post('/test/worker/send-scheduled-message/:userId/:scheduledMessageId', async (ctx) => {
ensureHarnessAccess(ctx);
const params = ctx.req.param() as {
userId?: string;
scheduledMessageId?: string;
};
const userIdParam = params.userId;
const scheduledMessageIdParam = params.scheduledMessageId;
if (!userIdParam) {
throw new Error('Missing userId parameter');
}
if (!scheduledMessageIdParam) {
throw new Error('Missing scheduledMessageId parameter');
}
const userId = createUserID(BigInt(userIdParam));
const scheduledMessageId = createMessageID(BigInt(scheduledMessageIdParam));
const snowflakeService = getSnowflakeService();
try {
const workerDeps = await initializeWorkerDepsWithHarnessEmail(ctx, snowflakeService);
setWorkerDependencies(workerDeps);
const scheduledMessageRepository = new ScheduledMessageRepository();
const scheduledMessage = await scheduledMessageRepository.getScheduledMessage(userId, scheduledMessageId);
if (!scheduledMessage) {
return ctx.json({success: false, reason: 'scheduled message not found'}, 404);
}
const logger = {
debug: (message: string, extra?: object) => Logger.debug(extra ?? {}, message),
info: (message: string, extra?: object) => Logger.info(extra ?? {}, message),
warn: (message: string, extra?: object) => Logger.warn(extra ?? {}, message),
error: (message: string, extra?: object) => Logger.error(extra ?? {}, message),
};
const executor = new ScheduledMessageExecutor(workerDeps, logger, scheduledMessageRepository);
await executor.execute({
userId: userId.toString(),
scheduledMessageId: scheduledMessageId.toString(),
expectedScheduledAt: scheduledMessage.scheduledAt.toISOString(),
});
return ctx.json({success: true}, 200);
} finally {
await snowflakeService.shutdown();
}
});
app.post('/test/attachment-decay/rows', async (ctx) => { app.post('/test/attachment-decay/rows', async (ctx) => {
ensureHarnessAccess(ctx); ensureHarnessAccess(ctx);
const payload = (await ctx.req.json()) as { const payload = (await ctx.req.json()) as {
-2
View File
@@ -18,7 +18,6 @@ import type {IChannelRepository} from '../channel/IChannelRepository';
import type {ChannelRequestService} from '../channel/services/ChannelRequestService'; import type {ChannelRequestService} from '../channel/services/ChannelRequestService';
import type {ChannelService} from '../channel/services/ChannelService'; import type {ChannelService} from '../channel/services/ChannelService';
import type {MessageRequestService} from '../channel/services/message/MessageRequestService'; import type {MessageRequestService} from '../channel/services/message/MessageRequestService';
import type {ScheduledMessageService} from '../channel/services/ScheduledMessageService';
import type {StreamPreviewService} from '../channel/services/StreamPreviewService'; import type {StreamPreviewService} from '../channel/services/StreamPreviewService';
import type {StreamService} from '../channel/services/StreamService'; import type {StreamService} from '../channel/services/StreamService';
import type {ConnectionRequestService} from '../connection/ConnectionRequestService'; import type {ConnectionRequestService} from '../connection/ConnectionRequestService';
@@ -177,7 +176,6 @@ export interface HonoEnv {
webhookService: WebhookService; webhookService: WebhookService;
webhookRequestService: WebhookRequestService; webhookRequestService: WebhookRequestService;
workerService: IWorkerService<WorkerTaskName>; workerService: IWorkerService<WorkerTaskName>;
scheduledMessageService: ScheduledMessageService;
stripeService: StripeService; stripeService: StripeService;
ageVerificationService: AgeVerificationService; ageVerificationService: AgeVerificationService;
applicationService: ApplicationService; applicationService: ApplicationService;
@@ -8,7 +8,6 @@ import {UserAuthController} from './UserAuthController';
import {UserChannelController} from './UserChannelController'; import {UserChannelController} from './UserChannelController';
import {UserContentController} from './UserContentController'; import {UserContentController} from './UserContentController';
import {UserRelationshipController} from './UserRelationshipController'; import {UserRelationshipController} from './UserRelationshipController';
import {UserScheduledMessageController} from './UserScheduledMessageController';
export function UserController(app: HonoApp) { export function UserController(app: HonoApp) {
UserAccountController(app); UserAccountController(app);
@@ -16,7 +15,6 @@ export function UserController(app: HonoApp) {
UserRelationshipController(app); UserRelationshipController(app);
UserChannelController(app); UserChannelController(app);
UserContentController(app); UserContentController(app);
UserScheduledMessageController(app);
EntranceSoundController(app); EntranceSoundController(app);
EntranceSoundPlayController(app); EntranceSoundPlayController(app);
} }
@@ -1,141 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {ScheduledMessageIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
ScheduledMessageRequestSchema,
ScheduledMessageResponseSchema,
} from '@fluxer/schema/src/domains/message/ScheduledMessageSchemas';
import type {Context} from 'hono';
import {z} from 'zod';
import {createMessageID} from '../../BrandedTypes';
import {parseScheduledMessageInput} from '../../channel/controllers/ScheduledMessageParsing';
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp, HonoEnv} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
export function UserScheduledMessageController(app: HonoApp) {
app.get(
'/users/@me/scheduled-messages',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'list_scheduled_messages',
summary: 'List scheduled messages',
responseSchema: z.array(ScheduledMessageResponseSchema),
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves all scheduled messages for the current user. Returns list of messages that are scheduled to be sent at a future date and time.',
}),
async (ctx: Context<HonoEnv>) => {
const userId = ctx.get('user').id;
const scheduledMessageService = ctx.get('scheduledMessageService');
const scheduledMessages = await scheduledMessageService.listScheduledMessages(userId);
return ctx.json(
scheduledMessages.map((message) => message.toResponse()),
200,
);
},
);
app.get(
'/users/@me/scheduled-messages/:scheduled_message_id',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
LoginRequired,
DefaultUserOnly,
Validator('param', ScheduledMessageIdParam),
OpenAPI({
operationId: 'get_scheduled_message',
summary: 'Get scheduled message',
responseSchema: ScheduledMessageResponseSchema,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Retrieves details of a specific scheduled message by ID. Returns the message content, scheduled send time, and status.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const scheduledMessageId = createMessageID(ctx.req.valid('param').scheduled_message_id);
const scheduledMessageService = ctx.get('scheduledMessageService');
const scheduledMessage = await scheduledMessageService.getScheduledMessage(userId, scheduledMessageId);
if (!scheduledMessage) {
throw new UnknownMessageError();
}
return ctx.json(scheduledMessage.toResponse(), 200);
},
);
app.delete(
'/users/@me/scheduled-messages/:scheduled_message_id',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
LoginRequired,
DefaultUserOnly,
Validator('param', ScheduledMessageIdParam),
OpenAPI({
operationId: 'cancel_scheduled_message',
summary: 'Cancel scheduled message',
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Cancels and deletes a scheduled message before it is sent. The message will not be delivered if cancelled.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const scheduledMessageId = createMessageID(ctx.req.valid('param').scheduled_message_id);
const scheduledMessageService = ctx.get('scheduledMessageService');
await scheduledMessageService.cancelScheduledMessage(userId, scheduledMessageId);
return ctx.body(null, 204);
},
);
app.patch(
'/users/@me/scheduled-messages/:scheduled_message_id',
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
LoginRequired,
DefaultUserOnly,
Validator('param', ScheduledMessageIdParam),
OpenAPI({
operationId: 'update_scheduled_message',
summary: 'Update scheduled message',
responseSchema: ScheduledMessageResponseSchema,
requestSchema: ScheduledMessageRequestSchema,
requestFormSchema: ScheduledMessageRequestSchema,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Updates an existing scheduled message before it is sent. Can modify message content, scheduled time, and timezone. Returns updated scheduled message details.',
}),
async (ctx) => {
const user = ctx.get('user');
const scheduledMessageService = ctx.get('scheduledMessageService');
const scheduledMessageId = createMessageID(ctx.req.valid('param').scheduled_message_id);
const existingMessage = await scheduledMessageService.getScheduledMessage(user.id, scheduledMessageId);
if (!existingMessage) {
throw new UnknownMessageError();
}
const channelId = existingMessage.channelId;
const {message, scheduledLocalAt, timezone} = await parseScheduledMessageInput({
ctx,
user,
channelId,
});
const scheduledMessage = await scheduledMessageService.updateScheduledMessage({
user,
channelId,
data: message,
scheduledLocalAt,
timezone,
scheduledMessageId,
existing: existingMessage,
});
return ctx.json(scheduledMessage.toResponse(), 200);
},
);
}
@@ -1,65 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MessageID, UserID} from '../../BrandedTypes';
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '../../database/CassandraQueryExecution';
import {Db} from '../../database/CassandraTypes';
import type {ScheduledMessageRow} from '../../database/types/UserTypes';
import {ScheduledMessage} from '../../models/ScheduledMessage';
import {ScheduledMessages} from '../../Tables';
export class ScheduledMessageRepository {
private readonly fetchCql = ScheduledMessages.selectCql({
where: [ScheduledMessages.where.eq('user_id')],
});
async listScheduledMessages(userId: UserID, limit: number = 25): Promise<Array<ScheduledMessage>> {
const rows = await fetchMany<ScheduledMessageRow>(this.fetchCql, {
user_id: userId,
});
const messages = rows.map((row) => ScheduledMessage.fromRow(row));
return messages.sort((a, b) => (b.id > a.id ? 1 : a.id > b.id ? -1 : 0)).slice(0, limit);
}
async getScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<ScheduledMessage | null> {
const row = await fetchOne<ScheduledMessageRow>(
ScheduledMessages.selectCql({
where: [ScheduledMessages.where.eq('user_id'), ScheduledMessages.where.eq('scheduled_message_id')],
}),
{
user_id: userId,
scheduled_message_id: scheduledMessageId,
},
);
return row ? ScheduledMessage.fromRow(row) : null;
}
async upsertScheduledMessage(message: ScheduledMessage, ttlSeconds: number): Promise<void> {
await upsertOne(ScheduledMessages.upsertAllWithTtl(message.toRow(), ttlSeconds));
}
async deleteScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<void> {
await deleteOneOrMany(
ScheduledMessages.deleteByPk({
user_id: userId,
scheduled_message_id: scheduledMessageId,
}),
);
}
async markInvalid(userId: UserID, scheduledMessageId: MessageID, reason: string, ttlSeconds: number): Promise<void> {
await upsertOne(
ScheduledMessages.patchByPkWithTtl(
{
user_id: userId,
scheduled_message_id: scheduledMessageId,
},
{
status: Db.set('invalid'),
status_reason: Db.set(reason),
invalidated_at: Db.set(new Date()),
},
ttlSeconds,
),
);
}
}
@@ -2,8 +2,7 @@
import {beforeAll, beforeEach, describe, it} from 'vitest'; import {beforeAll, beforeEach, describe, it} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils'; import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {joinGuild} from '../../channel/tests/ScheduledMessageTestUtils'; import {acceptInvite, createGuild} from '../../guild/tests/GuildTestUtils';
import {createGuild} from '../../guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants'; import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder'; import {createBuilder} from '../../test/TestRequestBuilder';
@@ -23,7 +22,7 @@ describe('Webhook permissions', () => {
const guild = await createGuild(harness, owner.token, `Webhook Security ${Date.now()}`); const guild = await createGuild(harness, owner.token, `Webhook Security ${Date.now()}`);
const channelId = guild.system_channel_id!; const channelId = guild.system_channel_id!;
const invite = await createChannelInvite(harness, owner.token, channelId); const invite = await createChannelInvite(harness, owner.token, channelId);
await joinGuild(harness, member.token, invite.code); await acceptInvite(harness, member.token, invite.code);
const webhook = await createWebhook(harness, channelId, owner.token, 'Test Webhook'); const webhook = await createWebhook(harness, channelId, owner.token, 'Test Webhook');
await createBuilder(harness, member.token) await createBuilder(harness, member.token)
.post(`/channels/${channelId}/webhooks`) .post(`/channels/${channelId}/webhooks`)
@@ -40,7 +40,6 @@ import type {IVoiceRoomStore} from '../infrastructure/IVoiceRoomStore';
import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService'; import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService';
import type {KVActivityTracker} from '../infrastructure/KVActivityTracker'; import type {KVActivityTracker} from '../infrastructure/KVActivityTracker';
import type {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService'; import type {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService';
import type {KVScheduledJobQueueService} from '../infrastructure/KVScheduledJobQueueService';
import type {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService'; import type {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService';
import type {UserCacheService} from '../infrastructure/UserCacheService'; import type {UserCacheService} from '../infrastructure/UserCacheService';
import type {InstanceConfigRepository} from '../instance/InstanceConfigRepository'; import type {InstanceConfigRepository} from '../instance/InstanceConfigRepository';
@@ -87,7 +86,6 @@ import {
getKVAccountDeletionQueue, getKVAccountDeletionQueue,
getKVActivityTracker, getKVActivityTracker,
getKVBulkMessageDeletionQueue, getKVBulkMessageDeletionQueue,
getKVScheduledJobQueue,
getLimitConfigService, getLimitConfigService,
getNcmecSubmissionService, getNcmecSubmissionService,
getOAuth2TokenRepository, getOAuth2TokenRepository,
@@ -164,7 +162,6 @@ export interface WorkerDependencies {
activityTracker: KVActivityTracker; activityTracker: KVActivityTracker;
deletionQueueService: KVAccountDeletionQueueService; deletionQueueService: KVAccountDeletionQueueService;
bulkMessageDeletionQueueService: KVBulkMessageDeletionQueueService; bulkMessageDeletionQueueService: KVBulkMessageDeletionQueueService;
scheduledJobQueueService: KVScheduledJobQueueService;
premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService; premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService;
deletionEligibilityService: UserDeletionEligibilityService; deletionEligibilityService: UserDeletionEligibilityService;
voiceRoomStore: IVoiceRoomStore; voiceRoomStore: IVoiceRoomStore;
@@ -229,7 +226,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
const activityTracker = getKVActivityTracker(); const activityTracker = getKVActivityTracker();
const deletionQueueService = getKVAccountDeletionQueue(); const deletionQueueService = getKVAccountDeletionQueue();
const bulkMessageDeletionQueueService = getKVBulkMessageDeletionQueue(); const bulkMessageDeletionQueueService = getKVBulkMessageDeletionQueue();
const scheduledJobQueueService = getKVScheduledJobQueue();
const premiumStateReconciliationQueueService = getPremiumStateReconciliationQueueService(); const premiumStateReconciliationQueueService = getPremiumStateReconciliationQueueService();
const deletionEligibilityService = new UserDeletionEligibilityService(kvClient); const deletionEligibilityService = new UserDeletionEligibilityService(kvClient);
await ensureVoiceResourcesInitialized(); await ensureVoiceResourcesInitialized();
@@ -341,7 +337,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
activityTracker, activityTracker,
deletionQueueService, deletionQueueService,
bulkMessageDeletionQueueService, bulkMessageDeletionQueueService,
scheduledJobQueueService,
premiumStateReconciliationQueueService, premiumStateReconciliationQueueService,
deletionEligibilityService, deletionEligibilityService,
voiceRoomStore, voiceRoomStore,
+1 -16
View File
@@ -32,7 +32,6 @@ const LANE_CONFIG = {
consumerName: 'workers_lifecycle', consumerName: 'workers_lifecycle',
tasks: [ tasks: [
'processStripeWebhook', 'processStripeWebhook',
'sendScheduledMessage',
'sendSystemDm', 'sendSystemDm',
'userProcessPendingDeletion', 'userProcessPendingDeletion',
'userProcessPendingDeletions', 'userProcessPendingDeletions',
@@ -72,7 +71,6 @@ const LANE_CONFIG = {
'processInactivityDeletions', 'processInactivityDeletions',
'processPendingBulkMessageDeletions', 'processPendingBulkMessageDeletions',
'processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue',
'processScheduledJobQueue',
'prunePostgresKvTtl', 'prunePostgresKvTtl',
'refreshSearchIndex', 'refreshSearchIndex',
'syncDiscoveryIndex', 'syncDiscoveryIndex',
@@ -214,13 +212,6 @@ function resolveWorkerLanes(config: WorkerLaneRuntimeConfig): Array<WorkerLaneDe
}); });
} }
const SCHEDULED_JOB_DRAIN_INTERVALS_PER_ACK_WAIT = 2;
function resolveScheduledJobDrainIntervalSeconds(): number {
const minimumAckWaitMs = Math.min(...WORKER_LANES.map((lane) => lane.ackWaitMs));
return Math.max(1, Math.floor(minimumAckWaitMs / SCHEDULED_JOB_DRAIN_INTERVALS_PER_ACK_WAIT / 1000));
}
function resolveCronSchedulerEnabled(mode: APIWorkerMode, configuredValue: boolean | undefined): boolean { function resolveCronSchedulerEnabled(mode: APIWorkerMode, configuredValue: boolean | undefined): boolean {
if (configuredValue !== undefined) { if (configuredValue !== undefined) {
return configuredValue; return configuredValue;
@@ -253,10 +244,4 @@ export function findLaneForTask(taskName: string): APIWorkerLaneName | null {
} }
export type {WorkerLaneDefinition}; export type {WorkerLaneDefinition};
export { export {resolveCronSchedulerEnabled, resolveWorkerLanes, validateLaneCompleteness, WORKER_LANES};
resolveCronSchedulerEnabled,
resolveScheduledJobDrainIntervalSeconds,
resolveWorkerLanes,
validateLaneCompleteness,
WORKER_LANES,
};
-9
View File
@@ -25,7 +25,6 @@ import {clearWorkerDependencies, setWorkerDependencies} from './WorkerContext';
import {initializeWorkerDependencies, shutdownWorkerDependencies, type WorkerDependencies} from './WorkerDependencies'; import {initializeWorkerDependencies, shutdownWorkerDependencies, type WorkerDependencies} from './WorkerDependencies';
import { import {
resolveCronSchedulerEnabled, resolveCronSchedulerEnabled,
resolveScheduledJobDrainIntervalSeconds,
resolveWorkerLanes, resolveWorkerLanes,
validateLaneCompleteness, validateLaneCompleteness,
type WorkerLaneDefinition, type WorkerLaneDefinition,
@@ -54,13 +53,6 @@ function registerCronJobs(cron: CronScheduler): void {
cron.upsert('processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue', {}, '0 * * * * *', { cron.upsert('processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue', {}, '0 * * * * *', {
ledger: false, ledger: false,
}); });
cron.upsert(
'processScheduledJobQueue',
'processScheduledJobQueue',
{},
`*/${resolveScheduledJobDrainIntervalSeconds()} * * * * *`,
{ledger: false},
);
if (!Config.instance.selfHosted) { if (!Config.instance.selfHosted) {
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *', {ledger: false}); cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *', {ledger: false});
} }
@@ -249,7 +241,6 @@ export async function startWorkerMain(): Promise<void> {
const runner = new WorkerRunner({ const runner = new WorkerRunner({
tasks: laneTasks, tasks: laneTasks,
queue, queue,
scheduledJobQueue: dependencies.scheduledJobQueueService,
consumerName: lane.consumerName, consumerName: lane.consumerName,
laneName: lane.name, laneName: lane.name,
ledger: jobLedger, ledger: jobLedger,
+5 -39
View File
@@ -4,7 +4,6 @@ import {randomUUID} from 'node:crypto';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService'; import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {JobCancelledError, type WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; import {JobCancelledError, type WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import type {ConsumerMessages, JsMsg} from 'nats'; import type {ConsumerMessages, JsMsg} from 'nats';
import {buildScheduledJobIdentity, type ParkedScheduledJob} from '../infrastructure/KVScheduledJobQueueService';
import type {IJobLedgerRepository} from '../jobs/IJobLedgerRepository'; import type {IJobLedgerRepository} from '../jobs/IJobLedgerRepository';
import {Logger} from '../Logger'; import {Logger} from '../Logger';
import {getWorkerService} from '../middleware/ServiceRegistry'; import {getWorkerService} from '../middleware/ServiceRegistry';
@@ -42,14 +41,9 @@ interface WorkerRunnerQueue {
publishToDlq(taskType: string, originalPayload: Record<string, unknown>, meta: WorkerRunnerDlqMeta): Promise<void>; publishToDlq(taskType: string, originalPayload: Record<string, unknown>, meta: WorkerRunnerDlqMeta): Promise<void>;
} }
interface WorkerRunnerScheduledJobQueue {
parkJob(job: ParkedScheduledJob, releaseAt: Date): Promise<void>;
}
interface WorkerRunnerOptions { interface WorkerRunnerOptions {
tasks: Record<string, WorkerTaskHandler>; tasks: Record<string, WorkerTaskHandler>;
queue: WorkerRunnerQueue; queue: WorkerRunnerQueue;
scheduledJobQueue: WorkerRunnerScheduledJobQueue;
consumerName: string; consumerName: string;
laneName: string; laneName: string;
ledger: IJobLedgerRepository; ledger: IJobLedgerRepository;
@@ -62,7 +56,6 @@ interface WorkerRunnerOptions {
export class WorkerRunner { export class WorkerRunner {
private readonly tasks: Record<string, WorkerTaskHandler>; private readonly tasks: Record<string, WorkerTaskHandler>;
private readonly queue: WorkerRunnerQueue; private readonly queue: WorkerRunnerQueue;
private readonly scheduledJobQueue: WorkerRunnerScheduledJobQueue;
private readonly consumerName: string; private readonly consumerName: string;
private readonly laneName: string; private readonly laneName: string;
private readonly workerId: string; private readonly workerId: string;
@@ -78,7 +71,6 @@ export class WorkerRunner {
constructor(options: WorkerRunnerOptions) { constructor(options: WorkerRunnerOptions) {
this.tasks = options.tasks; this.tasks = options.tasks;
this.queue = options.queue; this.queue = options.queue;
this.scheduledJobQueue = options.scheduledJobQueue;
this.consumerName = options.consumerName; this.consumerName = options.consumerName;
this.laneName = options.laneName; this.laneName = options.laneName;
this.workerId = options.workerId ?? `worker-${options.laneName}-${randomUUID()}`; this.workerId = options.workerId ?? `worker-${options.laneName}-${randomUUID()}`;
@@ -201,37 +193,11 @@ export class WorkerRunner {
if (Number.isFinite(runAtMs)) { if (Number.isFinite(runAtMs)) {
const delayMs = runAtMs - Date.now(); const delayMs = runAtMs - Date.now();
if (delayMs > 0) { if (delayMs > 0) {
const deliveryCount = msg.info.deliveryCount; Logger.debug(
const shouldPark = delayMs > this.ackWaitMs || deliveryCount >= this.maxDeliver - 1; {taskType, seq: msg.seq, runAt, delayMs},
if (shouldPark) { 'Job scheduled for future execution, redelivering with delay',
const ledgerJobIdValue = ledgerJobId === null ? null : ledgerJobId.toString(); );
try { msg.nak(delayMs);
await this.scheduledJobQueue.parkJob(
{
jobIdentity: buildScheduledJobIdentity(taskType, jobPayload, runAtMs, ledgerJobIdValue),
taskType,
payload: jobPayload,
runAtMs,
ledgerJobId: ledgerJobIdValue,
},
new Date(runAtMs - this.ackWaitMs),
);
msg.ack();
Logger.debug(
{taskType, seq: msg.seq, runAt, deliveryCount},
'Parked scheduled job in the due queue to free ack slot',
);
} catch (error) {
Logger.error({taskType, seq: msg.seq, err: error}, 'Failed to park scheduled job, falling back to NAK');
msg.nak(Math.min(delayMs, this.ackWaitMs - 5000));
}
} else {
Logger.debug(
{taskType, seq: msg.seq, runAt, delayMs},
'Job scheduled for future execution, redelivering with delay',
);
msg.nak(delayMs);
}
return false; return false;
} }
} }
@@ -30,13 +30,11 @@ import processExpiredPremiumSweep from './tasks/ProcessExpiredPremiumSweep';
import processInactivityDeletions from './tasks/ProcessInactivityDeletions'; import processInactivityDeletions from './tasks/ProcessInactivityDeletions';
import processPendingBulkMessageDeletions from './tasks/ProcessPendingBulkMessageDeletions'; import processPendingBulkMessageDeletions from './tasks/ProcessPendingBulkMessageDeletions';
import processPremiumStateReconciliationQueue from './tasks/ProcessPremiumStateReconciliationQueue'; import processPremiumStateReconciliationQueue from './tasks/ProcessPremiumStateReconciliationQueue';
import processScheduledJobQueue from './tasks/ProcessScheduledJobQueue';
import processStripeWebhook from './tasks/ProcessStripeWebhook'; import processStripeWebhook from './tasks/ProcessStripeWebhook';
import prunePostgresKvTtl from './tasks/PrunePostgresKvTtl'; import prunePostgresKvTtl from './tasks/PrunePostgresKvTtl';
import reconcileUserPayments from './tasks/ReconcileUserPayments'; import reconcileUserPayments from './tasks/ReconcileUserPayments';
import refreshSearchIndex from './tasks/RefreshSearchIndex'; import refreshSearchIndex from './tasks/RefreshSearchIndex';
import revalidateUserConnections from './tasks/RevalidateUserConnections'; import revalidateUserConnections from './tasks/RevalidateUserConnections';
import {sendScheduledMessage} from './tasks/SendScheduledMessage';
import {sendSystemDm} from './tasks/SendSystemDm'; import {sendSystemDm} from './tasks/SendSystemDm';
import syncDiscoveryIndex from './tasks/SyncDiscoveryIndex'; import syncDiscoveryIndex from './tasks/SyncDiscoveryIndex';
import syncDisposableEmailDomains from './tasks/SyncDisposableEmailDomains'; import syncDisposableEmailDomains from './tasks/SyncDisposableEmailDomains';
@@ -76,12 +74,10 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
processInactivityDeletions, processInactivityDeletions,
processPendingBulkMessageDeletions, processPendingBulkMessageDeletions,
processPremiumStateReconciliationQueue, processPremiumStateReconciliationQueue,
processScheduledJobQueue,
reconcileUserPayments, reconcileUserPayments,
prunePostgresKvTtl, prunePostgresKvTtl,
refreshSearchIndex, refreshSearchIndex,
revalidateUserConnections, revalidateUserConnections,
sendScheduledMessage,
sendSystemDm, sendSystemDm,
syncFileShaBlocklists, syncFileShaBlocklists,
syncUrlBlocklists, syncUrlBlocklists,
@@ -1,125 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MessageID, UserID} from '../../BrandedTypes';
import {createMessageID, createUserID} from '../../BrandedTypes';
import {SCHEDULED_MESSAGE_TTL_SECONDS} from '../../channel/services/ScheduledMessageService';
import {createRequestCache} from '../../middleware/RequestCacheMiddleware';
import {ScheduledMessageRepository} from '../../user/repositories/ScheduledMessageRepository';
import type {WorkerDependencies} from '../WorkerDependencies';
interface WorkerLogger {
debug(message: string, extra?: object): void;
info(message: string, extra?: object): void;
warn(message: string, extra?: object): void;
error(message: string, extra?: object): void;
}
export interface SendScheduledMessageParams {
userId: string;
scheduledMessageId: string;
expectedScheduledAt: string;
}
export class ScheduledMessageExecutor {
constructor(
private readonly deps: WorkerDependencies,
private readonly logger: WorkerLogger,
private readonly scheduledMessageRepository: ScheduledMessageRepository = new ScheduledMessageRepository(),
) {}
async execute(params: SendScheduledMessageParams): Promise<void> {
const userId = this.parseUserID(params.userId);
const scheduledMessageId = this.parseMessageID(params.scheduledMessageId);
if (!userId || !scheduledMessageId) {
this.logger.warn('Malformed scheduled message job payload', {payload: params});
return;
}
const expectedScheduledAt = this.parseScheduledAt(params.expectedScheduledAt);
if (!expectedScheduledAt) {
this.logger.warn('Invalid expectedScheduledAt for scheduled message job', {payload: params});
return;
}
const scheduledMessage = await this.scheduledMessageRepository.getScheduledMessage(userId, scheduledMessageId);
if (!scheduledMessage) {
this.logger.info('Scheduled message not found, skipping', {userId, scheduledMessageId});
return;
}
if (scheduledMessage.status !== 'pending') {
this.logger.info('Scheduled message already processed', {
scheduledMessageId,
status: scheduledMessage.status,
});
return;
}
if (scheduledMessage.scheduledAt.toISOString() !== expectedScheduledAt.toISOString()) {
this.logger.info('Scheduled message time mismatch, skipping stale job', {
scheduledMessageId,
expected: expectedScheduledAt.toISOString(),
actual: scheduledMessage.scheduledAt.toISOString(),
});
return;
}
const user = await this.deps.userRepository.findUnique(userId);
if (!user) {
await this.markInvalid(userId, scheduledMessageId, 'User not found');
return;
}
const messageRequest = scheduledMessage.parseToMessageRequest();
const requestCache = createRequestCache();
try {
await this.deps.channelService.messages.send.validateMessageCanBeSent({
user,
channelId: scheduledMessage.channelId,
data: messageRequest,
});
await this.deps.channelService.messages.send.sendMessage({
user,
channelId: scheduledMessage.channelId,
data: messageRequest,
requestCache,
});
await this.scheduledMessageRepository.deleteScheduledMessage(userId, scheduledMessageId);
this.logger.info('Scheduled message sent successfully', {scheduledMessageId, userId});
} catch (error) {
const reason = error instanceof Error ? error.message : 'Failed to send scheduled message';
this.logger.warn('Marking scheduled message invalid', {scheduledMessageId, userId, reason});
await this.markInvalid(userId, scheduledMessageId, reason);
} finally {
requestCache.clear();
}
}
private async markInvalid(userId: UserID, scheduledMessageId: MessageID, reason: string): Promise<void> {
try {
await this.scheduledMessageRepository.markInvalid(
userId,
scheduledMessageId,
reason,
SCHEDULED_MESSAGE_TTL_SECONDS,
);
} catch (error) {
this.logger.error('Failed to mark scheduled message invalid', {error, scheduledMessageId});
}
}
private parseUserID(value: string): UserID | null {
try {
return createUserID(BigInt(value));
} catch {
return null;
}
}
private parseMessageID(value: string): MessageID | null {
try {
return createMessageID(BigInt(value));
} catch {
return null;
}
}
private parseScheduledAt(value: string): Date | null {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
}
@@ -1,80 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
import type {ParkedScheduledJob} from '../../infrastructure/KVScheduledJobQueueService';
import {Logger} from '../../Logger';
import {getWorkerDependencies} from '../WorkerContext';
import {findLaneForTask, type WorkerTaskName} from '../WorkerLaneConfig';
const MAX_JOBS_PER_RUN = 500;
function buildReleasePayload(job: ParkedScheduledJob): WorkerJobPayload {
if (job.ledgerJobId === null) {
return job.payload;
}
return {...job.payload, __jobId: job.ledgerJobId};
}
const processScheduledJobQueue: WorkerTaskHandler = async (_payload, helpers) => {
const {scheduledJobQueueService, workerService} = getWorkerDependencies();
const nowMs = Date.now();
const readyJobs = await scheduledJobQueueService.getReadyJobs(nowMs, MAX_JOBS_PER_RUN);
if (readyJobs.length === 0) {
helpers.logger.debug('No parked scheduled jobs are due');
return;
}
let releasedCount = 0;
let claimedElsewhereCount = 0;
let droppedCount = 0;
let failedCount = 0;
for (const {job, member} of readyJobs) {
try {
const claimed = await scheduledJobQueueService.claimJob(job.jobIdentity, member);
if (!claimed) {
claimedElsewhereCount += 1;
continue;
}
if (findLaneForTask(job.taskType) === null) {
droppedCount += 1;
Logger.error(
{jobIdentity: job.jobIdentity, taskType: job.taskType},
'Dropping parked scheduled job with an unknown task type',
);
continue;
}
await workerService.addJob(job.taskType as WorkerTaskName, buildReleasePayload(job), {
runAt: new Date(job.runAtMs),
jobKey: job.jobIdentity,
skipLedger: true,
});
releasedCount += 1;
} catch (error) {
failedCount += 1;
Logger.error(
{error, jobIdentity: job.jobIdentity, taskType: job.taskType},
'Failed to release parked scheduled job, re-parking for the next drain',
);
try {
await scheduledJobQueueService.parkJob(job, new Date(nowMs));
} catch (parkError) {
Logger.error(
{error: parkError, jobIdentity: job.jobIdentity, taskType: job.taskType},
'Failed to re-park scheduled job after a release failure',
);
}
}
}
helpers.logger.info(
{
total: readyJobs.length,
releasedCount,
claimedElsewhereCount,
droppedCount,
failedCount,
},
'Released parked scheduled jobs',
);
};
export default processScheduledJobQueue;
@@ -1,20 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {z} from 'zod';
import {ScheduledMessageExecutor, type SendScheduledMessageParams} from '../executors/ScheduledMessageExecutor';
import {getWorkerDependencies} from '../WorkerContext';
const PayloadSchema = z.object({
userId: z.string(),
scheduledMessageId: z.string(),
expectedScheduledAt: z.string(),
});
export async function sendScheduledMessage(payload: unknown, helpers: WorkerTaskHelpers): Promise<void> {
const validated = PayloadSchema.parse(payload) as SendScheduledMessageParams;
const deps = getWorkerDependencies();
const executor = new ScheduledMessageExecutor(deps, helpers.logger);
const result = await executor.execute(validated);
return result;
}
@@ -1,343 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import type {WorkerJobOptions, WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
import type {JsMsg} from 'nats';
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
import {KVScheduledJobQueueService} from '../../infrastructure/KVScheduledJobQueueService';
import type {IJobLedgerRepository} from '../../jobs/IJobLedgerRepository';
import {setInjectedWorkerService} from '../../middleware/ServiceRegistry';
import {MockKVProvider} from '../../test/mocks/MockKVProvider';
import {NoopLogger} from '../../test/mocks/NoopLogger';
import {NoopWorkerService} from '../../test/NoopWorkerService';
import processScheduledJobQueue from '../tasks/ProcessScheduledJobQueue';
import {setWorkerDependenciesForTest} from '../WorkerContext';
import {WorkerRunner} from '../WorkerRunner';
const LANE_ACK_WAIT_MS = 60000;
const LANE_MAX_DELIVER = 25;
const TASK_TYPE = 'sendScheduledMessage';
interface RecordedJob {
taskType: string;
payload: WorkerJobPayload;
options: WorkerJobOptions | undefined;
}
class RecordingWorkerService implements IWorkerService {
readonly jobs: Array<RecordedJob> = [];
async addJob<TPayload extends WorkerJobPayload = WorkerJobPayload>(
taskType: string,
payload: TPayload,
options?: WorkerJobOptions,
): Promise<bigint> {
this.jobs.push({taskType, payload, options});
return BigInt(this.jobs.length);
}
async cancelJob(_jobId: bigint): Promise<boolean> {
return false;
}
async retryDeadLetterJob(_jobId: bigint): Promise<boolean> {
return false;
}
}
const queueStub = {
getConnectionManager: () => {
throw new Error('WorkerRunner tests never consume messages');
},
getStreamName: () => 'JOBS',
enqueue: vi.fn(),
publishToDlq: vi.fn(),
};
class ConcurrentlyClaimedKVProvider extends MockKVProvider {
override async removeBulkDeletion(queueKey: string, secondaryKey: string): Promise<boolean> {
await super.removeBulkDeletion(queueKey, secondaryKey);
return false;
}
}
class TestWorkerRunner extends WorkerRunner {
async runJob(taskType: string, msg: JsMsg): Promise<boolean> {
return await this.processJob(taskType, msg);
}
}
function createRunner(scheduledJobQueue: KVScheduledJobQueueService): TestWorkerRunner {
return new TestWorkerRunner({
tasks: {[TASK_TYPE]: async () => {}},
queue: queueStub,
scheduledJobQueue,
consumerName: 'workers_lifecycle',
laneName: 'lifecycle',
ledger: {} as IJobLedgerRepository,
concurrency: 8,
maxDeliver: LANE_MAX_DELIVER,
ackWaitMs: LANE_ACK_WAIT_MS,
});
}
function createJobMessage(options: {
runAt: Date;
payload: Record<string, unknown>;
seq?: number;
deliveryCount?: number;
}) {
const envelope = {
payload: options.payload,
run_at: options.runAt.toISOString(),
max_attempts: 5,
priority: 0,
created_at: new Date().toISOString(),
};
return {
seq: options.seq ?? 1,
subject: `jobs.${TASK_TYPE}`,
redelivered: (options.deliveryCount ?? 1) > 1,
data: new TextEncoder().encode(JSON.stringify(envelope)),
info: {deliveryCount: options.deliveryCount ?? 1},
ack: vi.fn(),
nak: vi.fn(),
term: vi.fn(),
};
}
function createHelpers(): WorkerTaskHelpers {
return {
logger: new NoopLogger(),
jobId: 0n,
addJob: async () => 0n,
reportProgress: async () => {},
shouldCancel: async () => false,
setContextLink: async () => {},
};
}
async function drain(scheduledJobQueue: KVScheduledJobQueueService, workerService: RecordingWorkerService) {
setWorkerDependenciesForTest({scheduledJobQueueService: scheduledJobQueue, workerService});
await processScheduledJobQueue({}, createHelpers());
}
describe('Scheduled job due queue', () => {
beforeAll(() => {
setInjectedWorkerService(new NoopWorkerService());
});
afterEach(() => {
vi.useRealTimers();
queueStub.enqueue.mockClear();
queueStub.publishToDlq.mockClear();
});
it('naks a job due inside one ack-wait instead of parking it', async () => {
const scheduledJobQueue = new KVScheduledJobQueueService(new MockKVProvider());
const runner = createRunner(scheduledJobQueue);
const msg = createJobMessage({
runAt: new Date(Date.now() + LANE_ACK_WAIT_MS - 30000),
payload: {scheduledMessageId: '77', __jobId: '4242'},
});
await runner.runJob(TASK_TYPE, msg as unknown as JsMsg);
expect(msg.nak).toHaveBeenCalledTimes(1);
expect(msg.ack).not.toHaveBeenCalled();
const delayMs = msg.nak.mock.calls[0]![0] as number;
expect(delayMs).toBeGreaterThan(0);
expect(delayMs).toBeLessThanOrEqual(LANE_ACK_WAIT_MS - 30000);
expect(await scheduledJobQueue.getQueueSize()).toBe(0);
});
it('parks a job due beyond one ack-wait without republishing it', async () => {
const scheduledJobQueue = new KVScheduledJobQueueService(new MockKVProvider());
const runner = createRunner(scheduledJobQueue);
const runAt = new Date(Date.now() + 60 * 60 * 1000);
const msg = createJobMessage({runAt, payload: {scheduledMessageId: '77', __jobId: '4242'}});
await runner.runJob(TASK_TYPE, msg as unknown as JsMsg);
expect(msg.ack).toHaveBeenCalledTimes(1);
expect(msg.nak).not.toHaveBeenCalled();
expect(queueStub.enqueue).not.toHaveBeenCalled();
expect(await scheduledJobQueue.getQueueSize()).toBe(1);
expect(await scheduledJobQueue.getReadyJobs(Date.now(), 10)).toHaveLength(0);
const due = await scheduledJobQueue.getReadyJobs(runAt.getTime() - LANE_ACK_WAIT_MS, 10);
expect(due).toHaveLength(1);
expect(due[0]!.job).toEqual({
jobIdentity: '4242',
taskType: TASK_TYPE,
payload: {scheduledMessageId: '77'},
runAtMs: runAt.getTime(),
ledgerJobId: '4242',
});
expect(due[0]!.member).toContain('"jobIdentity":"4242"');
});
it('releases a parked job exactly once when its due time arrives', async () => {
vi.useFakeTimers({toFake: ['Date']});
const start = new Date('2026-08-30T12:00:00.000Z');
vi.setSystemTime(start);
const scheduledJobQueue = new KVScheduledJobQueueService(new MockKVProvider());
const runner = createRunner(scheduledJobQueue);
const runAt = new Date(start.getTime() + 60 * 60 * 1000);
const msg = createJobMessage({runAt, payload: {scheduledMessageId: '77', __jobId: '4242'}});
await runner.runJob(TASK_TYPE, msg as unknown as JsMsg);
const workerService = new RecordingWorkerService();
await drain(scheduledJobQueue, workerService);
expect(workerService.jobs).toHaveLength(0);
vi.setSystemTime(new Date(runAt.getTime() - LANE_ACK_WAIT_MS + 1000));
await drain(scheduledJobQueue, workerService);
expect(workerService.jobs).toHaveLength(1);
expect(workerService.jobs[0]!.taskType).toBe(TASK_TYPE);
expect(workerService.jobs[0]!.payload).toEqual({scheduledMessageId: '77', __jobId: '4242'});
expect(workerService.jobs[0]!.options?.runAt?.getTime()).toBe(runAt.getTime());
expect(workerService.jobs[0]!.options?.jobKey).toBe('4242');
expect(workerService.jobs[0]!.options?.skipLedger).toBe(true);
expect(await scheduledJobQueue.getQueueSize()).toBe(0);
await drain(scheduledJobQueue, workerService);
expect(workerService.jobs).toHaveLength(1);
});
it('runs a job parked twice only once', async () => {
vi.useFakeTimers({toFake: ['Date']});
const start = new Date('2026-08-30T12:00:00.000Z');
vi.setSystemTime(start);
const scheduledJobQueue = new KVScheduledJobQueueService(new MockKVProvider());
const runner = createRunner(scheduledJobQueue);
const runAt = new Date(start.getTime() + 60 * 60 * 1000);
const payload = {scheduledMessageId: '77', __jobId: '4242'};
await runner.runJob(TASK_TYPE, createJobMessage({runAt, payload, seq: 1}) as unknown as JsMsg);
await runner.runJob(TASK_TYPE, createJobMessage({runAt, payload, seq: 2, deliveryCount: 2}) as unknown as JsMsg);
expect(await scheduledJobQueue.getQueueSize()).toBe(1);
vi.setSystemTime(new Date(runAt.getTime() - LANE_ACK_WAIT_MS + 1000));
const workerService = new RecordingWorkerService();
await drain(scheduledJobQueue, workerService);
expect(workerService.jobs).toHaveLength(1);
expect(await scheduledJobQueue.getQueueSize()).toBe(0);
});
it('parks a nearly delivery-exhausted job so it gets a fresh delivery budget', async () => {
const scheduledJobQueue = new KVScheduledJobQueueService(new MockKVProvider());
const runner = createRunner(scheduledJobQueue);
const runAt = new Date(Date.now() + 20000);
const msg = createJobMessage({
runAt,
payload: {scheduledMessageId: '77', __jobId: '4242'},
deliveryCount: LANE_MAX_DELIVER - 1,
});
await runner.runJob(TASK_TYPE, msg as unknown as JsMsg);
expect(msg.ack).toHaveBeenCalledTimes(1);
expect(msg.nak).not.toHaveBeenCalled();
expect(queueStub.enqueue).not.toHaveBeenCalled();
const workerService = new RecordingWorkerService();
await drain(scheduledJobQueue, workerService);
expect(workerService.jobs).toHaveLength(1);
expect(workerService.jobs[0]!.options?.runAt?.getTime()).toBe(runAt.getTime());
});
it('does nothing when the due queue is empty', async () => {
const scheduledJobQueue = new KVScheduledJobQueueService(new MockKVProvider());
const workerService = new RecordingWorkerService();
await expect(drain(scheduledJobQueue, workerService)).resolves.toBeUndefined();
expect(workerService.jobs).toHaveLength(0);
expect(await scheduledJobQueue.getQueueSize()).toBe(0);
});
it('does not release a job another drain claimed first', async () => {
const scheduledJobQueue = new KVScheduledJobQueueService(new ConcurrentlyClaimedKVProvider());
const runner = createRunner(scheduledJobQueue);
const runAt = new Date(Date.now() + 20000);
const msg = createJobMessage({
runAt,
payload: {scheduledMessageId: '77', __jobId: '4242'},
deliveryCount: LANE_MAX_DELIVER - 1,
});
await runner.runJob(TASK_TYPE, msg as unknown as JsMsg);
expect(await scheduledJobQueue.getReadyJobs(Date.now(), 10)).toHaveLength(1);
const workerService = new RecordingWorkerService();
await drain(scheduledJobQueue, workerService);
expect(workerService.jobs).toHaveLength(0);
expect(await scheduledJobQueue.getQueueSize()).toBe(0);
});
});
describe('Parked job claim survives secondary key loss', () => {
it('claims by member when the secondary key was evicted', async () => {
const kv = new MockKVProvider();
const service = new KVScheduledJobQueueService(kv);
const runAtMs = Date.now() + 86_400_000;
const job = {
jobIdentity: 'job-evicted',
taskType: TASK_TYPE,
payload: {message_id: '1'} as WorkerJobPayload,
runAtMs,
ledgerJobId: null,
};
await service.parkJob(job, new Date(Date.now() - 1000));
expect(await service.getQueueSize()).toBe(1);
await kv.del('scheduled_job_queue:job-evicted');
const ready = await service.getReadyJobs(Date.now(), 10);
expect(ready).toHaveLength(1);
expect(await service.claimJob(ready[0]!.job.jobIdentity, ready[0]!.member)).toBe(true);
expect(await service.getQueueSize()).toBe(0);
});
it('leaves the entry wedged when claiming without the member', async () => {
const kv = new MockKVProvider();
const service = new KVScheduledJobQueueService(kv);
const job = {
jobIdentity: 'job-wedged',
taskType: TASK_TYPE,
payload: {message_id: '2'} as WorkerJobPayload,
runAtMs: Date.now() + 86_400_000,
ledgerJobId: null,
};
await service.parkJob(job, new Date(Date.now() - 1000));
await kv.del('scheduled_job_queue:job-wedged');
expect(await service.claimJob('job-wedged')).toBe(false);
expect(await service.getQueueSize()).toBe(1);
});
it('still reports a second claim of the same member as already claimed', async () => {
const kv = new MockKVProvider();
const service = new KVScheduledJobQueueService(kv);
const job = {
jobIdentity: 'job-twice',
taskType: TASK_TYPE,
payload: {message_id: '3'} as WorkerJobPayload,
runAtMs: Date.now() + 86_400_000,
ledgerJobId: null,
};
await service.parkJob(job, new Date(Date.now() - 1000));
const ready = await service.getReadyJobs(Date.now(), 10);
const member = ready[0]!.member;
expect(await service.claimJob('job-twice', member)).toBe(true);
expect(await service.claimJob('job-twice', member)).toBe(false);
});
});
@@ -3,10 +3,8 @@
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask'; import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
import type {JsMsg} from 'nats'; import type {JsMsg} from 'nats';
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
import {KVScheduledJobQueueService} from '../../infrastructure/KVScheduledJobQueueService';
import type {IJobLedgerRepository} from '../../jobs/IJobLedgerRepository'; import type {IJobLedgerRepository} from '../../jobs/IJobLedgerRepository';
import {setInjectedWorkerService} from '../../middleware/ServiceRegistry'; import {setInjectedWorkerService} from '../../middleware/ServiceRegistry';
import {MockKVProvider} from '../../test/mocks/MockKVProvider';
import {NoopWorkerService} from '../../test/NoopWorkerService'; import {NoopWorkerService} from '../../test/NoopWorkerService';
import {WorkerRunner} from '../WorkerRunner'; import {WorkerRunner} from '../WorkerRunner';
@@ -32,7 +30,6 @@ function createRunner(task: () => Promise<void>): TestWorkerRunner {
return new TestWorkerRunner({ return new TestWorkerRunner({
tasks: {[TASK_TYPE]: task}, tasks: {[TASK_TYPE]: task},
queue: queueStub, queue: queueStub,
scheduledJobQueue: new KVScheduledJobQueueService(new MockKVProvider()),
consumerName: 'workers_batch', consumerName: 'workers_batch',
laneName: 'batch', laneName: 'batch',
ledger: {} as IJobLedgerRepository, ledger: {} as IJobLedgerRepository,
@@ -146,12 +143,10 @@ describe('Worker ack heartbeat', () => {
expect(msg.working.mock.calls.length).toBe(heartbeatsAtCompletion); expect(msg.working.mock.calls.length).toBe(heartbeatsAtCompletion);
}); });
it('does not heartbeat a parked scheduled job', async () => { it('does not heartbeat a job deferred to the future', async () => {
const scheduledJobQueue = new KVScheduledJobQueueService(new MockKVProvider());
const runner = new TestWorkerRunner({ const runner = new TestWorkerRunner({
tasks: {[TASK_TYPE]: async () => {}}, tasks: {[TASK_TYPE]: async () => {}},
queue: queueStub, queue: queueStub,
scheduledJobQueue,
consumerName: 'workers_batch', consumerName: 'workers_batch',
laneName: 'batch', laneName: 'batch',
ledger: {} as IJobLedgerRepository, ledger: {} as IJobLedgerRepository,
@@ -174,8 +169,8 @@ describe('Worker ack heartbeat', () => {
await runner.runJob(TASK_TYPE, msg as unknown as JsMsg); await runner.runJob(TASK_TYPE, msg as unknown as JsMsg);
expect(msg.ack).toHaveBeenCalledTimes(1); expect(msg.ack).not.toHaveBeenCalled();
expect(msg.nak).toHaveBeenCalledTimes(1);
expect(msg.working).not.toHaveBeenCalled(); expect(msg.working).not.toHaveBeenCalled();
expect(await scheduledJobQueue.getQueueSize()).toBe(1);
}); });
}); });
@@ -1,12 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import { import {resolveCronSchedulerEnabled, resolveWorkerLanes, WORKER_LANES} from '../WorkerLaneConfig';
resolveCronSchedulerEnabled,
resolveScheduledJobDrainIntervalSeconds,
resolveWorkerLanes,
WORKER_LANES,
} from '../WorkerLaneConfig';
describe('WorkerLaneConfig', () => { describe('WorkerLaneConfig', () => {
it('returns all lanes in all_lanes mode', () => { it('returns all lanes in all_lanes mode', () => {
@@ -102,10 +97,4 @@ describe('WorkerLaneConfig', () => {
expect(resolveCronSchedulerEnabled('single_lane', true)).toBe(true); expect(resolveCronSchedulerEnabled('single_lane', true)).toBe(true);
expect(resolveCronSchedulerEnabled('all_lanes', false)).toBe(false); expect(resolveCronSchedulerEnabled('all_lanes', false)).toBe(false);
}); });
it('drains parked scheduled jobs well inside the shortest lane ack-wait', () => {
const intervalSeconds = resolveScheduledJobDrainIntervalSeconds();
const shortestAckWaitMs = Math.min(...WORKER_LANES.map((lane) => lane.ackWaitMs));
expect(intervalSeconds).toBeGreaterThanOrEqual(1);
expect(intervalSeconds * 1000 * 2).toBeLessThanOrEqual(shortestAckWaitMs);
});
}); });
@@ -1,46 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.text {
display: flex;
flex-direction: column;
gap: 0.125rem;
overflow: hidden;
}
.label {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--text-primary);
}
.timestamp {
font-size: 0.75rem;
color: var(--text-primary-muted);
}
.controls {
display: flex;
align-items: center;
}
.button {
cursor: pointer;
flex-shrink: 0;
border: none;
background-color: transparent;
padding: 0.5rem 0 0.5rem 1rem;
color: var(--text-primary-muted);
line-height: 0;
transition: color 200ms;
}
.button:hover {
color: var(--text-primary);
}
.icon {
height: 1.25rem;
width: 1.25rem;
}
@@ -1,80 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/channel/components/ScheduledMessageEditBar.module.css';
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import {getFormattedDateTimeInZone} from '@fluxer/date_utils/src/DateFormatting';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ClockIcon, XCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback, useMemo} from 'react';
const EDITING_SCHEDULED_MESSAGE_DESCRIPTOR = msg({
message: 'Editing scheduled message',
comment: 'Button or menu action label in the channel and chat scheduled message edit bar. Keep it concise.',
});
const CANCEL_EDITING_SCHEDULED_MESSAGE_DESCRIPTOR = msg({
message: 'Cancel editing scheduled message',
comment: 'Button or menu action label in the channel and chat scheduled message edit bar. Keep it concise.',
});
interface ScheduledMessageEditBarProps {
scheduledLocalAt: string;
timezone: string;
onCancel: () => void;
}
const formatScheduleLabel = (local: string, timezone: string): string => {
const locale = getCurrentLocale();
const formatted = getFormattedDateTimeInZone(local, timezone, locale);
return `${formatted} (${timezone})`;
};
export const ScheduledMessageEditBar = observer(
({scheduledLocalAt, timezone, onCancel}: ScheduledMessageEditBarProps) => {
const {i18n} = useLingui();
const scheduleLabel = useMemo(() => formatScheduleLabel(scheduledLocalAt, timezone), [scheduledLocalAt, timezone]);
const handleStopEditing = useCallback(() => {
onCancel();
}, [onCancel]);
return (
<div
className={`${wrapperStyles.box} ${wrapperStyles.wrapperSides} ${wrapperStyles.roundedTop} ${wrapperStyles.noBottomBorder}`}
data-flx="channel.scheduled-message-edit-bar.div"
>
<div
className={wrapperStyles.barInner}
style={{gridTemplateColumns: '1fr auto'}}
data-flx="channel.scheduled-message-edit-bar.div--2"
>
<div className={styles.text} data-flx="channel.scheduled-message-edit-bar.text">
<div className={styles.label} data-flx="channel.scheduled-message-edit-bar.label">
<ClockIcon className={styles.icon} weight="fill" data-flx="channel.scheduled-message-edit-bar.icon" />
<span data-flx="channel.scheduled-message-edit-bar.span">
{i18n._(EDITING_SCHEDULED_MESSAGE_DESCRIPTOR)}
</span>
</div>
<div className={styles.timestamp} data-flx="channel.scheduled-message-edit-bar.timestamp">
{scheduleLabel}
</div>
</div>
<div className={styles.controls} data-flx="channel.scheduled-message-edit-bar.controls">
<FocusRing offset={-2} data-flx="channel.scheduled-message-edit-bar.focus-ring">
<button
type="button"
className={styles.button}
onClick={handleStopEditing}
aria-label={i18n._(CANCEL_EDITING_SCHEDULED_MESSAGE_DESCRIPTOR)}
data-flx="channel.scheduled-message-edit-bar.button.stop-editing"
>
<XCircleIcon className={styles.icon} data-flx="channel.scheduled-message-edit-bar.icon--2" />
</button>
</FocusRing>
</div>
</div>
<div className={wrapperStyles.separator} data-flx="channel.scheduled-message-edit-bar.div--3" />
</div>
);
},
);
@@ -17,7 +17,6 @@ function signals(overrides: Partial<MobileTextareaButtonSignals> = {}): MobileTe
hasContent: false, hasContent: false,
hasAttachments: false, hasAttachments: false,
hasPendingSticker: false, hasPendingSticker: false,
isEditingScheduledMessage: false,
...overrides, ...overrides,
}; };
} }
@@ -93,15 +92,6 @@ describe('MobileTextareaButtonStateMachine', () => {
}); });
}); });
it('does not expose voice recording while editing a scheduled message', () => {
expect(state({isEditingScheduledMessage: true})).toMatchObject({
mode: 'sendBlocked',
visibleButton: 'send',
sendButton: {disabled: true},
voiceButton: {disabled: true},
});
});
it('falls back to a disabled send button when voice recording is unavailable and the composer is empty', () => { it('falls back to a disabled send button when voice recording is unavailable and the composer is empty', () => {
expect(state({canRecordVoice: false})).toMatchObject({ expect(state({canRecordVoice: false})).toMatchObject({
mode: 'sendBlocked', mode: 'sendBlocked',
@@ -16,7 +16,6 @@ export interface MobileTextareaButtonSignals {
hasContent: boolean; hasContent: boolean;
hasAttachments: boolean; hasAttachments: boolean;
hasPendingSticker: boolean; hasPendingSticker: boolean;
isEditingScheduledMessage: boolean;
} }
export interface MobileTextareaButtonModel { export interface MobileTextareaButtonModel {
@@ -51,16 +50,13 @@ export function selectMobileTextareaButtonModel(signals: MobileTextareaButtonSig
const typedText = hasTypedText(signals.value); const typedText = hasTypedText(signals.value);
const hasTextContent = signals.hasContent || typedText; const hasTextContent = signals.hasContent || typedText;
const hasSubmissionContent = hasTextContent || signals.hasAttachments || signals.hasPendingSticker; const hasSubmissionContent = hasTextContent || signals.hasAttachments || signals.hasPendingSticker;
const shouldShowVoice = const shouldShowVoice = signals.canRecordVoice && !signals.isEditingMessage && !hasSubmissionContent;
signals.canRecordVoice && !signals.isEditingMessage && !signals.isEditingScheduledMessage && !hasSubmissionContent;
const sendDisabled = const sendDisabled =
signals.disabled || signals.disabled ||
signals.isOverCharacterLimit || signals.isOverCharacterLimit ||
signals.isEditingScheduledMessage ||
(signals.isSlowmodeActive && !signals.isEditingMessage) || (signals.isSlowmodeActive && !signals.isEditingMessage) ||
(!hasSubmissionContent && !signals.isEditingMessage); (!hasSubmissionContent && !signals.isEditingMessage);
const voiceDisabled = const voiceDisabled = signals.disabled || signals.isSlowmodeActive || signals.isOverCharacterLimit;
signals.disabled || signals.isSlowmodeActive || signals.isOverCharacterLimit || signals.isEditingScheduledMessage;
const visibleButton = shouldShowVoice ? 'voice' : 'send'; const visibleButton = shouldShowVoice ? 'voice' : 'send';
const mode = visibleButton === 'voice' ? 'voice' : sendDisabled ? 'sendBlocked' : 'sendReady'; const mode = visibleButton === 'voice' ? 'voice' : sendDisabled ? 'sendBlocked' : 'sendReady';
return { return {
@@ -84,7 +80,6 @@ const DEFAULT_SIGNALS: MobileTextareaButtonSignals = {
hasContent: false, hasContent: false,
hasAttachments: false, hasAttachments: false,
hasPendingSticker: false, hasPendingSticker: false,
isEditingScheduledMessage: false,
}; };
const DEFAULT_MODEL = selectMobileTextareaButtonModel(DEFAULT_SIGNALS); const DEFAULT_MODEL = selectMobileTextareaButtonModel(DEFAULT_SIGNALS);
@@ -106,7 +106,6 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
hasContent, hasContent,
hasAttachments, hasAttachments,
hasPendingSticker, hasPendingSticker,
isEditingScheduledMessage: false,
}), }),
[ [
canRecordVoice, canRecordVoice,
@@ -28,7 +28,6 @@ import MemberSidebar from '@app/features/member/state/MemberSidebar';
import MessageReactions from '@app/features/messaging/state/MessageReactions'; import MessageReactions from '@app/features/messaging/state/MessageReactions';
import Messages from '@app/features/messaging/state/MessagingMessages'; import Messages from '@app/features/messaging/state/MessagingMessages';
import SavedMessages from '@app/features/messaging/state/SavedMessages'; import SavedMessages from '@app/features/messaging/state/SavedMessages';
import ScheduledMessages from '@app/features/messaging/state/ScheduledMessages';
import MentionFeed from '@app/features/notification/state/MentionFeed'; import MentionFeed from '@app/features/notification/state/MentionFeed';
import Permission from '@app/features/permissions/state/Permission'; import Permission from '@app/features/permissions/state/Permission';
import {Logger} from '@app/features/platform/utils/AppLogger'; import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -170,7 +169,6 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
MemberSearch.handleGatewayReady(); MemberSearch.handleGatewayReady();
SavedMessages.handleGatewayReady(); SavedMessages.handleGatewayReady();
MentionFeed.handleGatewayReady(); MentionFeed.handleGatewayReady();
ScheduledMessages.handleGatewayReady();
ChannelPins.handleGatewayReady(); ChannelPins.handleGatewayReady();
UserConnection.handleGatewayReady(); UserConnection.handleGatewayReady();
UserGuildSettings.handleGatewayReady(data.user_guild_settings ?? []); UserGuildSettings.handleGatewayReady(data.user_guild_settings ?? []);
+1 -1
View File
@@ -6,7 +6,7 @@ import {makeAutoObservable} from 'mobx';
const logger = new Logger('Inbox'); const logger = new Logger('Inbox');
export type InboxTab = 'bookmarks' | 'mentions' | 'scheduled' | 'unreadChannels'; export type InboxTab = 'bookmarks' | 'mentions' | 'unreadChannels';
class Inbox { class Inbox {
selectedTab: InboxTab = 'bookmarks'; selectedTab: InboxTab = 'bookmarks';
@@ -1,495 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {FeatureTemporarilyDisabledModal} from '@app/features/app/components/alerts/FeatureTemporarilyDisabledModal';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
import {MessageSendFailedModal} from '@app/features/messaging/components/alerts/MessageSendFailedModal';
import {MessageSendTooQuickModal} from '@app/features/messaging/components/alerts/MessageSendTooQuickModal';
import type {
ScheduledAttachment,
ScheduledMessagePayload,
ScheduledMessageResponse,
} from '@app/features/messaging/models/ScheduledMessage';
import {ScheduledMessage} from '@app/features/messaging/models/ScheduledMessage';
import ScheduledMessages from '@app/features/messaging/state/ScheduledMessages';
import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
import {prepareAttachmentsForNonce} from '@app/features/messaging/utils/MessageAttachmentUtils';
import {
type ApiAttachmentMetadata,
buildMessageCreateRequest,
type MessageCreateRequest,
type NormalizedMessageContent,
normalizeMessageContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as SlowmodeCommands from '@app/features/slowmode/commands/SlowmodeCommands';
import {SlowmodeRateLimitedModal} from '@app/features/slowmode/components/alerts/SlowmodeRateLimitedModal';
import {TypingUtils} from '@app/features/typing/utils/TypingUtils';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {
AllowedMentions,
MessageReference,
MessageStickerItem,
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
const SCHEDULED_MESSAGE_FOR_DESCRIPTOR = msg({
message: 'Scheduled message for {scheduledTimeLabel}',
comment: 'Label in the messaging commands. {scheduledTimeLabel} is the localized scheduled date and time.',
});
const UPDATED_SCHEDULED_MESSAGE_FOR_DESCRIPTOR = msg({
message: 'Updated scheduled message for {scheduledTimeLabel}',
comment: 'Label in the messaging commands. {scheduledTimeLabel} is the localized scheduled date and time.',
});
const REMOVED_SCHEDULED_MESSAGE_DESCRIPTOR = msg({
message: 'Removed scheduled message',
comment: 'Button or menu action label in the messaging commands. Keep it concise. Keep the tone plain and specific.',
});
const logger = new Logger('ScheduledMessages');
type ScheduledMessageRequest = MessageCreateRequest & {
scheduled_local_at: string;
timezone: string;
};
interface ApiErrorBody {
code?: number | string;
message?: string;
}
export interface ScheduleMessageParams {
channelId: string;
content: string;
scheduledLocalAt: string;
timezone: string;
messageReference?: MessageReference;
replyMentioning?: boolean;
favoriteMemeId?: string;
stickers?: Array<MessageStickerItem>;
tts?: boolean;
hasAttachments: boolean;
}
interface UpdateScheduledMessageParams {
channelId: string;
scheduledMessageId: string;
scheduledLocalAt: string;
timezone: string;
normalized: NormalizedMessageContent;
payload: ScheduledMessagePayload;
replyMentioning?: boolean;
}
interface PreparedScheduledMessage {
nonce: string;
payload: ScheduledMessageRequest;
files?: Array<File>;
normalized: NormalizedMessageContent;
}
const formatScheduledLabel = (local: string, timezone: string): string => {
return `${local.replace('T', ' ')} (${timezone})`;
};
function mapScheduledAttachments(
attachments?: ReadonlyArray<ScheduledAttachment>,
): Array<ApiAttachmentMetadata> | undefined {
if (!attachments || attachments.length === 0) {
return undefined;
}
return attachments.map((attachment) => ({
id: attachment.id,
filename: attachment.filename,
title: attachment.title ?? attachment.filename,
description: attachment.description ?? undefined,
flags: attachment.flags,
}));
}
function scheduleAllowedMentions(params: ScheduleMessageParams): AllowedMentions {
return {replied_user: params.replyMentioning ?? true};
}
function claimScheduleAttachments(params: ScheduleMessageParams, nonce: string): void {
if (!params.hasAttachments) {
return;
}
MessageSubmitUtils.claimMessageAttachments(
params.channelId,
nonce,
params.content,
params.messageReference,
params.replyMentioning,
);
}
async function prepareScheduledMessage(params: ScheduleMessageParams): Promise<PreparedScheduledMessage> {
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
const normalized = normalizeMessageContent(params.content);
claimScheduleAttachments(params, nonce);
let attachments: Array<ApiAttachmentMetadata> | undefined;
let files: Array<File> | undefined;
if (params.hasAttachments) {
const result = await prepareAttachmentsForNonce(nonce, params.favoriteMemeId);
attachments = result.attachments;
files = result.files;
}
const requestBody = buildMessageCreateRequest({
content: normalized.content,
nonce,
attachments,
allowedMentions: scheduleAllowedMentions(params),
messageReference: params.messageReference,
flags: normalized.flags,
favoriteMemeId: params.favoriteMemeId,
stickers: params.stickers,
tts: params.tts,
});
return {
nonce,
normalized,
files,
payload: {
...requestBody,
scheduled_local_at: params.scheduledLocalAt,
timezone: params.timezone,
},
};
}
function handleScheduleSuccess(
i18n: I18n,
params: ScheduleMessageParams,
nonce: string,
response: ScheduledMessageResponse,
): ScheduledMessage {
const record = ScheduledMessage.fromResponse(response);
ScheduledMessages.upsert(record);
DraftCommands.deleteDraft(params.channelId);
TypingUtils.clear(params.channelId);
MessageCommands.stopReply(params.channelId);
if (params.hasAttachments) {
CloudUpload.removeMessageUpload(nonce);
}
ToastCommands.createToast({
type: 'success',
children: i18n._(SCHEDULED_MESSAGE_FOR_DESCRIPTOR, {
scheduledTimeLabel: formatScheduledLabel(params.scheduledLocalAt, params.timezone),
}),
});
return record;
}
function scheduledMessageUpdateRequest(params: UpdateScheduledMessageParams): ScheduledMessageRequest {
return {
content: params.normalized.content,
attachments: mapScheduledAttachments(params.payload.attachments),
allowed_mentions: params.payload.allowed_mentions ?? (params.replyMentioning ? {replied_user: true} : undefined),
message_reference:
params.payload.message_reference?.channel_id && params.payload.message_reference.message_id
? {
channel_id: params.payload.message_reference.channel_id,
message_id: params.payload.message_reference.message_id,
guild_id: params.payload.message_reference.guild_id,
type: params.payload.message_reference.type,
}
: undefined,
flags: params.normalized.flags,
favorite_meme_id: params.payload.favorite_meme_id ?? undefined,
sticker_ids: params.payload.sticker_ids,
tts: params.payload.tts ? true : undefined,
scheduled_local_at: params.scheduledLocalAt,
timezone: params.timezone,
};
}
async function requestScheduledMessageUpdate(
scheduledMessageId: string,
requestBody: ScheduledMessageRequest,
): Promise<ScheduledMessageResponse> {
const response = await http.patch<ScheduledMessageResponse>(Endpoints.USER_SCHEDULED_MESSAGE(scheduledMessageId), {
body: requestBody,
});
return response.body;
}
export async function fetchScheduledMessages(): Promise<Array<ScheduledMessage>> {
logger.debug('Fetching scheduled messages');
ScheduledMessages.fetchStart();
try {
const response = await http.get<Array<ScheduledMessageResponse>>(Endpoints.USER_SCHEDULED_MESSAGES);
const data = response.body ?? [];
const messages = data.map(ScheduledMessage.fromResponse);
ScheduledMessages.fetchSuccess(messages);
logger.debug('Scheduled messages fetched successfully');
return messages;
} catch (error) {
ScheduledMessages.fetchError();
logger.error('Failed to fetch scheduled messages:', error);
throw error;
}
}
export async function scheduleMessage(i18n: I18n, params: ScheduleMessageParams): Promise<ScheduledMessage> {
logger.debug('Scheduling message', params);
const prepared = await prepareScheduledMessage(params);
try {
const response = await scheduleMessageRequest(params.channelId, prepared.payload, prepared.files, prepared.nonce);
return handleScheduleSuccess(i18n, params, prepared.nonce, response.body);
} catch (error) {
handleScheduleError(
i18n,
error,
params.channelId,
prepared.nonce,
params.content,
params.messageReference,
params.replyMentioning,
params.hasAttachments,
);
throw error;
}
}
export async function updateScheduledMessage(
i18n: I18n,
params: UpdateScheduledMessageParams,
): Promise<ScheduledMessage> {
logger.debug('Updating scheduled message', params);
try {
const record = ScheduledMessage.fromResponse(
await requestScheduledMessageUpdate(params.scheduledMessageId, scheduledMessageUpdateRequest(params)),
);
ScheduledMessages.upsert(record);
DraftCommands.deleteDraft(params.channelId);
TypingUtils.clear(params.channelId);
MessageCommands.stopReply(params.channelId);
ToastCommands.createToast({
type: 'success',
children: i18n._(UPDATED_SCHEDULED_MESSAGE_FOR_DESCRIPTOR, {
scheduledTimeLabel: formatScheduledLabel(params.scheduledLocalAt, params.timezone),
}),
});
return record;
} catch (error) {
logger.error('Failed to update scheduled message', error);
throw error;
}
}
export async function cancelScheduledMessage(i18n: I18n, scheduledMessageId: string): Promise<void> {
logger.debug('Canceling scheduled message', scheduledMessageId);
try {
await http.delete(Endpoints.USER_SCHEDULED_MESSAGE(scheduledMessageId));
ScheduledMessages.remove(scheduledMessageId);
ToastCommands.createToast({
type: 'success',
children: i18n._(REMOVED_SCHEDULED_MESSAGE_DESCRIPTOR),
});
} catch (error) {
logger.error('Failed to cancel scheduled message', error);
throw error;
}
}
function restoreDraftAfterScheduleFailure(
channelId: string,
nonce: string,
content: string,
messageReference?: MessageReference,
replyMentioning?: boolean,
hadAttachments?: boolean,
): void {
if (hadAttachments) {
CloudUpload.restoreAttachmentsToTextarea(nonce);
}
DraftCommands.createDraft(channelId, content);
if (messageReference && replyMentioning !== undefined) {
MessageCommands.startReply(channelId, messageReference.message_id, replyMentioning);
}
}
async function scheduleMessageRequest(
channelId: string,
payload: ScheduledMessageRequest,
files?: Array<File>,
nonce?: string,
): Promise<RestResponse<ScheduledMessageResponse>> {
const abortController = new AbortController();
try {
if (files?.length) {
return await scheduleMultipartMessage(channelId, payload, files, abortController.signal, nonce);
}
return await http.post<ScheduledMessageResponse>(Endpoints.CHANNEL_MESSAGE_SCHEDULE(channelId), {
body: payload,
signal: abortController.signal,
});
} finally {
abortController.abort();
}
}
async function scheduleMultipartMessage(
channelId: string,
payload: ScheduledMessageRequest,
files: Array<File>,
signal: AbortSignal,
nonce?: string,
): Promise<RestResponse<ScheduledMessageResponse>> {
return http.post<ScheduledMessageResponse>(Endpoints.CHANNEL_MESSAGE_SCHEDULE(channelId), {
multipart: {
files: files.map((file, index) => ({name: `files[${index}]`, file, filename: file.name})),
fields: {payload_json: JSON.stringify(payload)},
},
signal,
onProgress: nonce
? (event) => {
if (event.lengthComputable && event.total > 0) {
const progress = (event.loaded / event.total) * 100;
CloudUpload.updateSendingProgress(nonce, progress);
}
}
: undefined,
});
}
const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
return typeof error.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
};
function handleScheduleError(
i18n: I18n,
error: unknown,
channelId: string,
nonce: string,
content: string,
messageReference?: MessageReference,
replyMentioning?: boolean,
hadAttachments?: boolean,
): void {
restoreDraftAfterScheduleFailure(channelId, nonce, content, messageReference, replyMentioning, hadAttachments);
if (!(error instanceof HttpError)) {
ModalCommands.push(
modal(() => (
<MessageSendFailedModal
hasAttachments={hadAttachments}
data-flx="messaging.scheduled-message-commands.handle-schedule-error.message-send-failed-modal--request"
/>
)),
);
return;
}
if (isRateLimitError(error)) {
handleScheduleRateLimit(i18n, error);
return;
}
if (isSlowmodeError(error)) {
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(resolveRetryAfterMs(error));
if (retryAfterMs <= 0) {
ModalCommands.push(
modal(() => (
<MessageSendFailedModal
hasAttachments={hadAttachments}
data-flx="messaging.scheduled-message-commands.handle-schedule-error.message-send-failed-modal"
/>
)),
);
return;
}
const retryAfter = Math.ceil(retryAfterMs / 1000);
SlowmodeCommands.updateSlowmodeRemaining(channelId, retryAfterMs);
ModalCommands.push(
modal(() => (
<SlowmodeRateLimitedModal
retryAfter={retryAfter}
data-flx="messaging.scheduled-message-commands.handle-schedule-error.slowmode-rate-limited-modal"
/>
)),
);
return;
}
if (isFeatureDisabledError(error)) {
ModalCommands.push(
modal(() => (
<FeatureTemporarilyDisabledModal data-flx="messaging.scheduled-message-commands.handle-schedule-error.feature-temporarily-disabled-modal" />
)),
);
return;
}
if (isExplicitContentError(error)) {
ModalCommands.push(
modal(() => (
<MatureContentRejectedModal data-flx="messaging.scheduled-message-commands.handle-schedule-error.mature-content-rejected-modal" />
)),
);
return;
}
if (isFileTooLargeError(error)) {
ModalCommands.push(
modal(() => (
<FileSizeTooLargeModal data-flx="messaging.scheduled-message-commands.handle-schedule-error.file-size-too-large-modal" />
)),
);
return;
}
ModalCommands.push(
modal(() => (
<MessageSendFailedModal
hasAttachments={hadAttachments}
data-flx="messaging.scheduled-message-commands.handle-schedule-error.message-send-failed-modal--http"
/>
)),
);
}
function handleScheduleRateLimit(_i18n: I18n, error: HttpError): void {
const retryAfterMs = resolveRetryAfterMs(error);
const retryAfterSeconds = retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000);
ModalCommands.push(
modal(() => (
<MessageSendTooQuickModal
retryAfter={retryAfterSeconds}
onRetry={undefined}
data-flx="messaging.scheduled-message-commands.handle-schedule-rate-limit.message-send-too-quick-modal"
/>
)),
);
logger.warn('Scheduled message rate limited, retry after', retryAfterSeconds);
}
function isRateLimitError(error: HttpError): boolean {
if (error.status !== 429) return false;
return !isSlowmodeError(error);
}
function isSlowmodeError(error: HttpError): boolean {
if (error.status !== 400 && error.status !== 429) return false;
const body = getApiErrorBody(error);
if (body === undefined) return false;
return body.code === APIErrorCodes.SLOWMODE_RATE_LIMITED;
}
function isFeatureDisabledError(error: HttpError): boolean {
return error?.status === 403 && getApiErrorBody(error)?.code === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED;
}
function isExplicitContentError(error: HttpError): boolean {
return getApiErrorBody(error)?.code === APIErrorCodes.EXPLICIT_CONTENT_CANNOT_BE_SENT;
}
function isFileTooLargeError(error: HttpError): boolean {
return getApiErrorBody(error)?.code === APIErrorCodes.FILE_SIZE_TOO_LARGE;
}
@@ -7,7 +7,6 @@ import Inbox, {type InboxTab} from '@app/features/inbox/state/Inbox';
import styles from '@app/features/messaging/components/popouts/InboxPopout.module.css'; import styles from '@app/features/messaging/components/popouts/InboxPopout.module.css';
import {RecentMentionsContent} from '@app/features/messaging/components/popouts/RecentMentionsContent'; import {RecentMentionsContent} from '@app/features/messaging/components/popouts/RecentMentionsContent';
import {SavedMessagesContent} from '@app/features/messaging/components/popouts/SavedMessagesContent'; import {SavedMessagesContent} from '@app/features/messaging/components/popouts/SavedMessagesContent';
import {ScheduledMessagesContent} from '@app/features/messaging/components/popouts/ScheduledMessagesContent';
import ReadStates from '@app/features/read_state/state/ReadStates'; import ReadStates from '@app/features/read_state/state/ReadStates';
import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import FocusRing from '@app/features/ui/focus_ring/FocusRing';
@@ -20,10 +19,9 @@ import {
import {getNextTabIndex, getTabNavigationDirection} from '@app/features/ui/tabs/TabKeyboardNavigation'; import {getNextTabIndex, getTabNavigationDirection} from '@app/features/ui/tabs/TabKeyboardNavigation';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings'; import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import Users from '@app/features/user/state/Users';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro'; import {useLingui} from '@lingui/react/macro';
import {AtIcon, BellIcon, BookmarkSimpleIcon, CheckIcon, ClockIcon} from '@phosphor-icons/react'; import {AtIcon, BellIcon, BookmarkSimpleIcon, CheckIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx'; import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import type React from 'react'; import type React from 'react';
@@ -37,10 +35,6 @@ const BOOKMARKS_DESCRIPTOR = msg({
message: 'Bookmarks', message: 'Bookmarks',
comment: 'Tab label in the inbox popout listing the user bookmarks.', comment: 'Tab label in the inbox popout listing the user bookmarks.',
}); });
const SCHEDULED_DESCRIPTOR = msg({
message: 'Scheduled',
comment: 'Tab label in the inbox popout listing the user scheduled messages.',
});
const INBOX_TABS_DESCRIPTOR = msg({ const INBOX_TABS_DESCRIPTOR = msg({
message: 'Inbox tabs', message: 'Inbox tabs',
comment: 'Accessible label for the inbox popout tablist.', comment: 'Accessible label for the inbox popout tablist.',
@@ -141,15 +135,7 @@ export const InboxPopout = observer(({initialTab}: {initialTab?: InboxTab} = {})
icon: <AtIcon weight="bold" className={styles.iconSmall} data-flx="messaging.inbox-popout.icon-small--3" />, icon: <AtIcon weight="bold" className={styles.iconSmall} data-flx="messaging.inbox-popout.icon-small--3" />,
}, },
]; ];
const scheduledTab: TabConfig = { const tabs = baseTabs;
key: 'scheduled',
label: i18n._(SCHEDULED_DESCRIPTOR),
icon: <ClockIcon className={styles.iconSmall} data-flx="messaging.inbox-popout.icon-small--4" />,
};
const currentUser = Users.getCurrentUser();
let showScheduledTab = false;
if (currentUser != null) showScheduledTab = currentUser.isStaff();
const tabs = showScheduledTab ? [...baseTabs, scheduledTab] : baseTabs;
const normalizedActiveTab = tabs.some((tab) => tab.key === activeTab) ? activeTab : tabs[0].key; const normalizedActiveTab = tabs.some((tab) => tab.key === activeTab) ? activeTab : tabs[0].key;
const setActiveTab = useCallback((tab: InboxTab) => { const setActiveTab = useCallback((tab: InboxTab) => {
InboxCommands.setTab(tab); InboxCommands.setTab(tab);
@@ -253,19 +239,6 @@ export const InboxPopout = observer(({initialTab}: {initialTab?: InboxTab} = {})
/> />
</div> </div>
)} )}
{showScheduledTab && normalizedActiveTab === 'scheduled' && (
<div
id="inbox-panel-scheduled"
className={styles.tabContent}
role="tabpanel"
tabIndex={-1}
aria-labelledby="scheduled"
data-autofocus=""
data-flx="messaging.inbox-popout.inbox-panel-scheduled"
>
<ScheduledMessagesContent data-flx="messaging.inbox-popout.scheduled-messages-content" />
</div>
)}
</div> </div>
); );
return ( return (
@@ -1,56 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.375rem;
gap: 0.5rem;
}
.statusBadge {
font-size: 0.6875rem;
font-weight: 600;
padding: 0.125rem 0.625rem;
border-radius: 62.4375rem;
background-color: var(--background-modifier-accent);
color: var(--text-primary);
}
.statusInvalid {
background-color: var(--text-warning);
color: var(--background-primary);
}
.timestamp {
font-size: 0.75rem;
color: var(--text-primary-muted);
}
.messageText {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
margin-bottom: 0.375rem;
min-height: 2.75rem;
}
.attachmentsInfo {
font-size: 0.75rem;
color: var(--text-primary-muted);
margin-bottom: 0.375rem;
}
.statusReason {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--text-warning);
margin-bottom: 0.375rem;
}
.warningIcon {
width: 1rem;
height: 1rem;
}
@@ -1,218 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import previewStyles from '@app/features/app/components/shared/MessagePreview.module.css';
import * as ScheduledMessageCommands from '@app/features/messaging/commands/ScheduledMessageCommands';
import styles from '@app/features/messaging/components/popouts/ScheduledMessagesContent.module.css';
import {useMessageSelectionCopy} from '@app/features/messaging/hooks/useMessageSelectionCopy';
import ScheduledMessages from '@app/features/messaging/state/ScheduledMessages';
import {Scroller} from '@app/features/ui/components/Scroller';
import {Spinner} from '@app/features/ui/components/Spinner';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import {formatScheduledMessage} from '@fluxer/date_utils/src/DateFormatting';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {FlagCheckeredIcon, WarningCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useState} from 'react';
const NO_SCHEDULED_MESSAGES_DESCRIPTOR = msg({
message: 'No scheduled messages',
comment: 'Empty state title in the scheduled messages popout when the user has none scheduled.',
});
const RIGHT_CLICK_THE_SEND_BUTTON_TO_SCHEDULE_A_DESCRIPTOR = msg({
message: 'Right-click the send button to schedule a message.',
comment: 'Empty state hint in the scheduled messages popout explaining how to schedule a new message.',
});
const INVALID_DESCRIPTOR = msg({
message: 'Invalid',
comment: 'Status pill on a scheduled message row when the schedule is in the past or otherwise invalid.',
});
const SCHEDULED_DESCRIPTOR = msg({
message: 'Scheduled',
comment: 'Status pill on a scheduled message row indicating it will send at the chosen time.',
});
const ATTACHMENT_ONLY_MESSAGE_DESCRIPTOR = msg({
message: 'Attachment only message',
comment: 'Placeholder content text shown for scheduled messages that have attachments but no text content.',
});
const NO_CONTENT_DESCRIPTOR = msg({
message: '(no content)',
comment:
'Placeholder content text shown for scheduled messages with neither text nor attachments. Keep the parentheses.',
});
const ATTACHMENTS_DESCRIPTOR = msg({
message: 'Attachments',
comment: 'Label in the scheduled message row preview indicating that attachments are included.',
});
const CANCEL_DESCRIPTOR = msg({
message: 'Cancel',
comment: 'Button label on a scheduled message row that cancels (deletes) the scheduled send.',
});
const YOU_RE_CAUGHT_UP_DESCRIPTOR = msg({
message: "You're caught up",
comment:
'End-of-list title in the scheduled messages popout when all scheduled messages are visible. Tone can be friendly.',
});
const NO_MORE_SCHEDULED_MESSAGES_DESCRIPTOR = msg({
message: "That's everything in the queue.",
comment: 'End-of-list body text in the scheduled messages popout when all scheduled messages are visible.',
});
export const ScheduledMessagesContent = observer(() => {
const {i18n} = useLingui();
const {scheduledMessages, fetched, fetching} = ScheduledMessages;
const [cancellingId, setCancellingId] = useState<string | null>(null);
const getScheduledMessagePlaintext = useCallback(
(messageId: string) => {
const message = scheduledMessages.find((entry) => entry.id === messageId);
if (!message) {
return null;
}
const blocks: Array<string> = [];
const content = message.payload.content?.trim();
const attachmentCount = message.payload.attachments?.length ?? 0;
if (content) {
blocks.push(content);
}
if (attachmentCount > 0) {
blocks.push(`${i18n._(ATTACHMENTS_DESCRIPTOR)}: ${attachmentCount}`);
}
if (message.status === 'invalid' && message.statusReason) {
blocks.push(message.statusReason);
}
return blocks.length > 0 ? blocks.join('\n') : i18n._(NO_CONTENT_DESCRIPTOR);
},
[i18n, scheduledMessages],
);
const onCopySelectedMessages = useMessageSelectionCopy<HTMLDivElement>({
getMessagePlaintext: getScheduledMessagePlaintext,
});
useEffect(() => {
if (!fetched && !fetching) {
ScheduledMessageCommands.fetchScheduledMessages();
}
}, [fetched, fetching]);
const handleCancel = useCallback(
async (messageId: string) => {
setCancellingId(messageId);
try {
await ScheduledMessageCommands.cancelScheduledMessage(i18n, messageId);
} finally {
setCancellingId(null);
}
},
[i18n],
);
if (fetching) {
return (
<div className={previewStyles.emptyState} data-flx="messaging.scheduled-messages-content.loading-state">
<Spinner data-flx="messaging.scheduled-messages-content.spinner" />
</div>
);
}
if (scheduledMessages.length === 0) {
return (
<div className={previewStyles.emptyState} data-flx="messaging.scheduled-messages-content.div">
<div className={previewStyles.emptyStateContent} data-flx="messaging.scheduled-messages-content.div--2">
<FlagCheckeredIcon
className={previewStyles.emptyStateIcon}
data-flx="messaging.scheduled-messages-content.flag-checkered-icon"
/>
<div className={previewStyles.emptyStateTextContainer} data-flx="messaging.scheduled-messages-content.div--3">
<h3 className={previewStyles.emptyStateTitle} data-flx="messaging.scheduled-messages-content.h3">
{i18n._(NO_SCHEDULED_MESSAGES_DESCRIPTOR)}
</h3>
<p className={previewStyles.emptyStateDescription} data-flx="messaging.scheduled-messages-content.p">
{i18n._(RIGHT_CLICK_THE_SEND_BUTTON_TO_SCHEDULE_A_DESCRIPTOR)}
</p>
</div>
</div>
</div>
);
}
const formatScheduledAt = (message: (typeof scheduledMessages)[number]) => {
try {
return formatScheduledMessage(message.scheduledAt, getCurrentLocale(), message.timezone);
} catch {
return `${message.scheduledLocalAt} (${message.timezone})`;
}
};
return (
<Scroller
className={previewStyles.scroller}
key="scheduled-messages-scroller"
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="messaging.scheduled-messages-content.scroller"
>
{scheduledMessages.map((message) => (
<div
key={message.id}
className={previewStyles.previewCard}
data-message-id={message.id}
data-is-group-start="true"
data-flx="messaging.scheduled-messages-content.div--4"
>
<div className={styles.cardHeader} data-flx="messaging.scheduled-messages-content.card-header">
<span
className={`${styles.statusBadge} ${message.status === 'invalid' ? styles.statusInvalid : ''}`}
data-flx="messaging.scheduled-messages-content.status-badge"
>
{message.status === 'invalid' ? i18n._(INVALID_DESCRIPTOR) : i18n._(SCHEDULED_DESCRIPTOR)}
</span>
<span className={styles.timestamp} data-flx="messaging.scheduled-messages-content.timestamp">
{formatScheduledAt(message)}
</span>
</div>
<p className={styles.messageText} data-flx="messaging.scheduled-messages-content.message-text">
{message.payload.content ??
(message.payload.attachments?.length
? i18n._(ATTACHMENT_ONLY_MESSAGE_DESCRIPTOR)
: i18n._(NO_CONTENT_DESCRIPTOR))}
</p>
{message.payload.attachments?.length ? (
<div className={styles.attachmentsInfo} data-flx="messaging.scheduled-messages-content.attachments-info">
{i18n._(ATTACHMENTS_DESCRIPTOR)}: {message.payload.attachments.length}
</div>
) : null}
{message.status === 'invalid' && message.statusReason ? (
<div className={styles.statusReason} data-flx="messaging.scheduled-messages-content.status-reason">
<WarningCircleIcon
className={styles.warningIcon}
weight="fill"
data-flx="messaging.scheduled-messages-content.warning-icon"
/>
<span data-flx="messaging.scheduled-messages-content.span">{message.statusReason}</span>
</div>
) : null}
<div className={previewStyles.actionButtons} data-flx="messaging.scheduled-messages-content.div--5">
<button
type="button"
className={previewStyles.actionButton}
onClick={() => handleCancel(message.id)}
disabled={cancellingId === message.id}
data-flx="messaging.scheduled-messages-content.button.cancel"
>
{i18n._(CANCEL_DESCRIPTOR)}
</button>
</div>
</div>
))}
<div className={previewStyles.endState} data-flx="messaging.scheduled-messages-content.div--6">
<div className={previewStyles.endStateContent} data-flx="messaging.scheduled-messages-content.div--7">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="messaging.scheduled-messages-content.flag-checkered-icon--2"
/>
<div className={previewStyles.endStateTextContainer} data-flx="messaging.scheduled-messages-content.div--8">
<h3 className={previewStyles.endStateTitle} data-flx="messaging.scheduled-messages-content.h3--2">
{i18n._(YOU_RE_CAUGHT_UP_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.scheduled-messages-content.p--2">
{i18n._(NO_MORE_SCHEDULED_MESSAGES_DESCRIPTOR)}
</p>
</div>
</div>
</div>
</Scroller>
);
});
@@ -1,81 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface ScheduledMessageReference {
message_id: string;
channel_id?: string;
guild_id?: string;
type?: number;
}
export interface ScheduledAllowedMentions {
parse?: Array<'users' | 'roles' | 'everyone'>;
users?: Array<string>;
roles?: Array<string>;
replied_user?: boolean;
}
export interface ScheduledAttachment {
id: string;
filename: string;
title?: string;
description?: string;
flags?: number;
}
export interface ScheduledMessagePayload {
content?: string | null;
embeds?: Array<unknown>;
attachments?: Array<ScheduledAttachment>;
message_reference?: ScheduledMessageReference;
allowed_mentions?: ScheduledAllowedMentions;
flags?: number;
nonce?: string;
favorite_meme_id?: string;
sticker_ids?: Array<string>;
tts?: boolean;
}
export type ScheduledMessageStatus = 'pending' | 'invalid';
export interface ScheduledMessageResponse {
id: string;
channel_id: string;
scheduled_at: string;
scheduled_local_at: string;
timezone: string;
status: ScheduledMessageStatus;
status_reason: string | null;
payload: ScheduledMessagePayload;
created_at: string;
invalidated_at: string | null;
}
export class ScheduledMessage {
readonly id: string;
readonly channelId: string;
readonly scheduledAt: Date;
readonly scheduledLocalAt: string;
readonly timezone: string;
readonly payload: ScheduledMessagePayload;
readonly status: ScheduledMessageStatus;
readonly statusReason: string | null;
readonly createdAt: Date;
readonly invalidatedAt: Date | null;
constructor(response: ScheduledMessageResponse) {
this.id = response.id;
this.channelId = response.channel_id;
this.scheduledAt = new Date(response.scheduled_at);
this.scheduledLocalAt = response.scheduled_local_at;
this.timezone = response.timezone;
this.payload = response.payload;
this.status = response.status ?? 'pending';
this.statusReason = response.status_reason;
this.createdAt = new Date(response.created_at);
this.invalidatedAt = response.invalidated_at ? new Date(response.invalidated_at) : null;
}
static fromResponse(response: ScheduledMessageResponse): ScheduledMessage {
return new ScheduledMessage(response);
}
}
@@ -1,44 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ScheduledMessage, ScheduledMessagePayload} from '@app/features/messaging/models/ScheduledMessage';
import {makeAutoObservable} from 'mobx';
interface ScheduledMessageEditState {
scheduledMessageId: string;
channelId: string;
payload: ScheduledMessagePayload;
scheduledLocalAt: string;
timezone: string;
}
class ScheduledMessageEditor {
private state: ScheduledMessageEditState | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
startEditing(record: ScheduledMessage): void {
this.state = {
scheduledMessageId: record.id,
channelId: record.channelId,
payload: record.payload,
scheduledLocalAt: record.scheduledLocalAt,
timezone: record.timezone,
};
}
stopEditing(): void {
this.state = null;
}
isEditingChannel(channelId: string): boolean {
return this.state?.channelId === channelId;
}
getEditingState(): ScheduledMessageEditState | null {
return this.state;
}
}
export default new ScheduledMessageEditor();
@@ -1,61 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ScheduledMessage} from '@app/features/messaging/models/ScheduledMessage';
import {makeAutoObservable} from 'mobx';
class ScheduledMessages {
scheduledMessages: Array<ScheduledMessage> = [];
fetched = false;
fetching = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get hasScheduledMessages(): boolean {
return this.scheduledMessages.length > 0;
}
fetchStart(): void {
this.fetching = true;
}
fetchSuccess(messages: Array<ScheduledMessage>): void {
this.scheduledMessages = sortScheduledMessages(messages);
this.fetching = false;
this.fetched = true;
}
fetchError(): void {
this.fetching = false;
this.fetched = false;
this.scheduledMessages = [];
}
handleGatewayReady(): void {
this.scheduledMessages = [];
this.fetched = false;
this.fetching = false;
}
upsert(message: ScheduledMessage): void {
const existingIndex = this.scheduledMessages.findIndex((entry) => entry.id === message.id);
const next = [...this.scheduledMessages];
if (existingIndex === -1) {
next.push(message);
} else {
next[existingIndex] = message;
}
this.scheduledMessages = sortScheduledMessages(next);
}
remove(messageId: string): void {
this.scheduledMessages = this.scheduledMessages.filter((message) => message.id !== messageId);
}
}
function sortScheduledMessages(messages: Array<ScheduledMessage>): Array<ScheduledMessage> {
return [...messages].sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
export default new ScheduledMessages();
@@ -113,7 +113,6 @@ export const ValidationErrorCodes = {
INVALID_CODE: 'INVALID_CODE', INVALID_CODE: 'INVALID_CODE',
INVALID_CURRENT_PASSWORD: 'INVALID_CURRENT_PASSWORD', INVALID_CURRENT_PASSWORD: 'INVALID_CURRENT_PASSWORD',
INVALID_DATE_OF_BIRTH_FORMAT: 'INVALID_DATE_OF_BIRTH_FORMAT', INVALID_DATE_OF_BIRTH_FORMAT: 'INVALID_DATE_OF_BIRTH_FORMAT',
INVALID_DATETIME_FOR_SCHEDULED_SEND: 'INVALID_DATETIME_FOR_SCHEDULED_SEND',
INVALID_EMAIL_ADDRESS: 'INVALID_EMAIL_ADDRESS', INVALID_EMAIL_ADDRESS: 'INVALID_EMAIL_ADDRESS',
INVALID_EMAIL_FORMAT: 'INVALID_EMAIL_FORMAT', INVALID_EMAIL_FORMAT: 'INVALID_EMAIL_FORMAT',
INVALID_EMAIL_LOCAL_PART: 'INVALID_EMAIL_LOCAL_PART', INVALID_EMAIL_LOCAL_PART: 'INVALID_EMAIL_LOCAL_PART',
@@ -143,7 +142,6 @@ export const ValidationErrorCodes = {
INVALID_PROOF_TOKEN: 'INVALID_PROOF_TOKEN', INVALID_PROOF_TOKEN: 'INVALID_PROOF_TOKEN',
INVALID_ROLE_ID: 'INVALID_ROLE_ID', INVALID_ROLE_ID: 'INVALID_ROLE_ID',
INVALID_RTC_REGION: 'INVALID_RTC_REGION', INVALID_RTC_REGION: 'INVALID_RTC_REGION',
INVALID_SCHEDULED_MESSAGE_PAYLOAD: 'INVALID_SCHEDULED_MESSAGE_PAYLOAD',
INVALID_SNOWFLAKE: 'INVALID_SNOWFLAKE', INVALID_SNOWFLAKE: 'INVALID_SNOWFLAKE',
INVALID_SSO_AUTHORIZATION_CODE: 'INVALID_SSO_AUTHORIZATION_CODE', INVALID_SSO_AUTHORIZATION_CODE: 'INVALID_SSO_AUTHORIZATION_CODE',
INVALID_SSO_TOKEN: 'INVALID_SSO_TOKEN', INVALID_SSO_TOKEN: 'INVALID_SSO_TOKEN',
@@ -201,8 +199,6 @@ export const ValidationErrorCodes = {
RECIPIENT_IDS_MUST_BE_VALID_SNOWFLAKES: 'RECIPIENT_IDS_MUST_BE_VALID_SNOWFLAKES', RECIPIENT_IDS_MUST_BE_VALID_SNOWFLAKES: 'RECIPIENT_IDS_MUST_BE_VALID_SNOWFLAKES',
REFERENCED_ATTACHMENT_NOT_FOUND: 'REFERENCED_ATTACHMENT_NOT_FOUND', REFERENCED_ATTACHMENT_NOT_FOUND: 'REFERENCED_ATTACHMENT_NOT_FOUND',
ROWS_IS_REQUIRED: 'ROWS_IS_REQUIRED', ROWS_IS_REQUIRED: 'ROWS_IS_REQUIRED',
SCHEDULED_MESSAGES_MAX_30_DAYS: 'SCHEDULED_MESSAGES_MAX_30_DAYS',
SCHEDULED_TIME_MUST_BE_FUTURE: 'SCHEDULED_TIME_MUST_BE_FUTURE',
SESSION_TIMEOUT: 'SESSION_TIMEOUT', SESSION_TIMEOUT: 'SESSION_TIMEOUT',
SIZE_BYTES_MUST_BE_VALID_INTEGER: 'SIZE_BYTES_MUST_BE_VALID_INTEGER', SIZE_BYTES_MUST_BE_VALID_INTEGER: 'SIZE_BYTES_MUST_BE_VALID_INTEGER',
SSO_IDENTITY_MISMATCH: 'SSO_IDENTITY_MISMATCH', SSO_IDENTITY_MISMATCH: 'SSO_IDENTITY_MISMATCH',
-33
View File
@@ -23,31 +23,6 @@ export function getFormattedDateTime(timestamp: DateInput, locale: string = DEFA
}).format(date); }).format(date);
} }
export function getFormattedDateTimeInZone(
isoString: string,
timezone: string,
locale: string = DEFAULT_LOCALE,
hour12?: boolean,
): string {
try {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) {
return isoString;
}
return getDateFormatter(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: resolveHour12(locale, hour12),
timeZone: timezone,
}).format(date);
} catch {
return isoString;
}
}
export function getFormattedShortDate(timestamp: DateInput, locale: string = DEFAULT_LOCALE): string { export function getFormattedShortDate(timestamp: DateInput, locale: string = DEFAULT_LOCALE): string {
return getDateFormatter(locale, {month: 'short', day: 'numeric', year: 'numeric'}).format(parseDate(timestamp)); return getDateFormatter(locale, {month: 'short', day: 'numeric', year: 'numeric'}).format(parseDate(timestamp));
} }
@@ -149,11 +124,3 @@ export function formatLastActive(date: DateInput, locale: string = DEFAULT_LOCAL
} }
return getDateFormatter(locale, {dateStyle: 'medium', timeStyle: 'short'}).format(dateObj); return getDateFormatter(locale, {dateStyle: 'medium', timeStyle: 'short'}).format(dateObj);
} }
export function formatScheduledMessage(date: DateInput, locale: string = DEFAULT_LOCALE, timeZone?: string): string {
const dateObj = parseDate(date);
if (Number.isNaN(dateObj.getTime())) {
return String(date);
}
return getDateFormatter(locale, {dateStyle: 'medium', timeStyle: 'short', timeZone}).format(dateObj);
}
@@ -384,10 +384,6 @@ export const ErrorCodeToI18nKey = {
[ValidationErrorCodes.VOICE_MESSAGES_DURATION_EXCEEDS_LIMIT]: 'calls_and_voice.voice_message_duration_exceeds_limit', [ValidationErrorCodes.VOICE_MESSAGES_DURATION_EXCEEDS_LIMIT]: 'calls_and_voice.voice_message_duration_exceeds_limit',
[ValidationErrorCodes.VOICE_MESSAGES_REQUIRE_SINGLE_ATTACHMENT]: [ValidationErrorCodes.VOICE_MESSAGES_REQUIRE_SINGLE_ATTACHMENT]:
'calls_and_voice.voice_messages_require_single_attachment', 'calls_and_voice.voice_messages_require_single_attachment',
[ValidationErrorCodes.SCHEDULED_TIME_MUST_BE_FUTURE]: 'messages.scheduled_time_must_be_future',
[ValidationErrorCodes.SCHEDULED_MESSAGES_MAX_30_DAYS]: 'messages.scheduled_messages_max_30_days',
[ValidationErrorCodes.INVALID_SCHEDULED_MESSAGE_PAYLOAD]: 'messages.invalid_scheduled_message_payload',
[ValidationErrorCodes.INVALID_DATETIME_FOR_SCHEDULED_SEND]: 'messages.scheduled_send_datetime_invalid',
[ValidationErrorCodes.MUST_START_SESSION_BEFORE_SENDING]: 'auth_and_oauth.must_start_session_before_sending_messages', [ValidationErrorCodes.MUST_START_SESSION_BEFORE_SENDING]: 'auth_and_oauth.must_start_session_before_sending_messages',
[ValidationErrorCodes.TOO_MANY_EMBEDS]: 'embeds.too_many_embeds', [ValidationErrorCodes.TOO_MANY_EMBEDS]: 'embeds.too_many_embeds',
[ValidationErrorCodes.ATTACHMENTS_METADATA_REQUIRED_WHEN_UPLOADING]: [ValidationErrorCodes.ATTACHMENTS_METADATA_REQUIRED_WHEN_UPLOADING]:
@@ -432,13 +432,9 @@ export const ERROR_I18N_MESSAGES = {
'messages.forward_reference_requires_channel_and_message': 'messages.forward_reference_requires_channel_and_message':
'Forward message reference must include `channel_id` and `message_id`.', 'Forward message reference must include `channel_id` and `message_id`.',
'messages.invalid_message_data': 'Invalid message data.', 'messages.invalid_message_data': 'Invalid message data.',
'messages.invalid_scheduled_message_payload': 'Invalid scheduled message payload.',
'messages.message_ids_cannot_be_empty': "`message_ids` can't be empty.", 'messages.message_ids_cannot_be_empty': "`message_ids` can't be empty.",
'messages.messages_array_required': '`messages` array is required and must not be empty.', 'messages.messages_array_required': '`messages` array is required and must not be empty.',
'messages.messages_with_snapshots_cannot_be_edited': "Messages with snapshots can't be edited.", 'messages.messages_with_snapshots_cannot_be_edited': "Messages with snapshots can't be edited.",
'messages.scheduled_messages_max_30_days': 'Scheduled messages can be at most 30 days in the future.',
'messages.scheduled_send_datetime_invalid': 'Invalid date or time for scheduled send.',
'messages.scheduled_time_must_be_future': 'Scheduled time must be in the future.',
'messages.voice_messages_attachment_must_be_audio': 'Voice message attachments must be audio files.', 'messages.voice_messages_attachment_must_be_audio': 'Voice message attachments must be audio files.',
'messages.voice_messages_cannot_have_content': "Voice messages can't have text content.", 'messages.voice_messages_cannot_have_content': "Voice messages can't have text content.",
'messages.voice_messages_cannot_have_embeds': "Voice messages can't have embeds.", 'messages.voice_messages_cannot_have_embeds': "Voice messages can't have embeds.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_AR_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "لا يمكن أن تحتوي الرسائل المعاد توجيهها على محتوى، تضمينات، مرفقات، أو ملصقات.", "messages.forward_messages_cannot_include_extra_content": "لا يمكن أن تحتوي الرسائل المعاد توجيهها على محتوى، تضمينات، مرفقات، أو ملصقات.",
"messages.forward_reference_requires_channel_and_message": "يجب أن يتضمن مرجع الرسالة المعاد توجيهها كلاً من `channel_id` و `message_id`.", "messages.forward_reference_requires_channel_and_message": "يجب أن يتضمن مرجع الرسالة المعاد توجيهها كلاً من `channel_id` و `message_id`.",
"messages.invalid_message_data": "بيانات الرسالة غير صحيحة.", "messages.invalid_message_data": "بيانات الرسالة غير صحيحة.",
"messages.invalid_scheduled_message_payload": "حمولة الرسالة المجدولة غير صالحة.",
"messages.message_ids_cannot_be_empty": "لا يمكن أن تكون `message_ids` فارغة.", "messages.message_ids_cannot_be_empty": "لا يمكن أن تكون `message_ids` فارغة.",
"messages.messages_array_required": "مصفوفة `messages` مطلوبة ولا يمكن أن تكون فارغة.", "messages.messages_array_required": "مصفوفة `messages` مطلوبة ولا يمكن أن تكون فارغة.",
"messages.messages_with_snapshots_cannot_be_edited": "لا يمكن تعديل الرسائل التي تحتوي على لقطات.", "messages.messages_with_snapshots_cannot_be_edited": "لا يمكن تعديل الرسائل التي تحتوي على لقطات.",
"messages.scheduled_messages_max_30_days": "يمكن جدولة الرسائل لمدة أقصاها 30 يومًا فقط.",
"messages.scheduled_send_datetime_invalid": "تاريخ أو وقت غير صحيح للإرسال المجدول.",
"messages.scheduled_time_must_be_future": "يجب أن يكون الوقت المحدد في المستقبل.",
"messages.voice_messages_attachment_must_be_audio": "يجب أن تكون مرفقات الرسائل الصوتية ملفات صوتية.", "messages.voice_messages_attachment_must_be_audio": "يجب أن تكون مرفقات الرسائل الصوتية ملفات صوتية.",
"messages.voice_messages_cannot_have_content": "لا يمكن للرسائل الصوتية أن تحتوي على نص.", "messages.voice_messages_cannot_have_content": "لا يمكن للرسائل الصوتية أن تحتوي على نص.",
"messages.voice_messages_cannot_have_embeds": "لا يمكن للرسائل الصوتية أن تحتوي على تضمينات.", "messages.voice_messages_cannot_have_embeds": "لا يمكن للرسائل الصوتية أن تحتوي على تضمينات.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_BG_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Препратените съобщения не могат да съдържат съдържание, вградено съдържание, прикачени файлове или стикери.", "messages.forward_messages_cannot_include_extra_content": "Препратените съобщения не могат да съдържат съдържание, вградено съдържание, прикачени файлове или стикери.",
"messages.forward_reference_requires_channel_and_message": "Препратката към съобщението трябва да съдържа `channel_id` и `message_id`.", "messages.forward_reference_requires_channel_and_message": "Препратката към съобщението трябва да съдържа `channel_id` и `message_id`.",
"messages.invalid_message_data": "Невалидни данни на съобщението.", "messages.invalid_message_data": "Невалидни данни на съобщението.",
"messages.invalid_scheduled_message_payload": "Невалидни данни за планирано съобщение.",
"messages.message_ids_cannot_be_empty": "`message_ids` не могат да бъдат празни.", "messages.message_ids_cannot_be_empty": "`message_ids` не могат да бъдат празни.",
"messages.messages_array_required": "Масивът `messages` е задължителен и не може да е празен.", "messages.messages_array_required": "Масивът `messages` е задължителен и не може да е празен.",
"messages.messages_with_snapshots_cannot_be_edited": "Съобщения с моментни снимки не могат да бъдат редактирани.", "messages.messages_with_snapshots_cannot_be_edited": "Съобщения с моментни снимки не могат да бъдат редактирани.",
"messages.scheduled_messages_max_30_days": "Планираните съобщения могат да бъдат най-много 30 дни в бъдещето.",
"messages.scheduled_send_datetime_invalid": "Невалидна дата или час за изпращане по график.",
"messages.scheduled_time_must_be_future": "Часът за изпращане трябва да е в бъдещето.",
"messages.voice_messages_attachment_must_be_audio": "Прикачените файлове към гласови съобщения трябва да бъдат аудио файлове.", "messages.voice_messages_attachment_must_be_audio": "Прикачените файлове към гласови съобщения трябва да бъдат аудио файлове.",
"messages.voice_messages_cannot_have_content": "Гласовите съобщения не могат да имат текстово съдържание.", "messages.voice_messages_cannot_have_content": "Гласовите съобщения не могат да имат текстово съдържание.",
"messages.voice_messages_cannot_have_embeds": "Гласовите съобщения не могат да съдържат вградено съдържание.", "messages.voice_messages_cannot_have_embeds": "Гласовите съобщения не могат да съдържат вградено съдържание.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_CS_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Přeposlané zprávy nemohou obsahovat obsah, vložené prvky, přílohy ani samolepky.", "messages.forward_messages_cannot_include_extra_content": "Přeposlané zprávy nemohou obsahovat obsah, vložené prvky, přílohy ani samolepky.",
"messages.forward_reference_requires_channel_and_message": "Odkaz na přeposlanou zprávu musí obsahovat `channel_id` a `message_id`.", "messages.forward_reference_requires_channel_and_message": "Odkaz na přeposlanou zprávu musí obsahovat `channel_id` a `message_id`.",
"messages.invalid_message_data": "Neplatná data u zprávy.", "messages.invalid_message_data": "Neplatná data u zprávy.",
"messages.invalid_scheduled_message_payload": "Neplatná data plánované zprávy.",
"messages.message_ids_cannot_be_empty": "`message_ids` nemohou být prázdné.", "messages.message_ids_cannot_be_empty": "`message_ids` nemohou být prázdné.",
"messages.messages_array_required": "Pole `messages` je nutné a nesmí být prázdné.", "messages.messages_array_required": "Pole `messages` je nutné a nesmí být prázdné.",
"messages.messages_with_snapshots_cannot_be_edited": "Zprávy se snímky nelze upravit.", "messages.messages_with_snapshots_cannot_be_edited": "Zprávy se snímky nelze upravit.",
"messages.scheduled_messages_max_30_days": "Plánované zprávy mohou být maximálně 30 dní do budoucna.",
"messages.scheduled_send_datetime_invalid": "Neplatné datum nebo čas pro plánované odeslání.",
"messages.scheduled_time_must_be_future": "Plánovaný čas musí být v budoucnu.",
"messages.voice_messages_attachment_must_be_audio": "Přílohy hlasových zpráv musí být zvukové soubory.", "messages.voice_messages_attachment_must_be_audio": "Přílohy hlasových zpráv musí být zvukové soubory.",
"messages.voice_messages_cannot_have_content": "Hlasové zprávy nemohou mít text.", "messages.voice_messages_cannot_have_content": "Hlasové zprávy nemohou mít text.",
"messages.voice_messages_cannot_have_embeds": "Hlasové zprávy nemohou mít vložené prvky.", "messages.voice_messages_cannot_have_embeds": "Hlasové zprávy nemohou mít vložené prvky.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_DA_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Videresendte beskeder kan ikke indeholde indhold, indlejringer, vedhæftede filer eller stickers.", "messages.forward_messages_cannot_include_extra_content": "Videresendte beskeder kan ikke indeholde indhold, indlejringer, vedhæftede filer eller stickers.",
"messages.forward_reference_requires_channel_and_message": "Reference for videresendte beskeder skal indeholde `channel_id` og `message_id`.", "messages.forward_reference_requires_channel_and_message": "Reference for videresendte beskeder skal indeholde `channel_id` og `message_id`.",
"messages.invalid_message_data": "Ugyldige beskeddata.", "messages.invalid_message_data": "Ugyldige beskeddata.",
"messages.invalid_scheduled_message_payload": "Ugyldig planlagt besked-nyttelast.",
"messages.message_ids_cannot_be_empty": "`message_ids` må ikke være tomme.", "messages.message_ids_cannot_be_empty": "`message_ids` må ikke være tomme.",
"messages.messages_array_required": "`messages`-arrayet skal være angivet og må ikke være tomt.", "messages.messages_array_required": "`messages`-arrayet skal være angivet og må ikke være tomt.",
"messages.messages_with_snapshots_cannot_be_edited": "Beskeder med snapshots kan ikke redigeres.", "messages.messages_with_snapshots_cannot_be_edited": "Beskeder med snapshots kan ikke redigeres.",
"messages.scheduled_messages_max_30_days": "Planlagte beskeder kan højst være 30 dage ude i fremtiden.",
"messages.scheduled_send_datetime_invalid": "Ugyldig dato eller tidspunkt for planlagt afsendelse.",
"messages.scheduled_time_must_be_future": "Planlagt tidspunkt skal være i fremtiden.",
"messages.voice_messages_attachment_must_be_audio": "Vedhæftede filer til talebeskeder skal være lydfiler.", "messages.voice_messages_attachment_must_be_audio": "Vedhæftede filer til talebeskeder skal være lydfiler.",
"messages.voice_messages_cannot_have_content": "Talebeskeder kan ikke have tekstindhold.", "messages.voice_messages_cannot_have_content": "Talebeskeder kan ikke have tekstindhold.",
"messages.voice_messages_cannot_have_embeds": "Talebeskeder kan ikke have indlejringer.", "messages.voice_messages_cannot_have_embeds": "Talebeskeder kan ikke have indlejringer.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_DE_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Weitergeleitete Nachrichten dürfen keine Inhalte, Embeds, Anhänge oder Sticker enthalten.", "messages.forward_messages_cannot_include_extra_content": "Weitergeleitete Nachrichten dürfen keine Inhalte, Embeds, Anhänge oder Sticker enthalten.",
"messages.forward_reference_requires_channel_and_message": "Die Referenz für weitergeleitete Nachrichten muss `channel_id` und `message_id` enthalten.", "messages.forward_reference_requires_channel_and_message": "Die Referenz für weitergeleitete Nachrichten muss `channel_id` und `message_id` enthalten.",
"messages.invalid_message_data": "Ungültige Nachrichtendaten.", "messages.invalid_message_data": "Ungültige Nachrichtendaten.",
"messages.invalid_scheduled_message_payload": "Ungültige Payload für geplante Nachrichten.",
"messages.message_ids_cannot_be_empty": "`message_ids` darf nicht leer sein.", "messages.message_ids_cannot_be_empty": "`message_ids` darf nicht leer sein.",
"messages.messages_array_required": "Das `messages`-Array ist erforderlich und darf nicht leer sein.", "messages.messages_array_required": "Das `messages`-Array ist erforderlich und darf nicht leer sein.",
"messages.messages_with_snapshots_cannot_be_edited": "Nachrichten mit Snapshots können nicht bearbeitet werden.", "messages.messages_with_snapshots_cannot_be_edited": "Nachrichten mit Snapshots können nicht bearbeitet werden.",
"messages.scheduled_messages_max_30_days": "Geplante Nachrichten können maximal 30 Tage in die Zukunft reichen.",
"messages.scheduled_send_datetime_invalid": "Ungültiges Datum oder ungültige Uhrzeit für den geplanten Versand.",
"messages.scheduled_time_must_be_future": "Die geplante Zeit muss in der Zukunft sein.",
"messages.voice_messages_attachment_must_be_audio": "Sprachnachrichten-Anhänge müssen Audiodateien sein.", "messages.voice_messages_attachment_must_be_audio": "Sprachnachrichten-Anhänge müssen Audiodateien sein.",
"messages.voice_messages_cannot_have_content": "Sprachnachrichten können keinen Text enthalten.", "messages.voice_messages_cannot_have_content": "Sprachnachrichten können keinen Text enthalten.",
"messages.voice_messages_cannot_have_embeds": "Sprachnachrichten können keine Einbettungen enthalten.", "messages.voice_messages_cannot_have_embeds": "Sprachnachrichten können keine Einbettungen enthalten.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_EL_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Τα προωθημένα μηνύματα δεν μπορούν να περιλαμβάνουν περιεχόμενο, ενσωματώσεις, συνημμένα ή sticker.", "messages.forward_messages_cannot_include_extra_content": "Τα προωθημένα μηνύματα δεν μπορούν να περιλαμβάνουν περιεχόμενο, ενσωματώσεις, συνημμένα ή sticker.",
"messages.forward_reference_requires_channel_and_message": "Η αναφορά προώθησης μηνύματος πρέπει να περιλαμβάνει `channel_id` και `message_id`.", "messages.forward_reference_requires_channel_and_message": "Η αναφορά προώθησης μηνύματος πρέπει να περιλαμβάνει `channel_id` και `message_id`.",
"messages.invalid_message_data": "Μη έγκυρα δεδομένα μηνύματος.", "messages.invalid_message_data": "Μη έγκυρα δεδομένα μηνύματος.",
"messages.invalid_scheduled_message_payload": "Μη έγκυρο ωφέλιμο φορτίο προγραμματισμένου μηνύματος.",
"messages.message_ids_cannot_be_empty": "Το `message_ids` δεν μπορεί να είναι άδειο.", "messages.message_ids_cannot_be_empty": "Το `message_ids` δεν μπορεί να είναι άδειο.",
"messages.messages_array_required": "Ο πίνακας `messages` απαιτείται και δεν μπορεί να είναι άδειος.", "messages.messages_array_required": "Ο πίνακας `messages` απαιτείται και δεν μπορεί να είναι άδειος.",
"messages.messages_with_snapshots_cannot_be_edited": "Δεν μπορείς να επεξεργαστείς μηνύματα με στιγμιότυπα.", "messages.messages_with_snapshots_cannot_be_edited": "Δεν μπορείς να επεξεργαστείς μηνύματα με στιγμιότυπα.",
"messages.scheduled_messages_max_30_days": "Τα προγραμματισμένα μηνύματα μπορούν να οριστούν έως και 30 ημέρες από τώρα.",
"messages.scheduled_send_datetime_invalid": "Άκυρη ημερομηνία ή ώρα για προγραμματισμένη αποστολή.",
"messages.scheduled_time_must_be_future": "Η προγραμματισμένη ώρα πρέπει να είναι στο μέλλον.",
"messages.voice_messages_attachment_must_be_audio": "Τα συνημμένα φωνητικά μηνύματα πρέπει να είναι αρχεία ήχου.", "messages.voice_messages_attachment_must_be_audio": "Τα συνημμένα φωνητικά μηνύματα πρέπει να είναι αρχεία ήχου.",
"messages.voice_messages_cannot_have_content": "Τα φωνητικά μηνύματα δεν μπορούν να έχουν κείμενο.", "messages.voice_messages_cannot_have_content": "Τα φωνητικά μηνύματα δεν μπορούν να έχουν κείμενο.",
"messages.voice_messages_cannot_have_embeds": "Τα φωνητικά μηνύματα δεν μπορούν να έχουν ενσωματώσεις.", "messages.voice_messages_cannot_have_embeds": "Τα φωνητικά μηνύματα δεν μπορούν να έχουν ενσωματώσεις.",
@@ -375,13 +375,9 @@ const ERROR_I18N_EN_GB_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Forwarded messages can't include content, embeds, attachments, or stickers.", "messages.forward_messages_cannot_include_extra_content": "Forwarded messages can't include content, embeds, attachments, or stickers.",
"messages.forward_reference_requires_channel_and_message": "Forward message reference must include `channel_id` and `message_id`.", "messages.forward_reference_requires_channel_and_message": "Forward message reference must include `channel_id` and `message_id`.",
"messages.invalid_message_data": "Invalid message data.", "messages.invalid_message_data": "Invalid message data.",
"messages.invalid_scheduled_message_payload": "Invalid scheduled message payload.",
"messages.message_ids_cannot_be_empty": "`message_ids` can't be empty.", "messages.message_ids_cannot_be_empty": "`message_ids` can't be empty.",
"messages.messages_array_required": "`messages` array is required and must not be empty.", "messages.messages_array_required": "`messages` array is required and must not be empty.",
"messages.messages_with_snapshots_cannot_be_edited": "Messages with snapshots can't be edited.", "messages.messages_with_snapshots_cannot_be_edited": "Messages with snapshots can't be edited.",
"messages.scheduled_messages_max_30_days": "Scheduled messages can be at most 30 days in the future.",
"messages.scheduled_send_datetime_invalid": "Invalid date or time for scheduled send.",
"messages.scheduled_time_must_be_future": "Scheduled time must be in the future.",
"messages.voice_messages_attachment_must_be_audio": "Voice message attachments must be audio files.", "messages.voice_messages_attachment_must_be_audio": "Voice message attachments must be audio files.",
"messages.voice_messages_cannot_have_content": "Voice messages can't have text content.", "messages.voice_messages_cannot_have_content": "Voice messages can't have text content.",
"messages.voice_messages_cannot_have_embeds": "Voice messages can't have embeds.", "messages.voice_messages_cannot_have_embeds": "Voice messages can't have embeds.",
@@ -375,13 +375,9 @@ const ERROR_I18N_ES_419_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Los mensajes reenviados no pueden incluir contenido, elementos incrustados, archivos adjuntos o stickers.", "messages.forward_messages_cannot_include_extra_content": "Los mensajes reenviados no pueden incluir contenido, elementos incrustados, archivos adjuntos o stickers.",
"messages.forward_reference_requires_channel_and_message": "La referencia del mensaje reenviado debe incluir `channel_id` y `message_id`.", "messages.forward_reference_requires_channel_and_message": "La referencia del mensaje reenviado debe incluir `channel_id` y `message_id`.",
"messages.invalid_message_data": "Mensaje con datos no válidos.", "messages.invalid_message_data": "Mensaje con datos no válidos.",
"messages.invalid_scheduled_message_payload": "Carga útil de mensaje programado no válida.",
"messages.message_ids_cannot_be_empty": "`message_ids` no puede estar vacío.", "messages.message_ids_cannot_be_empty": "`message_ids` no puede estar vacío.",
"messages.messages_array_required": "El arreglo `messages` es requerido y no puede estar vacío.", "messages.messages_array_required": "El arreglo `messages` es requerido y no puede estar vacío.",
"messages.messages_with_snapshots_cannot_be_edited": "Los mensajes con capturas no se pueden editar.", "messages.messages_with_snapshots_cannot_be_edited": "Los mensajes con capturas no se pueden editar.",
"messages.scheduled_messages_max_30_days": "Los mensajes programados pueden enviarse hasta con 30 días de anticipación.",
"messages.scheduled_send_datetime_invalid": "La fecha o la hora del envío programado no es válida.",
"messages.scheduled_time_must_be_future": "La hora programada debe estar en el futuro.",
"messages.voice_messages_attachment_must_be_audio": "Los archivos adjuntos de los mensajes de voz deben ser archivos de audio.", "messages.voice_messages_attachment_must_be_audio": "Los archivos adjuntos de los mensajes de voz deben ser archivos de audio.",
"messages.voice_messages_cannot_have_content": "Los mensajes de voz no pueden incluir texto.", "messages.voice_messages_cannot_have_content": "Los mensajes de voz no pueden incluir texto.",
"messages.voice_messages_cannot_have_embeds": "Los mensajes de voz no pueden tener embeds.", "messages.voice_messages_cannot_have_embeds": "Los mensajes de voz no pueden tener embeds.",
@@ -375,13 +375,9 @@ const ERROR_I18N_ES_ES_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Los mensajes reenviados no pueden incluir contenido, elementos incrustados, archivos adjuntos o stickers.", "messages.forward_messages_cannot_include_extra_content": "Los mensajes reenviados no pueden incluir contenido, elementos incrustados, archivos adjuntos o stickers.",
"messages.forward_reference_requires_channel_and_message": "La referencia del mensaje reenviado debe incluir `channel_id` y `message_id`.", "messages.forward_reference_requires_channel_and_message": "La referencia del mensaje reenviado debe incluir `channel_id` y `message_id`.",
"messages.invalid_message_data": "Datos de mensaje no válidos.", "messages.invalid_message_data": "Datos de mensaje no válidos.",
"messages.invalid_scheduled_message_payload": "Carga útil de mensaje programado no válida.",
"messages.message_ids_cannot_be_empty": "`message_ids` no puede estar vacío.", "messages.message_ids_cannot_be_empty": "`message_ids` no puede estar vacío.",
"messages.messages_array_required": "El array `messages` es necesario y no puede estar vacío.", "messages.messages_array_required": "El array `messages` es necesario y no puede estar vacío.",
"messages.messages_with_snapshots_cannot_be_edited": "Los mensajes con instantáneas no se pueden editar.", "messages.messages_with_snapshots_cannot_be_edited": "Los mensajes con instantáneas no se pueden editar.",
"messages.scheduled_messages_max_30_days": "Los mensajes programados pueden ser como máximo 30 días en el futuro.",
"messages.scheduled_send_datetime_invalid": "Fecha u hora no válidas para el envío programado.",
"messages.scheduled_time_must_be_future": "La hora programada debe ser en el futuro.",
"messages.voice_messages_attachment_must_be_audio": "Los adjuntos de los mensajes de voz deben ser archivos de audio.", "messages.voice_messages_attachment_must_be_audio": "Los adjuntos de los mensajes de voz deben ser archivos de audio.",
"messages.voice_messages_cannot_have_content": "Los mensajes de voz no pueden tener contenido de texto.", "messages.voice_messages_cannot_have_content": "Los mensajes de voz no pueden tener contenido de texto.",
"messages.voice_messages_cannot_have_embeds": "Los mensajes de voz no pueden tener embeds.", "messages.voice_messages_cannot_have_embeds": "Los mensajes de voz no pueden tener embeds.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_FI_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Edelleenlähetetyt viestit eivät voi sisältää sisältöä, upotuksia, liitteitä tai tarroja.", "messages.forward_messages_cannot_include_extra_content": "Edelleenlähetetyt viestit eivät voi sisältää sisältöä, upotuksia, liitteitä tai tarroja.",
"messages.forward_reference_requires_channel_and_message": "Edelleenlähetetyn viestin viitteen on sisällettävä `channel_id` ja `message_id`.", "messages.forward_reference_requires_channel_and_message": "Edelleenlähetetyn viestin viitteen on sisällettävä `channel_id` ja `message_id`.",
"messages.invalid_message_data": "Virheellinen viestidata.", "messages.invalid_message_data": "Virheellinen viestidata.",
"messages.invalid_scheduled_message_payload": "Virheellinen ajoitetun viestin hyötykuorma.",
"messages.message_ids_cannot_be_empty": "`message_ids` ei voi olla tyhjä.", "messages.message_ids_cannot_be_empty": "`message_ids` ei voi olla tyhjä.",
"messages.messages_array_required": "`messages`-taulukko on pakollinen eikä se saa olla tyhjä.", "messages.messages_array_required": "`messages`-taulukko on pakollinen eikä se saa olla tyhjä.",
"messages.messages_with_snapshots_cannot_be_edited": "Viestit, joissa on tilannekuvia, eivät ole muokattavissa.", "messages.messages_with_snapshots_cannot_be_edited": "Viestit, joissa on tilannekuvia, eivät ole muokattavissa.",
"messages.scheduled_messages_max_30_days": "Ajoitetut viestit voivat olla enintään 30 päivän päässä tulevaisuudessa.",
"messages.scheduled_send_datetime_invalid": "Ajoitetun lähetyksen päivämäärä tai aika on virheellinen.",
"messages.scheduled_time_must_be_future": "Ajoitetun ajan on oltava tulevaisuudessa.",
"messages.voice_messages_attachment_must_be_audio": "Ääniviestin liitteiden täytyy olla äänitiedostoja.", "messages.voice_messages_attachment_must_be_audio": "Ääniviestin liitteiden täytyy olla äänitiedostoja.",
"messages.voice_messages_cannot_have_content": "Ääniviesteissä ei voi olla tekstisisältöä.", "messages.voice_messages_cannot_have_content": "Ääniviesteissä ei voi olla tekstisisältöä.",
"messages.voice_messages_cannot_have_embeds": "Ääniviesteissä ei voi olla upotteita.", "messages.voice_messages_cannot_have_embeds": "Ääniviesteissä ei voi olla upotteita.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_FR_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Les messages transférés ne peuvent pas inclure de contenu, d'éléments intégrés, de pièces jointes ou de stickers.", "messages.forward_messages_cannot_include_extra_content": "Les messages transférés ne peuvent pas inclure de contenu, d'éléments intégrés, de pièces jointes ou de stickers.",
"messages.forward_reference_requires_channel_and_message": "La référence du message à transférer doit inclure `channel_id` et `message_id`.", "messages.forward_reference_requires_channel_and_message": "La référence du message à transférer doit inclure `channel_id` et `message_id`.",
"messages.invalid_message_data": "Données de message non valides.", "messages.invalid_message_data": "Données de message non valides.",
"messages.invalid_scheduled_message_payload": "Contenu du message programmé invalide.",
"messages.message_ids_cannot_be_empty": "Le champ `message_ids` ne peut pas être vide.", "messages.message_ids_cannot_be_empty": "Le champ `message_ids` ne peut pas être vide.",
"messages.messages_array_required": "Le tableau `messages` est obligatoire et ne doit pas être vide.", "messages.messages_array_required": "Le tableau `messages` est obligatoire et ne doit pas être vide.",
"messages.messages_with_snapshots_cannot_be_edited": "Les messages avec des captures d'écran ne peuvent pas être modifiés.", "messages.messages_with_snapshots_cannot_be_edited": "Les messages avec des captures d'écran ne peuvent pas être modifiés.",
"messages.scheduled_messages_max_30_days": "Les messages programmés ne peuvent pas être planifiés plus de 30 jours à l'avance.",
"messages.scheduled_send_datetime_invalid": "Date ou heure incorrecte pour l'envoi programmé.",
"messages.scheduled_time_must_be_future": "L'heure programmée doit être dans le futur.",
"messages.voice_messages_attachment_must_be_audio": "Les pièces jointes des messages vocaux doivent être des fichiers audio.", "messages.voice_messages_attachment_must_be_audio": "Les pièces jointes des messages vocaux doivent être des fichiers audio.",
"messages.voice_messages_cannot_have_content": "Les messages vocaux ne peuvent pas avoir de contenu textuel.", "messages.voice_messages_cannot_have_content": "Les messages vocaux ne peuvent pas avoir de contenu textuel.",
"messages.voice_messages_cannot_have_embeds": "Les messages vocaux ne peuvent pas avoir d'intégrations.", "messages.voice_messages_cannot_have_embeds": "Les messages vocaux ne peuvent pas avoir d'intégrations.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_HE_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "הודעות מועברות לא יכולות לכלול תוכן, הטמעות, קבצים מצורפים או סטיקרים.", "messages.forward_messages_cannot_include_extra_content": "הודעות מועברות לא יכולות לכלול תוכן, הטמעות, קבצים מצורפים או סטיקרים.",
"messages.forward_reference_requires_channel_and_message": "הפניית הודעה מועברת צריכה לכלול `channel_id` ו-`message_id`.", "messages.forward_reference_requires_channel_and_message": "הפניית הודעה מועברת צריכה לכלול `channel_id` ו-`message_id`.",
"messages.invalid_message_data": "נתוני הודעה לא חוקיים.", "messages.invalid_message_data": "נתוני הודעה לא חוקיים.",
"messages.invalid_scheduled_message_payload": "מטען נתונים שגוי עבור הודעה מתוזמנת.",
"messages.message_ids_cannot_be_empty": "`message_ids` לא יכול להיות ריק.", "messages.message_ids_cannot_be_empty": "`message_ids` לא יכול להיות ריק.",
"messages.messages_array_required": "מערך `messages` נדרש ולא יכול להיות ריק.", "messages.messages_array_required": "מערך `messages` נדרש ולא יכול להיות ריק.",
"messages.messages_with_snapshots_cannot_be_edited": "לא ניתן לערוך הודעות הכוללות צילומי מסך.", "messages.messages_with_snapshots_cannot_be_edited": "לא ניתן לערוך הודעות הכוללות צילומי מסך.",
"messages.scheduled_messages_max_30_days": "ניתן לתזמן הודעות ל-30 הימים הבאים לכל היותר.",
"messages.scheduled_send_datetime_invalid": "תאריך או שעה לא חוקיים לשליחה מתוזמנת.",
"messages.scheduled_time_must_be_future": "מועד השליחה חייב להיות עתידי.",
"messages.voice_messages_attachment_must_be_audio": "קבצים מצורפים בהודעות קוליות חייבים להיות קובצי שמע.", "messages.voice_messages_attachment_must_be_audio": "קבצים מצורפים בהודעות קוליות חייבים להיות קובצי שמע.",
"messages.voice_messages_cannot_have_content": "הודעות קוליות אינן יכולות לכלול תוכן טקסט.", "messages.voice_messages_cannot_have_content": "הודעות קוליות אינן יכולות לכלול תוכן טקסט.",
"messages.voice_messages_cannot_have_embeds": "הודעות קוליות לא יכולות לכלול הטמעות.", "messages.voice_messages_cannot_have_embeds": "הודעות קוליות לא יכולות לכלול הטמעות.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_HI_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "फॉरवर्ड किए गए मैसेज में कंटेंट, एम्बेड, अटैचमेंट या स्टीकर शामिल नहीं हो सकते हैं।", "messages.forward_messages_cannot_include_extra_content": "फॉरवर्ड किए गए मैसेज में कंटेंट, एम्बेड, अटैचमेंट या स्टीकर शामिल नहीं हो सकते हैं।",
"messages.forward_reference_requires_channel_and_message": "फॉरवर्ड मैसेज रेफरेंस में `channel_id` और `message_id` शामिल होने चाहिए।", "messages.forward_reference_requires_channel_and_message": "फॉरवर्ड मैसेज रेफरेंस में `channel_id` और `message_id` शामिल होने चाहिए।",
"messages.invalid_message_data": "अमान्य मैसेज डेटा।", "messages.invalid_message_data": "अमान्य मैसेज डेटा।",
"messages.invalid_scheduled_message_payload": "अमान्य शेड्यूल किया गया मैसेज पेलोड।",
"messages.message_ids_cannot_be_empty": "`message_ids` खाली नहीं हो सकते।", "messages.message_ids_cannot_be_empty": "`message_ids` खाली नहीं हो सकते।",
"messages.messages_array_required": "`messages` एरे ज़रूरी है और खाली नहीं होना चाहिए।", "messages.messages_array_required": "`messages` एरे ज़रूरी है और खाली नहीं होना चाहिए।",
"messages.messages_with_snapshots_cannot_be_edited": "स्नैपशॉट वाले मैसेज एडिट नहीं किए जा सकते हैं.", "messages.messages_with_snapshots_cannot_be_edited": "स्नैपशॉट वाले मैसेज एडिट नहीं किए जा सकते हैं.",
"messages.scheduled_messages_max_30_days": "शेड्यूल किए गए मैसेज ज़्यादा से ज़्यादा 30 दिन आगे के हो सकते हैं.",
"messages.scheduled_send_datetime_invalid": "शेड्यूल किए गए सेंड के लिए अमान्य तारीख या समय.",
"messages.scheduled_time_must_be_future": "शेड्यूल किया गया समय भविष्य में होना चाहिए.",
"messages.voice_messages_attachment_must_be_audio": "वॉइस मैसेज अटैचमेंट सिर्फ़ ऑडियो फाइल होने चाहिए.", "messages.voice_messages_attachment_must_be_audio": "वॉइस मैसेज अटैचमेंट सिर्फ़ ऑडियो फाइल होने चाहिए.",
"messages.voice_messages_cannot_have_content": "वॉइस मैसेज में टेक्स्ट कंटेंट नहीं हो सकता है.", "messages.voice_messages_cannot_have_content": "वॉइस मैसेज में टेक्स्ट कंटेंट नहीं हो सकता है.",
"messages.voice_messages_cannot_have_embeds": "वॉइस मैसेज में एम्बेड नहीं हो सकते हैं.", "messages.voice_messages_cannot_have_embeds": "वॉइस मैसेज में एम्बेड नहीं हो सकते हैं.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_HR_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Proslijeđene poruke ne mogu sadržavati sadržaj, ugrađene objekte, privitke ili naljepnice.", "messages.forward_messages_cannot_include_extra_content": "Proslijeđene poruke ne mogu sadržavati sadržaj, ugrađene objekte, privitke ili naljepnice.",
"messages.forward_reference_requires_channel_and_message": "Referenca prosljeđene poruke mora sadržavati `channel_id` i `message_id`.", "messages.forward_reference_requires_channel_and_message": "Referenca prosljeđene poruke mora sadržavati `channel_id` i `message_id`.",
"messages.invalid_message_data": "Neispravni podaci poruke.", "messages.invalid_message_data": "Neispravni podaci poruke.",
"messages.invalid_scheduled_message_payload": "Neispravan payload zakazane poruke.",
"messages.message_ids_cannot_be_empty": "`message_ids` ne može biti prazan.", "messages.message_ids_cannot_be_empty": "`message_ids` ne može biti prazan.",
"messages.messages_array_required": "Polje `messages` je obavezno i ne smije biti prazno.", "messages.messages_array_required": "Polje `messages` je obavezno i ne smije biti prazno.",
"messages.messages_with_snapshots_cannot_be_edited": "Poruke sa snimkama ne mogu se uređivati.", "messages.messages_with_snapshots_cannot_be_edited": "Poruke sa snimkama ne mogu se uređivati.",
"messages.scheduled_messages_max_30_days": "Zakazane poruke mogu biti najviše 30 dana u budućnosti.",
"messages.scheduled_send_datetime_invalid": "Nevažeći datum ili vrijeme za zakazano slanje.",
"messages.scheduled_time_must_be_future": "Zakazano vrijeme mora biti u budućnosti.",
"messages.voice_messages_attachment_must_be_audio": "Privitci glasovnih poruka moraju biti audio datoteke.", "messages.voice_messages_attachment_must_be_audio": "Privitci glasovnih poruka moraju biti audio datoteke.",
"messages.voice_messages_cannot_have_content": "Glasovne poruke ne mogu imati tekstualni sadržaj.", "messages.voice_messages_cannot_have_content": "Glasovne poruke ne mogu imati tekstualni sadržaj.",
"messages.voice_messages_cannot_have_embeds": "Glasovne poruke ne mogu imati ugrađene elemente.", "messages.voice_messages_cannot_have_embeds": "Glasovne poruke ne mogu imati ugrađene elemente.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_HU_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "A továbbított üzenetek nem tartalmazhatnak tartalmat, beágyazásokat, mellékleteket vagy matricákat.", "messages.forward_messages_cannot_include_extra_content": "A továbbított üzenetek nem tartalmazhatnak tartalmat, beágyazásokat, mellékleteket vagy matricákat.",
"messages.forward_reference_requires_channel_and_message": "A továbbított üzenet hivatkozásának tartalmaznia kell a `channel_id` és a `message_id` azonosítót.", "messages.forward_reference_requires_channel_and_message": "A továbbított üzenet hivatkozásának tartalmaznia kell a `channel_id` és a `message_id` azonosítót.",
"messages.invalid_message_data": "Érvénytelen üzenetadatok.", "messages.invalid_message_data": "Érvénytelen üzenetadatok.",
"messages.invalid_scheduled_message_payload": "Érvénytelen ütemezett üzenetcsomag.",
"messages.message_ids_cannot_be_empty": "A `message_ids` nem maradhat üresen.", "messages.message_ids_cannot_be_empty": "A `message_ids` nem maradhat üresen.",
"messages.messages_array_required": "A `messages` tömb szükséges és nem maradhat üresen.", "messages.messages_array_required": "A `messages` tömb szükséges és nem maradhat üresen.",
"messages.messages_with_snapshots_cannot_be_edited": "A pillanatkép-üzenetek nem szerkeszthetők.", "messages.messages_with_snapshots_cannot_be_edited": "A pillanatkép-üzenetek nem szerkeszthetők.",
"messages.scheduled_messages_max_30_days": "Az ütemezett üzenetek legfeljebb 30 nappal későbbre küldhetők.",
"messages.scheduled_send_datetime_invalid": "Érvénytelen dátum vagy idő az ütemezett küldéshez.",
"messages.scheduled_time_must_be_future": "Az ütemezés időpontjának a jövőben kell lennie.",
"messages.voice_messages_attachment_must_be_audio": "A hangüzenet mellékleteinek hangfájlokat kell tartalmazniuk.", "messages.voice_messages_attachment_must_be_audio": "A hangüzenet mellékleteinek hangfájlokat kell tartalmazniuk.",
"messages.voice_messages_cannot_have_content": "A hangüzenetek nem tartalmazhatnak szöveges tartalmat.", "messages.voice_messages_cannot_have_content": "A hangüzenetek nem tartalmazhatnak szöveges tartalmat.",
"messages.voice_messages_cannot_have_embeds": "A hangüzenetek nem tartalmazhatnak beágyazásokat.", "messages.voice_messages_cannot_have_embeds": "A hangüzenetek nem tartalmazhatnak beágyazásokat.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_ID_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Pesan yang diteruskan tidak bisa menyertakan konten, embed, lampiran, atau stiker.", "messages.forward_messages_cannot_include_extra_content": "Pesan yang diteruskan tidak bisa menyertakan konten, embed, lampiran, atau stiker.",
"messages.forward_reference_requires_channel_and_message": "Referensi pesan terusan harus menyertakan `channel_id` dan `message_id`.", "messages.forward_reference_requires_channel_and_message": "Referensi pesan terusan harus menyertakan `channel_id` dan `message_id`.",
"messages.invalid_message_data": "Data pesan tidak valid.", "messages.invalid_message_data": "Data pesan tidak valid.",
"messages.invalid_scheduled_message_payload": "Payload pesan terjadwal tidak valid.",
"messages.message_ids_cannot_be_empty": "`message_ids` tidak boleh kosong.", "messages.message_ids_cannot_be_empty": "`message_ids` tidak boleh kosong.",
"messages.messages_array_required": "Array `messages` diperlukan dan tidak boleh kosong.", "messages.messages_array_required": "Array `messages` diperlukan dan tidak boleh kosong.",
"messages.messages_with_snapshots_cannot_be_edited": "Pesan dengan snapshot tidak dapat diedit.", "messages.messages_with_snapshots_cannot_be_edited": "Pesan dengan snapshot tidak dapat diedit.",
"messages.scheduled_messages_max_30_days": "Pesan terjadwal maksimal 30 hari ke depan.",
"messages.scheduled_send_datetime_invalid": "Tanggal atau waktu tidak valid untuk kirim terjadwal.",
"messages.scheduled_time_must_be_future": "Waktu terjadwal harus di waktu mendatang.",
"messages.voice_messages_attachment_must_be_audio": "Lampiran pesan suara harus berupa file audio.", "messages.voice_messages_attachment_must_be_audio": "Lampiran pesan suara harus berupa file audio.",
"messages.voice_messages_cannot_have_content": "Pesan suara tidak boleh memiliki konten teks.", "messages.voice_messages_cannot_have_content": "Pesan suara tidak boleh memiliki konten teks.",
"messages.voice_messages_cannot_have_embeds": "Pesan suara tidak boleh memiliki sematan.", "messages.voice_messages_cannot_have_embeds": "Pesan suara tidak boleh memiliki sematan.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_IT_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "I messaggi inoltrati non possono includere contenuti, embed, allegati o sticker.", "messages.forward_messages_cannot_include_extra_content": "I messaggi inoltrati non possono includere contenuti, embed, allegati o sticker.",
"messages.forward_reference_requires_channel_and_message": "Il riferimento per l'inoltro del messaggio deve includere `channel_id` e `message_id`.", "messages.forward_reference_requires_channel_and_message": "Il riferimento per l'inoltro del messaggio deve includere `channel_id` e `message_id`.",
"messages.invalid_message_data": "Dati messaggio non validi.", "messages.invalid_message_data": "Dati messaggio non validi.",
"messages.invalid_scheduled_message_payload": "Payload del messaggio programmato non valido.",
"messages.message_ids_cannot_be_empty": "`message_ids` non deve essere vuoto.", "messages.message_ids_cannot_be_empty": "`message_ids` non deve essere vuoto.",
"messages.messages_array_required": "L'array `messages` è obbligatorio e non deve essere vuoto.", "messages.messages_array_required": "L'array `messages` è obbligatorio e non deve essere vuoto.",
"messages.messages_with_snapshots_cannot_be_edited": "I messaggi con istantanee non possono essere modificati.", "messages.messages_with_snapshots_cannot_be_edited": "I messaggi con istantanee non possono essere modificati.",
"messages.scheduled_messages_max_30_days": "I messaggi programmati possono essere programmati per un massimo di 30 giorni nel futuro.",
"messages.scheduled_send_datetime_invalid": "Data o ora non valide per l'invio programmato.",
"messages.scheduled_time_must_be_future": "L'ora programmata deve essere nel futuro.",
"messages.voice_messages_attachment_must_be_audio": "Gli allegati dei messaggi vocali devono essere file audio.", "messages.voice_messages_attachment_must_be_audio": "Gli allegati dei messaggi vocali devono essere file audio.",
"messages.voice_messages_cannot_have_content": "I messaggi vocali non possono contenere testo.", "messages.voice_messages_cannot_have_content": "I messaggi vocali non possono contenere testo.",
"messages.voice_messages_cannot_have_embeds": "I messaggi vocali non possono contenere embed.", "messages.voice_messages_cannot_have_embeds": "I messaggi vocali non possono contenere embed.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_JA_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "転送メッセージにはコンテンツ、埋め込み、添付ファイル、スタンプを含められません。", "messages.forward_messages_cannot_include_extra_content": "転送メッセージにはコンテンツ、埋め込み、添付ファイル、スタンプを含められません。",
"messages.forward_reference_requires_channel_and_message": "転送メッセージの参照には`channel_id`と`message_id`を含める必要があります。", "messages.forward_reference_requires_channel_and_message": "転送メッセージの参照には`channel_id`と`message_id`を含める必要があります。",
"messages.invalid_message_data": "メッセージデータが無効です。", "messages.invalid_message_data": "メッセージデータが無効です。",
"messages.invalid_scheduled_message_payload": "スケジュール済みメッセージペイロードが無効です。",
"messages.message_ids_cannot_be_empty": "`message_ids`は空にできません。", "messages.message_ids_cannot_be_empty": "`message_ids`は空にできません。",
"messages.messages_array_required": "`messages`配列は必須であり、空にできません。", "messages.messages_array_required": "`messages`配列は必須であり、空にできません。",
"messages.messages_with_snapshots_cannot_be_edited": "スナップショット付きメッセージは編集できません。", "messages.messages_with_snapshots_cannot_be_edited": "スナップショット付きメッセージは編集できません。",
"messages.scheduled_messages_max_30_days": "予約メッセージは30日後まで設定できます。",
"messages.scheduled_send_datetime_invalid": "予約送信の日時が無効です。",
"messages.scheduled_time_must_be_future": "予約時間は未来の日時を指定してください。",
"messages.voice_messages_attachment_must_be_audio": "音声メッセージの添付ファイルは音声ファイルのみです。", "messages.voice_messages_attachment_must_be_audio": "音声メッセージの添付ファイルは音声ファイルのみです。",
"messages.voice_messages_cannot_have_content": "音声メッセージにテキストは含められません。", "messages.voice_messages_cannot_have_content": "音声メッセージにテキストは含められません。",
"messages.voice_messages_cannot_have_embeds": "音声メッセージに埋め込みは含められません。", "messages.voice_messages_cannot_have_embeds": "音声メッセージに埋め込みは含められません。",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_KO_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "전달된 메시지에는 콘텐츠, 임베드, 첨부 파일 또는 스티커를 포함할 수 없어요.", "messages.forward_messages_cannot_include_extra_content": "전달된 메시지에는 콘텐츠, 임베드, 첨부 파일 또는 스티커를 포함할 수 없어요.",
"messages.forward_reference_requires_channel_and_message": "메시지 전달 참조에는 `channel_id`와 `message_id`가 포함되어야 해요.", "messages.forward_reference_requires_channel_and_message": "메시지 전달 참조에는 `channel_id`와 `message_id`가 포함되어야 해요.",
"messages.invalid_message_data": "유효하지 않은 메시지 데이터예요.", "messages.invalid_message_data": "유효하지 않은 메시지 데이터예요.",
"messages.invalid_scheduled_message_payload": "유효하지 않은 예약 메시지 페이로드예요.",
"messages.message_ids_cannot_be_empty": "`message_ids`는 비워둘 수 없어요.", "messages.message_ids_cannot_be_empty": "`message_ids`는 비워둘 수 없어요.",
"messages.messages_array_required": "`messages` 배열은 필수 항목이며 비워둘 수 없어요.", "messages.messages_array_required": "`messages` 배열은 필수 항목이며 비워둘 수 없어요.",
"messages.messages_with_snapshots_cannot_be_edited": "스냅샷이 있는 메시지는 수정할 수 없어요.", "messages.messages_with_snapshots_cannot_be_edited": "스냅샷이 있는 메시지는 수정할 수 없어요.",
"messages.scheduled_messages_max_30_days": "예약 메시지는 최대 30일 이후로 설정할 수 있어요.",
"messages.scheduled_send_datetime_invalid": "예약 전송 날짜 또는 시간이 유효하지 않아요.",
"messages.scheduled_time_must_be_future": "예약 시간은 미래여야 해요.",
"messages.voice_messages_attachment_must_be_audio": "음성 메시지 첨부 파일은 오디오 파일이어야 해요.", "messages.voice_messages_attachment_must_be_audio": "음성 메시지 첨부 파일은 오디오 파일이어야 해요.",
"messages.voice_messages_cannot_have_content": "음성 메시지에는 텍스트 콘텐츠를 포함할 수 없어요.", "messages.voice_messages_cannot_have_content": "음성 메시지에는 텍스트 콘텐츠를 포함할 수 없어요.",
"messages.voice_messages_cannot_have_embeds": "음성 메시지에는 임베드를 포함할 수 없어요.", "messages.voice_messages_cannot_have_embeds": "음성 메시지에는 임베드를 포함할 수 없어요.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_LT_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Persiųstose žinutėse negali būti turinio, įterptinių elementų, priedų ar lipdukų.", "messages.forward_messages_cannot_include_extra_content": "Persiųstose žinutėse negali būti turinio, įterptinių elementų, priedų ar lipdukų.",
"messages.forward_reference_requires_channel_and_message": "Persiųstos žinutės nuoroda turi apimti `channel_id` ir `message_id`.", "messages.forward_reference_requires_channel_and_message": "Persiųstos žinutės nuoroda turi apimti `channel_id` ir `message_id`.",
"messages.invalid_message_data": "Neteisingi žinutės duomenys.", "messages.invalid_message_data": "Neteisingi žinutės duomenys.",
"messages.invalid_scheduled_message_payload": "Neteisingas suplanuotos žinutės turinys.",
"messages.message_ids_cannot_be_empty": "`message_ids` negali būti tuščias.", "messages.message_ids_cannot_be_empty": "`message_ids` negali būti tuščias.",
"messages.messages_array_required": "Masyvas `messages` yra privalomas ir negali būti tuščias.", "messages.messages_array_required": "Masyvas `messages` yra privalomas ir negali būti tuščias.",
"messages.messages_with_snapshots_cannot_be_edited": "Žinučių su momentinėmis nuotraukomis negalima redaguoti.", "messages.messages_with_snapshots_cannot_be_edited": "Žinučių su momentinėmis nuotraukomis negalima redaguoti.",
"messages.scheduled_messages_max_30_days": "Suplanuotos žinutės gali būti daugiausia 30 dienų į priekį.",
"messages.scheduled_send_datetime_invalid": "Neteisinga data arba laikas suplanuotam siuntimui.",
"messages.scheduled_time_must_be_future": "Suplanuotas laikas turi būti ateityje.",
"messages.voice_messages_attachment_must_be_audio": "Balso žinučių priedai turi būti garso failai.", "messages.voice_messages_attachment_must_be_audio": "Balso žinučių priedai turi būti garso failai.",
"messages.voice_messages_cannot_have_content": "Balso žinutės negali turėti tekstinio turinio.", "messages.voice_messages_cannot_have_content": "Balso žinutės negali turėti tekstinio turinio.",
"messages.voice_messages_cannot_have_embeds": "Balso žinutės negali turėti įterptinių elementų.", "messages.voice_messages_cannot_have_embeds": "Balso žinutės negali turėti įterptinių elementų.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_NL_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Doorgestuurde berichten kunnen geen inhoud, embeds, bijlagen of stickers bevatten.", "messages.forward_messages_cannot_include_extra_content": "Doorgestuurde berichten kunnen geen inhoud, embeds, bijlagen of stickers bevatten.",
"messages.forward_reference_requires_channel_and_message": "De referentie van een doorgestuurd bericht moet `channel_id` en `message_id` bevatten.", "messages.forward_reference_requires_channel_and_message": "De referentie van een doorgestuurd bericht moet `channel_id` en `message_id` bevatten.",
"messages.invalid_message_data": "Ongeldige berichtgegevens.", "messages.invalid_message_data": "Ongeldige berichtgegevens.",
"messages.invalid_scheduled_message_payload": "Ongeldige payload voor gepland bericht.",
"messages.message_ids_cannot_be_empty": "`message_ids` mag niet leeg zijn.", "messages.message_ids_cannot_be_empty": "`message_ids` mag niet leeg zijn.",
"messages.messages_array_required": "De `messages`-array is vereist en mag niet leeg zijn.", "messages.messages_array_required": "De `messages`-array is vereist en mag niet leeg zijn.",
"messages.messages_with_snapshots_cannot_be_edited": "Berichten met snapshots kunnen niet worden bewerkt.", "messages.messages_with_snapshots_cannot_be_edited": "Berichten met snapshots kunnen niet worden bewerkt.",
"messages.scheduled_messages_max_30_days": "Geplande berichten kunnen maximaal 30 dagen in de toekomst zijn.",
"messages.scheduled_send_datetime_invalid": "Ongeldige datum of tijd voor gepland verzenden.",
"messages.scheduled_time_must_be_future": "De geplande tijd moet in de toekomst zijn.",
"messages.voice_messages_attachment_must_be_audio": "Bijlagen bij spraakberichten moeten audiobestanden zijn.", "messages.voice_messages_attachment_must_be_audio": "Bijlagen bij spraakberichten moeten audiobestanden zijn.",
"messages.voice_messages_cannot_have_content": "Spraakberichten kunnen geen tekstinhoud bevatten.", "messages.voice_messages_cannot_have_content": "Spraakberichten kunnen geen tekstinhoud bevatten.",
"messages.voice_messages_cannot_have_embeds": "Spraakberichten kunnen geen embeds bevatten.", "messages.voice_messages_cannot_have_embeds": "Spraakberichten kunnen geen embeds bevatten.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_NO_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Videresendte meldinger kan ikke inneholde innhold, innebygde elementer, vedlegg eller stickere.", "messages.forward_messages_cannot_include_extra_content": "Videresendte meldinger kan ikke inneholde innhold, innebygde elementer, vedlegg eller stickere.",
"messages.forward_reference_requires_channel_and_message": "Videresendt meldingsreferanse må inkludere `channel_id` og `message_id`.", "messages.forward_reference_requires_channel_and_message": "Videresendt meldingsreferanse må inkludere `channel_id` og `message_id`.",
"messages.invalid_message_data": "Ugyldig meldingsdata.", "messages.invalid_message_data": "Ugyldig meldingsdata.",
"messages.invalid_scheduled_message_payload": "Ugyldig nyttelast for planlagt melding.",
"messages.message_ids_cannot_be_empty": "`message_ids` kan ikke være tomme.", "messages.message_ids_cannot_be_empty": "`message_ids` kan ikke være tomme.",
"messages.messages_array_required": "Matrisen `messages` er påkrevd og kan ikke være tom.", "messages.messages_array_required": "Matrisen `messages` er påkrevd og kan ikke være tom.",
"messages.messages_with_snapshots_cannot_be_edited": "Meldinger med skjermbilder kan ikke redigeres.", "messages.messages_with_snapshots_cannot_be_edited": "Meldinger med skjermbilder kan ikke redigeres.",
"messages.scheduled_messages_max_30_days": "Planlagte meldinger kan være maksimalt 30 dager frem i tid.",
"messages.scheduled_send_datetime_invalid": "Ugyldig dato eller tid for planlagt sending.",
"messages.scheduled_time_must_be_future": "Planlagt tid må være i fremtiden.",
"messages.voice_messages_attachment_must_be_audio": "Vedlegg til talemeldinger må være lydfiler.", "messages.voice_messages_attachment_must_be_audio": "Vedlegg til talemeldinger må være lydfiler.",
"messages.voice_messages_cannot_have_content": "Talemeldinger kan ikke ha tekstinnhold.", "messages.voice_messages_cannot_have_content": "Talemeldinger kan ikke ha tekstinnhold.",
"messages.voice_messages_cannot_have_embeds": "Talemeldinger kan ikke ha innebygde elementer.", "messages.voice_messages_cannot_have_embeds": "Talemeldinger kan ikke ha innebygde elementer.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_PL_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Przekazane wiadomości nie mogą zawierać treści, osadzonych elementów, załączników ani naklejek.", "messages.forward_messages_cannot_include_extra_content": "Przekazane wiadomości nie mogą zawierać treści, osadzonych elementów, załączników ani naklejek.",
"messages.forward_reference_requires_channel_and_message": "Odwołanie do przekazanej wiadomości musi zawierać `channel_id` i `message_id`.", "messages.forward_reference_requires_channel_and_message": "Odwołanie do przekazanej wiadomości musi zawierać `channel_id` i `message_id`.",
"messages.invalid_message_data": "Nieprawidłowe dane wiadomości.", "messages.invalid_message_data": "Nieprawidłowe dane wiadomości.",
"messages.invalid_scheduled_message_payload": "Nieprawidłowy ładunek zaplanowanej wiadomości.",
"messages.message_ids_cannot_be_empty": "`message_ids` nie może być puste.", "messages.message_ids_cannot_be_empty": "`message_ids` nie może być puste.",
"messages.messages_array_required": "Tablica `messages` jest wymagana i nie może być pusta.", "messages.messages_array_required": "Tablica `messages` jest wymagana i nie może być pusta.",
"messages.messages_with_snapshots_cannot_be_edited": "Wiadomości z migawkami nie mogą być edytowane.", "messages.messages_with_snapshots_cannot_be_edited": "Wiadomości z migawkami nie mogą być edytowane.",
"messages.scheduled_messages_max_30_days": "Zaplanowane wiadomości mogą być wysłane na maksymalnie 30 dni do przodu.",
"messages.scheduled_send_datetime_invalid": "Nieprawidłowa data lub godzina zaplanowanego wysłania.",
"messages.scheduled_time_must_be_future": "Zaplanowany czas musi dotyczyć przyszłości.",
"messages.voice_messages_attachment_must_be_audio": "Załączniki wiadomości głosowych muszą być plikami audio.", "messages.voice_messages_attachment_must_be_audio": "Załączniki wiadomości głosowych muszą być plikami audio.",
"messages.voice_messages_cannot_have_content": "Wiadomości głosowe nie mogą zawierać treści tekstowych.", "messages.voice_messages_cannot_have_content": "Wiadomości głosowe nie mogą zawierać treści tekstowych.",
"messages.voice_messages_cannot_have_embeds": "Wiadomości głosowe nie mogą zawierać osadzonych elementów.", "messages.voice_messages_cannot_have_embeds": "Wiadomości głosowe nie mogą zawierać osadzonych elementów.",
@@ -375,13 +375,9 @@ const ERROR_I18N_PT_BR_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Mensagens encaminhadas não podem incluir conteúdo, elementos incorporados, anexos ou figurinhas.", "messages.forward_messages_cannot_include_extra_content": "Mensagens encaminhadas não podem incluir conteúdo, elementos incorporados, anexos ou figurinhas.",
"messages.forward_reference_requires_channel_and_message": "A referência de encaminhamento de mensagem precisa incluir `channel_id` e `message_id`.", "messages.forward_reference_requires_channel_and_message": "A referência de encaminhamento de mensagem precisa incluir `channel_id` e `message_id`.",
"messages.invalid_message_data": "Dados da mensagem inválidos.", "messages.invalid_message_data": "Dados da mensagem inválidos.",
"messages.invalid_scheduled_message_payload": "Payload inválido da mensagem agendada.",
"messages.message_ids_cannot_be_empty": "`message_ids` não pode ser vazio.", "messages.message_ids_cannot_be_empty": "`message_ids` não pode ser vazio.",
"messages.messages_array_required": "O array `messages` é necessário e não pode ser vazio.", "messages.messages_array_required": "O array `messages` é necessário e não pode ser vazio.",
"messages.messages_with_snapshots_cannot_be_edited": "Mensagens com snapshots não podem ser editadas.", "messages.messages_with_snapshots_cannot_be_edited": "Mensagens com snapshots não podem ser editadas.",
"messages.scheduled_messages_max_30_days": "Mensagens agendadas podem ser para no máximo 30 dias no futuro.",
"messages.scheduled_send_datetime_invalid": "Data ou hora inválida para o envio agendado.",
"messages.scheduled_time_must_be_future": "O horário agendado deve ser no futuro.",
"messages.voice_messages_attachment_must_be_audio": "Anexos de mensagens de voz precisam ser arquivos de áudio.", "messages.voice_messages_attachment_must_be_audio": "Anexos de mensagens de voz precisam ser arquivos de áudio.",
"messages.voice_messages_cannot_have_content": "Mensagens de voz não podem conter texto.", "messages.voice_messages_cannot_have_content": "Mensagens de voz não podem conter texto.",
"messages.voice_messages_cannot_have_embeds": "Mensagens de voz não podem ter embeds.", "messages.voice_messages_cannot_have_embeds": "Mensagens de voz não podem ter embeds.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_RO_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Mesajele redirecționate nu pot include conținut, încorporări, atașamente sau stickere.", "messages.forward_messages_cannot_include_extra_content": "Mesajele redirecționate nu pot include conținut, încorporări, atașamente sau stickere.",
"messages.forward_reference_requires_channel_and_message": "Referința mesajului redirecționat trebuie să includă `channel_id` și `message_id`.", "messages.forward_reference_requires_channel_and_message": "Referința mesajului redirecționat trebuie să includă `channel_id` și `message_id`.",
"messages.invalid_message_data": "Date mesaj invalide.", "messages.invalid_message_data": "Date mesaj invalide.",
"messages.invalid_scheduled_message_payload": "Sarcină utilă mesaj programat invalidă.",
"messages.message_ids_cannot_be_empty": "`message_ids` nu poate fi gol.", "messages.message_ids_cannot_be_empty": "`message_ids` nu poate fi gol.",
"messages.messages_array_required": "Câmpul `messages` este obligatoriu și nu trebuie să fie gol.", "messages.messages_array_required": "Câmpul `messages` este obligatoriu și nu trebuie să fie gol.",
"messages.messages_with_snapshots_cannot_be_edited": "Mesajele cu instantanee nu pot fi editate.", "messages.messages_with_snapshots_cannot_be_edited": "Mesajele cu instantanee nu pot fi editate.",
"messages.scheduled_messages_max_30_days": "Mesajele programate pot fi programate pentru cel mult 30 de zile în avans.",
"messages.scheduled_send_datetime_invalid": "Data sau ora sunt invalide pentru programarea trimiterii.",
"messages.scheduled_time_must_be_future": "Ora programată trebuie să fie o oră viitoare.",
"messages.voice_messages_attachment_must_be_audio": "Atașamentele mesajelor vocale trebuie să fie fișiere audio.", "messages.voice_messages_attachment_must_be_audio": "Atașamentele mesajelor vocale trebuie să fie fișiere audio.",
"messages.voice_messages_cannot_have_content": "Mesajele vocale nu pot avea conținut text.", "messages.voice_messages_cannot_have_content": "Mesajele vocale nu pot avea conținut text.",
"messages.voice_messages_cannot_have_embeds": "Mesajele vocale nu pot avea embed-uri.", "messages.voice_messages_cannot_have_embeds": "Mesajele vocale nu pot avea embed-uri.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_RU_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Пересланные сообщения не могут содержать контент, встраиваемые элементы, вложения или стикеры.", "messages.forward_messages_cannot_include_extra_content": "Пересланные сообщения не могут содержать контент, встраиваемые элементы, вложения или стикеры.",
"messages.forward_reference_requires_channel_and_message": "Ссылка на пересылаемое сообщение должна включать `channel_id` и `message_id`.", "messages.forward_reference_requires_channel_and_message": "Ссылка на пересылаемое сообщение должна включать `channel_id` и `message_id`.",
"messages.invalid_message_data": "Неверные данные сообщения.", "messages.invalid_message_data": "Неверные данные сообщения.",
"messages.invalid_scheduled_message_payload": "Недействительная нагрузка запланированного сообщения.",
"messages.message_ids_cannot_be_empty": "`message_ids` не может быть пустым.", "messages.message_ids_cannot_be_empty": "`message_ids` не может быть пустым.",
"messages.messages_array_required": "Массив `messages` обязателен и не должен быть пустым.", "messages.messages_array_required": "Массив `messages` обязателен и не должен быть пустым.",
"messages.messages_with_snapshots_cannot_be_edited": "Сообщения со снимками нельзя редактировать.", "messages.messages_with_snapshots_cannot_be_edited": "Сообщения со снимками нельзя редактировать.",
"messages.scheduled_messages_max_30_days": "Запланированные сообщения можно отправить не более чем через 30 дней.",
"messages.scheduled_send_datetime_invalid": "Неверная дата или время для запланированной отправки.",
"messages.scheduled_time_must_be_future": "Время отправки должно быть в будущем.",
"messages.voice_messages_attachment_must_be_audio": "Вложения голосовых сообщений должны быть аудиофайлами.", "messages.voice_messages_attachment_must_be_audio": "Вложения голосовых сообщений должны быть аудиофайлами.",
"messages.voice_messages_cannot_have_content": "Голосовые сообщения не могут содержать текст.", "messages.voice_messages_cannot_have_content": "Голосовые сообщения не могут содержать текст.",
"messages.voice_messages_cannot_have_embeds": "Голосовые сообщения не могут содержать встроенный контент.", "messages.voice_messages_cannot_have_embeds": "Голосовые сообщения не могут содержать встроенный контент.",
@@ -375,13 +375,9 @@ const ERROR_I18N_SV_SE_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Vidarebefordrade meddelanden kan inte innehålla innehåll, inbäddningar, bilagor eller stickers.", "messages.forward_messages_cannot_include_extra_content": "Vidarebefordrade meddelanden kan inte innehålla innehåll, inbäddningar, bilagor eller stickers.",
"messages.forward_reference_requires_channel_and_message": "Vidarebefordrad meddelandereferens måste inkludera `channel_id` och `message_id`.", "messages.forward_reference_requires_channel_and_message": "Vidarebefordrad meddelandereferens måste inkludera `channel_id` och `message_id`.",
"messages.invalid_message_data": "Ogiltig meddelandedata.", "messages.invalid_message_data": "Ogiltig meddelandedata.",
"messages.invalid_scheduled_message_payload": "Ogiltig nyttolast för schemalagt meddelande.",
"messages.message_ids_cannot_be_empty": "`message_ids` kan inte vara tomma.", "messages.message_ids_cannot_be_empty": "`message_ids` kan inte vara tomma.",
"messages.messages_array_required": "`messages`-arrayen krävs och får inte vara tom.", "messages.messages_array_required": "`messages`-arrayen krävs och får inte vara tom.",
"messages.messages_with_snapshots_cannot_be_edited": "Meddelanden med ögonblicksbilder kan inte redigeras.", "messages.messages_with_snapshots_cannot_be_edited": "Meddelanden med ögonblicksbilder kan inte redigeras.",
"messages.scheduled_messages_max_30_days": "Schemalagda meddelanden kan vara högst 30 dagar framåt.",
"messages.scheduled_send_datetime_invalid": "Ogiltigt datum eller tid för schemalagd sändning.",
"messages.scheduled_time_must_be_future": "Schemalagd tid måste vara framåt i tiden.",
"messages.voice_messages_attachment_must_be_audio": "Röstmeddelandebilagor måste vara ljudfiler.", "messages.voice_messages_attachment_must_be_audio": "Röstmeddelandebilagor måste vara ljudfiler.",
"messages.voice_messages_cannot_have_content": "Röstmeddelanden kan inte ha textinnehåll.", "messages.voice_messages_cannot_have_content": "Röstmeddelanden kan inte ha textinnehåll.",
"messages.voice_messages_cannot_have_embeds": "Röstmeddelanden kan inte ha inbäddningar.", "messages.voice_messages_cannot_have_embeds": "Röstmeddelanden kan inte ha inbäddningar.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_TH_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "ข้อความที่ส่งต่อไม่สามารถมีเนื้อหา, การฝัง, ไฟล์แนบ หรือสติกเกอร์ได้", "messages.forward_messages_cannot_include_extra_content": "ข้อความที่ส่งต่อไม่สามารถมีเนื้อหา, การฝัง, ไฟล์แนบ หรือสติกเกอร์ได้",
"messages.forward_reference_requires_channel_and_message": "การอ้างอิงข้อความที่ส่งต่อต้องมี `channel_id` และ `message_id`", "messages.forward_reference_requires_channel_and_message": "การอ้างอิงข้อความที่ส่งต่อต้องมี `channel_id` และ `message_id`",
"messages.invalid_message_data": "ข้อมูลข้อความไม่ถูกต้อง", "messages.invalid_message_data": "ข้อมูลข้อความไม่ถูกต้อง",
"messages.invalid_scheduled_message_payload": "เพย์โหลดข้อความที่กำหนดเวลาไม่ถูกต้อง",
"messages.message_ids_cannot_be_empty": "`message_ids` ต้องไม่ว่างเปล่า", "messages.message_ids_cannot_be_empty": "`message_ids` ต้องไม่ว่างเปล่า",
"messages.messages_array_required": "ต้องระบุอาร์เรย์ `messages` และต้องไม่ว่างเปล่า", "messages.messages_array_required": "ต้องระบุอาร์เรย์ `messages` และต้องไม่ว่างเปล่า",
"messages.messages_with_snapshots_cannot_be_edited": "ข้อความที่มีสแนปช็อตไม่สามารถแก้ไขได้", "messages.messages_with_snapshots_cannot_be_edited": "ข้อความที่มีสแนปช็อตไม่สามารถแก้ไขได้",
"messages.scheduled_messages_max_30_days": "ข้อความที่กำหนดเวลาสามารถตั้งล่วงหน้าได้สูงสุด 30 วัน",
"messages.scheduled_send_datetime_invalid": "วันที่หรือเวลาสำหรับการส่งตามกำหนดเวลาไม่ถูกต้อง",
"messages.scheduled_time_must_be_future": "เวลาที่กำหนดต้องเป็นเวลาในอนาคต",
"messages.voice_messages_attachment_must_be_audio": "ไฟล์แนบข้อความเสียงต้องเป็นไฟล์เสียง", "messages.voice_messages_attachment_must_be_audio": "ไฟล์แนบข้อความเสียงต้องเป็นไฟล์เสียง",
"messages.voice_messages_cannot_have_content": "ข้อความเสียงไม่สามารถมีเนื้อหาข้อความได้", "messages.voice_messages_cannot_have_content": "ข้อความเสียงไม่สามารถมีเนื้อหาข้อความได้",
"messages.voice_messages_cannot_have_embeds": "ข้อความเสียงไม่สามารถมีการฝังได้", "messages.voice_messages_cannot_have_embeds": "ข้อความเสียงไม่สามารถมีการฝังได้",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_TR_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "İletilen mesajlar içerik, gömülü içerik, ekler veya çıkartma içeremez.", "messages.forward_messages_cannot_include_extra_content": "İletilen mesajlar içerik, gömülü içerik, ekler veya çıkartma içeremez.",
"messages.forward_reference_requires_channel_and_message": "İletilen mesaj referansı `channel_id` ve `message_id` içermelidir.", "messages.forward_reference_requires_channel_and_message": "İletilen mesaj referansı `channel_id` ve `message_id` içermelidir.",
"messages.invalid_message_data": "Geçersiz mesaj verisi.", "messages.invalid_message_data": "Geçersiz mesaj verisi.",
"messages.invalid_scheduled_message_payload": "Geçersiz zamanlanmış mesaj yükü.",
"messages.message_ids_cannot_be_empty": "`message_ids` boş olamaz.", "messages.message_ids_cannot_be_empty": "`message_ids` boş olamaz.",
"messages.messages_array_required": "`messages` dizisi gereklidir ve boş olamaz.", "messages.messages_array_required": "`messages` dizisi gereklidir ve boş olamaz.",
"messages.messages_with_snapshots_cannot_be_edited": "Anlık görüntülü mesajlar düzenlenemez.", "messages.messages_with_snapshots_cannot_be_edited": "Anlık görüntülü mesajlar düzenlenemez.",
"messages.scheduled_messages_max_30_days": "Zamanlanmış mesajlar en fazla 30 gün ileriye ayarlanabilir.",
"messages.scheduled_send_datetime_invalid": "Zamanlanmış gönderim için geçersiz tarih veya saat.",
"messages.scheduled_time_must_be_future": "Zamanlanmış gönderim gelecekte bir zaman olmalı.",
"messages.voice_messages_attachment_must_be_audio": "Sesli mesaj ekleri ses dosyası türünde olmalı.", "messages.voice_messages_attachment_must_be_audio": "Sesli mesaj ekleri ses dosyası türünde olmalı.",
"messages.voice_messages_cannot_have_content": "Sesli mesajlar metin içeriği içeremez.", "messages.voice_messages_cannot_have_content": "Sesli mesajlar metin içeriği içeremez.",
"messages.voice_messages_cannot_have_embeds": "Sesli mesajlar yerleştirme içeremez.", "messages.voice_messages_cannot_have_embeds": "Sesli mesajlar yerleştirme içeremez.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_UK_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Переслані повідомлення не можуть містити вміст, вбудовані елементи, вкладення або стікери.", "messages.forward_messages_cannot_include_extra_content": "Переслані повідомлення не можуть містити вміст, вбудовані елементи, вкладення або стікери.",
"messages.forward_reference_requires_channel_and_message": "Посилання на переслане повідомлення має містити `channel_id` та `message_id`.", "messages.forward_reference_requires_channel_and_message": "Посилання на переслане повідомлення має містити `channel_id` та `message_id`.",
"messages.invalid_message_data": "Недійсні дані повідомлення.", "messages.invalid_message_data": "Недійсні дані повідомлення.",
"messages.invalid_scheduled_message_payload": "Недійсний вміст запланованого повідомлення.",
"messages.message_ids_cannot_be_empty": "`message_ids` не можуть бути порожніми.", "messages.message_ids_cannot_be_empty": "`message_ids` не можуть бути порожніми.",
"messages.messages_array_required": "Масив `messages` обов'язковий і не може бути порожнім.", "messages.messages_array_required": "Масив `messages` обов'язковий і не може бути порожнім.",
"messages.messages_with_snapshots_cannot_be_edited": "Повідомлення зі знімками не можна редагувати.", "messages.messages_with_snapshots_cannot_be_edited": "Повідомлення зі знімками не можна редагувати.",
"messages.scheduled_messages_max_30_days": "Заплановані повідомлення можуть бути щонайбільше на 30 днів уперед.",
"messages.scheduled_send_datetime_invalid": "Неправильна дата або час для запланованого надсилання.",
"messages.scheduled_time_must_be_future": "Запланований час має бути в майбутньому.",
"messages.voice_messages_attachment_must_be_audio": "Вкладення голосових повідомлень повинні бути аудіофайлами.", "messages.voice_messages_attachment_must_be_audio": "Вкладення голосових повідомлень повинні бути аудіофайлами.",
"messages.voice_messages_cannot_have_content": "Голосові повідомлення не можуть містити текст.", "messages.voice_messages_cannot_have_content": "Голосові повідомлення не можуть містити текст.",
"messages.voice_messages_cannot_have_embeds": "Голосові повідомлення не можуть містити вбудовані елементи.", "messages.voice_messages_cannot_have_embeds": "Голосові повідомлення не можуть містити вбудовані елементи.",
-4
View File
@@ -375,13 +375,9 @@ const ERROR_I18N_VI_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "Tin nhắn đã chuyển tiếp không được chứa nội dung, nhúng, tệp đính kèm hoặc sticker.", "messages.forward_messages_cannot_include_extra_content": "Tin nhắn đã chuyển tiếp không được chứa nội dung, nhúng, tệp đính kèm hoặc sticker.",
"messages.forward_reference_requires_channel_and_message": "Tham chiếu tin nhắn chuyển tiếp phải bao gồm `channel_id` và `message_id`.", "messages.forward_reference_requires_channel_and_message": "Tham chiếu tin nhắn chuyển tiếp phải bao gồm `channel_id` và `message_id`.",
"messages.invalid_message_data": "Dữ liệu tin nhắn không hợp lệ.", "messages.invalid_message_data": "Dữ liệu tin nhắn không hợp lệ.",
"messages.invalid_scheduled_message_payload": "Payload của tin nhắn hẹn giờ không hợp lệ.",
"messages.message_ids_cannot_be_empty": "`message_ids` không được trống.", "messages.message_ids_cannot_be_empty": "`message_ids` không được trống.",
"messages.messages_array_required": "Mảng `messages` là bắt buộc và không được trống.", "messages.messages_array_required": "Mảng `messages` là bắt buộc và không được trống.",
"messages.messages_with_snapshots_cannot_be_edited": "Tin nhắn có snapshot không thể chỉnh sửa.", "messages.messages_with_snapshots_cannot_be_edited": "Tin nhắn có snapshot không thể chỉnh sửa.",
"messages.scheduled_messages_max_30_days": "Tin nhắn đã lên lịch có thể tối đa 30 ngày tới.",
"messages.scheduled_send_datetime_invalid": "Ngày hoặc giờ không hợp lệ để gửi theo lịch.",
"messages.scheduled_time_must_be_future": "Thời gian đã lên lịch phải ở tương lai.",
"messages.voice_messages_attachment_must_be_audio": "Tệp đính kèm tin nhắn thoại phải là tệp âm thanh.", "messages.voice_messages_attachment_must_be_audio": "Tệp đính kèm tin nhắn thoại phải là tệp âm thanh.",
"messages.voice_messages_cannot_have_content": "Tin nhắn thoại không thể có nội dung văn bản.", "messages.voice_messages_cannot_have_content": "Tin nhắn thoại không thể có nội dung văn bản.",
"messages.voice_messages_cannot_have_embeds": "Tin nhắn thoại không thể có nhúng.", "messages.voice_messages_cannot_have_embeds": "Tin nhắn thoại không thể có nhúng.",
@@ -375,13 +375,9 @@ const ERROR_I18N_ZH_CN_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "转发消息不能包含内容、嵌入内容、附件或贴纸。", "messages.forward_messages_cannot_include_extra_content": "转发消息不能包含内容、嵌入内容、附件或贴纸。",
"messages.forward_reference_requires_channel_and_message": "转发消息引用必须包含 `channel_id` 和 `message_id`。", "messages.forward_reference_requires_channel_and_message": "转发消息引用必须包含 `channel_id` 和 `message_id`。",
"messages.invalid_message_data": "消息数据无效。", "messages.invalid_message_data": "消息数据无效。",
"messages.invalid_scheduled_message_payload": "无效的定时消息负载。",
"messages.message_ids_cannot_be_empty": "`message_ids` 不能为空。", "messages.message_ids_cannot_be_empty": "`message_ids` 不能为空。",
"messages.messages_array_required": "`messages` 数组是必需的,且不能为空。", "messages.messages_array_required": "`messages` 数组是必需的,且不能为空。",
"messages.messages_with_snapshots_cannot_be_edited": "带有快照的消息无法编辑。", "messages.messages_with_snapshots_cannot_be_edited": "带有快照的消息无法编辑。",
"messages.scheduled_messages_max_30_days": "定时消息最多只能安排在未来 30 天内。",
"messages.scheduled_send_datetime_invalid": "定时发送的日期或时间不正确。",
"messages.scheduled_time_must_be_future": "定时时间必须在未来。",
"messages.voice_messages_attachment_must_be_audio": "语音消息附件必须是音频文件。", "messages.voice_messages_attachment_must_be_audio": "语音消息附件必须是音频文件。",
"messages.voice_messages_cannot_have_content": "语音消息不能包含文本内容。", "messages.voice_messages_cannot_have_content": "语音消息不能包含文本内容。",
"messages.voice_messages_cannot_have_embeds": "语音消息不能包含嵌入内容。", "messages.voice_messages_cannot_have_embeds": "语音消息不能包含嵌入内容。",
@@ -375,13 +375,9 @@ const ERROR_I18N_ZH_TW_MESSAGES = defineErrorI18nLocaleMessages({
"messages.forward_messages_cannot_include_extra_content": "轉寄的訊息不能包含內容、嵌入、附件或貼圖。", "messages.forward_messages_cannot_include_extra_content": "轉寄的訊息不能包含內容、嵌入、附件或貼圖。",
"messages.forward_reference_requires_channel_and_message": "轉寄訊息參考必須包含 `channel_id` 和 `message_id`。", "messages.forward_reference_requires_channel_and_message": "轉寄訊息參考必須包含 `channel_id` 和 `message_id`。",
"messages.invalid_message_data": "無效的訊息資料。", "messages.invalid_message_data": "無效的訊息資料。",
"messages.invalid_scheduled_message_payload": "無效的排程訊息內容。",
"messages.message_ids_cannot_be_empty": "`message_ids` 不能為空。", "messages.message_ids_cannot_be_empty": "`message_ids` 不能為空。",
"messages.messages_array_required": "`messages` 陣列為必填,且不能為空。", "messages.messages_array_required": "`messages` 陣列為必填,且不能為空。",
"messages.messages_with_snapshots_cannot_be_edited": "包含快照的訊息無法編輯。", "messages.messages_with_snapshots_cannot_be_edited": "包含快照的訊息無法編輯。",
"messages.scheduled_messages_max_30_days": "排程訊息最多只能排程到未來 30 天。",
"messages.scheduled_send_datetime_invalid": "排程傳送的日期或時間無效。",
"messages.scheduled_time_must_be_future": "排程時間必須在未來。",
"messages.voice_messages_attachment_must_be_audio": "語音訊息附件必須是音訊檔案。", "messages.voice_messages_attachment_must_be_audio": "語音訊息附件必須是音訊檔案。",
"messages.voice_messages_cannot_have_content": "語音訊息不能包含文字內容。", "messages.voice_messages_cannot_have_content": "語音訊息不能包含文字內容。",
"messages.voice_messages_cannot_have_embeds": "語音訊息不能包含嵌入內容。", "messages.voice_messages_cannot_have_embeds": "語音訊息不能包含嵌入內容。",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "لا يمكن أن تحتوي الرسائل المعاد توجيهها على محتوى، تضمينات، مرفقات، أو ملصقات.", "messages.forward_messages_cannot_include_extra_content": "لا يمكن أن تحتوي الرسائل المعاد توجيهها على محتوى، تضمينات، مرفقات، أو ملصقات.",
"messages.forward_reference_requires_channel_and_message": "يجب أن يتضمن مرجع الرسالة المعاد توجيهها كلاً من `channel_id` و `message_id`.", "messages.forward_reference_requires_channel_and_message": "يجب أن يتضمن مرجع الرسالة المعاد توجيهها كلاً من `channel_id` و `message_id`.",
"messages.invalid_message_data": "بيانات الرسالة غير صحيحة.", "messages.invalid_message_data": "بيانات الرسالة غير صحيحة.",
"messages.invalid_scheduled_message_payload": "حمولة الرسالة المجدولة غير صالحة.",
"messages.message_ids_cannot_be_empty": "لا يمكن أن تكون `message_ids` فارغة.", "messages.message_ids_cannot_be_empty": "لا يمكن أن تكون `message_ids` فارغة.",
"messages.messages_array_required": "مصفوفة `messages` مطلوبة ولا يمكن أن تكون فارغة.", "messages.messages_array_required": "مصفوفة `messages` مطلوبة ولا يمكن أن تكون فارغة.",
"messages.messages_with_snapshots_cannot_be_edited": "لا يمكن تعديل الرسائل التي تحتوي على لقطات.", "messages.messages_with_snapshots_cannot_be_edited": "لا يمكن تعديل الرسائل التي تحتوي على لقطات.",
"messages.scheduled_messages_max_30_days": "يمكن جدولة الرسائل لمدة أقصاها 30 يومًا فقط.",
"messages.scheduled_send_datetime_invalid": "تاريخ أو وقت غير صحيح للإرسال المجدول.",
"messages.scheduled_time_must_be_future": "يجب أن يكون الوقت المحدد في المستقبل.",
"messages.voice_messages_attachment_must_be_audio": "يجب أن تكون مرفقات الرسائل الصوتية ملفات صوتية.", "messages.voice_messages_attachment_must_be_audio": "يجب أن تكون مرفقات الرسائل الصوتية ملفات صوتية.",
"messages.voice_messages_cannot_have_content": "لا يمكن للرسائل الصوتية أن تحتوي على نص.", "messages.voice_messages_cannot_have_content": "لا يمكن للرسائل الصوتية أن تحتوي على نص.",
"messages.voice_messages_cannot_have_embeds": "لا يمكن للرسائل الصوتية أن تحتوي على تضمينات.", "messages.voice_messages_cannot_have_embeds": "لا يمكن للرسائل الصوتية أن تحتوي على تضمينات.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "Препратените съобщения не могат да съдържат съдържание, вградено съдържание, прикачени файлове или стикери.", "messages.forward_messages_cannot_include_extra_content": "Препратените съобщения не могат да съдържат съдържание, вградено съдържание, прикачени файлове или стикери.",
"messages.forward_reference_requires_channel_and_message": "Препратката към съобщението трябва да съдържа `channel_id` и `message_id`.", "messages.forward_reference_requires_channel_and_message": "Препратката към съобщението трябва да съдържа `channel_id` и `message_id`.",
"messages.invalid_message_data": "Невалидни данни на съобщението.", "messages.invalid_message_data": "Невалидни данни на съобщението.",
"messages.invalid_scheduled_message_payload": "Невалидни данни за планирано съобщение.",
"messages.message_ids_cannot_be_empty": "`message_ids` не могат да бъдат празни.", "messages.message_ids_cannot_be_empty": "`message_ids` не могат да бъдат празни.",
"messages.messages_array_required": "Масивът `messages` е задължителен и не може да е празен.", "messages.messages_array_required": "Масивът `messages` е задължителен и не може да е празен.",
"messages.messages_with_snapshots_cannot_be_edited": "Съобщения с моментни снимки не могат да бъдат редактирани.", "messages.messages_with_snapshots_cannot_be_edited": "Съобщения с моментни снимки не могат да бъдат редактирани.",
"messages.scheduled_messages_max_30_days": "Планираните съобщения могат да бъдат най-много 30 дни в бъдещето.",
"messages.scheduled_send_datetime_invalid": "Невалидна дата или час за изпращане по график.",
"messages.scheduled_time_must_be_future": "Часът за изпращане трябва да е в бъдещето.",
"messages.voice_messages_attachment_must_be_audio": "Прикачените файлове към гласови съобщения трябва да бъдат аудио файлове.", "messages.voice_messages_attachment_must_be_audio": "Прикачените файлове към гласови съобщения трябва да бъдат аудио файлове.",
"messages.voice_messages_cannot_have_content": "Гласовите съобщения не могат да имат текстово съдържание.", "messages.voice_messages_cannot_have_content": "Гласовите съобщения не могат да имат текстово съдържание.",
"messages.voice_messages_cannot_have_embeds": "Гласовите съобщения не могат да съдържат вградено съдържание.", "messages.voice_messages_cannot_have_embeds": "Гласовите съобщения не могат да съдържат вградено съдържание.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "Přeposlané zprávy nemohou obsahovat obsah, vložené prvky, přílohy ani samolepky.", "messages.forward_messages_cannot_include_extra_content": "Přeposlané zprávy nemohou obsahovat obsah, vložené prvky, přílohy ani samolepky.",
"messages.forward_reference_requires_channel_and_message": "Odkaz na přeposlanou zprávu musí obsahovat `channel_id` a `message_id`.", "messages.forward_reference_requires_channel_and_message": "Odkaz na přeposlanou zprávu musí obsahovat `channel_id` a `message_id`.",
"messages.invalid_message_data": "Neplatná data u zprávy.", "messages.invalid_message_data": "Neplatná data u zprávy.",
"messages.invalid_scheduled_message_payload": "Neplatná data plánované zprávy.",
"messages.message_ids_cannot_be_empty": "`message_ids` nemohou být prázdné.", "messages.message_ids_cannot_be_empty": "`message_ids` nemohou být prázdné.",
"messages.messages_array_required": "Pole `messages` je nutné a nesmí být prázdné.", "messages.messages_array_required": "Pole `messages` je nutné a nesmí být prázdné.",
"messages.messages_with_snapshots_cannot_be_edited": "Zprávy se snímky nelze upravit.", "messages.messages_with_snapshots_cannot_be_edited": "Zprávy se snímky nelze upravit.",
"messages.scheduled_messages_max_30_days": "Plánované zprávy mohou být maximálně 30 dní do budoucna.",
"messages.scheduled_send_datetime_invalid": "Neplatné datum nebo čas pro plánované odeslání.",
"messages.scheduled_time_must_be_future": "Plánovaný čas musí být v budoucnu.",
"messages.voice_messages_attachment_must_be_audio": "Přílohy hlasových zpráv musí být zvukové soubory.", "messages.voice_messages_attachment_must_be_audio": "Přílohy hlasových zpráv musí být zvukové soubory.",
"messages.voice_messages_cannot_have_content": "Hlasové zprávy nemohou mít text.", "messages.voice_messages_cannot_have_content": "Hlasové zprávy nemohou mít text.",
"messages.voice_messages_cannot_have_embeds": "Hlasové zprávy nemohou mít vložené prvky.", "messages.voice_messages_cannot_have_embeds": "Hlasové zprávy nemohou mít vložené prvky.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "Videresendte beskeder kan ikke indeholde indhold, indlejringer, vedhæftede filer eller stickers.", "messages.forward_messages_cannot_include_extra_content": "Videresendte beskeder kan ikke indeholde indhold, indlejringer, vedhæftede filer eller stickers.",
"messages.forward_reference_requires_channel_and_message": "Reference for videresendte beskeder skal indeholde `channel_id` og `message_id`.", "messages.forward_reference_requires_channel_and_message": "Reference for videresendte beskeder skal indeholde `channel_id` og `message_id`.",
"messages.invalid_message_data": "Ugyldige beskeddata.", "messages.invalid_message_data": "Ugyldige beskeddata.",
"messages.invalid_scheduled_message_payload": "Ugyldig planlagt besked-nyttelast.",
"messages.message_ids_cannot_be_empty": "`message_ids` må ikke være tomme.", "messages.message_ids_cannot_be_empty": "`message_ids` må ikke være tomme.",
"messages.messages_array_required": "`messages`-arrayet skal være angivet og må ikke være tomt.", "messages.messages_array_required": "`messages`-arrayet skal være angivet og må ikke være tomt.",
"messages.messages_with_snapshots_cannot_be_edited": "Beskeder med snapshots kan ikke redigeres.", "messages.messages_with_snapshots_cannot_be_edited": "Beskeder med snapshots kan ikke redigeres.",
"messages.scheduled_messages_max_30_days": "Planlagte beskeder kan højst være 30 dage ude i fremtiden.",
"messages.scheduled_send_datetime_invalid": "Ugyldig dato eller tidspunkt for planlagt afsendelse.",
"messages.scheduled_time_must_be_future": "Planlagt tidspunkt skal være i fremtiden.",
"messages.voice_messages_attachment_must_be_audio": "Vedhæftede filer til talebeskeder skal være lydfiler.", "messages.voice_messages_attachment_must_be_audio": "Vedhæftede filer til talebeskeder skal være lydfiler.",
"messages.voice_messages_cannot_have_content": "Talebeskeder kan ikke have tekstindhold.", "messages.voice_messages_cannot_have_content": "Talebeskeder kan ikke have tekstindhold.",
"messages.voice_messages_cannot_have_embeds": "Talebeskeder kan ikke have indlejringer.", "messages.voice_messages_cannot_have_embeds": "Talebeskeder kan ikke have indlejringer.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "Weitergeleitete Nachrichten dürfen keine Inhalte, Embeds, Anhänge oder Sticker enthalten.", "messages.forward_messages_cannot_include_extra_content": "Weitergeleitete Nachrichten dürfen keine Inhalte, Embeds, Anhänge oder Sticker enthalten.",
"messages.forward_reference_requires_channel_and_message": "Die Referenz für weitergeleitete Nachrichten muss `channel_id` und `message_id` enthalten.", "messages.forward_reference_requires_channel_and_message": "Die Referenz für weitergeleitete Nachrichten muss `channel_id` und `message_id` enthalten.",
"messages.invalid_message_data": "Ungültige Nachrichtendaten.", "messages.invalid_message_data": "Ungültige Nachrichtendaten.",
"messages.invalid_scheduled_message_payload": "Ungültige Payload für geplante Nachrichten.",
"messages.message_ids_cannot_be_empty": "`message_ids` darf nicht leer sein.", "messages.message_ids_cannot_be_empty": "`message_ids` darf nicht leer sein.",
"messages.messages_array_required": "Das `messages`-Array ist erforderlich und darf nicht leer sein.", "messages.messages_array_required": "Das `messages`-Array ist erforderlich und darf nicht leer sein.",
"messages.messages_with_snapshots_cannot_be_edited": "Nachrichten mit Snapshots können nicht bearbeitet werden.", "messages.messages_with_snapshots_cannot_be_edited": "Nachrichten mit Snapshots können nicht bearbeitet werden.",
"messages.scheduled_messages_max_30_days": "Geplante Nachrichten können maximal 30 Tage in die Zukunft reichen.",
"messages.scheduled_send_datetime_invalid": "Ungültiges Datum oder ungültige Uhrzeit für den geplanten Versand.",
"messages.scheduled_time_must_be_future": "Die geplante Zeit muss in der Zukunft sein.",
"messages.voice_messages_attachment_must_be_audio": "Sprachnachrichten-Anhänge müssen Audiodateien sein.", "messages.voice_messages_attachment_must_be_audio": "Sprachnachrichten-Anhänge müssen Audiodateien sein.",
"messages.voice_messages_cannot_have_content": "Sprachnachrichten können keinen Text enthalten.", "messages.voice_messages_cannot_have_content": "Sprachnachrichten können keinen Text enthalten.",
"messages.voice_messages_cannot_have_embeds": "Sprachnachrichten können keine Einbettungen enthalten.", "messages.voice_messages_cannot_have_embeds": "Sprachnachrichten können keine Einbettungen enthalten.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "Τα προωθημένα μηνύματα δεν μπορούν να περιλαμβάνουν περιεχόμενο, ενσωματώσεις, συνημμένα ή sticker.", "messages.forward_messages_cannot_include_extra_content": "Τα προωθημένα μηνύματα δεν μπορούν να περιλαμβάνουν περιεχόμενο, ενσωματώσεις, συνημμένα ή sticker.",
"messages.forward_reference_requires_channel_and_message": "Η αναφορά προώθησης μηνύματος πρέπει να περιλαμβάνει `channel_id` και `message_id`.", "messages.forward_reference_requires_channel_and_message": "Η αναφορά προώθησης μηνύματος πρέπει να περιλαμβάνει `channel_id` και `message_id`.",
"messages.invalid_message_data": "Μη έγκυρα δεδομένα μηνύματος.", "messages.invalid_message_data": "Μη έγκυρα δεδομένα μηνύματος.",
"messages.invalid_scheduled_message_payload": "Μη έγκυρο ωφέλιμο φορτίο προγραμματισμένου μηνύματος.",
"messages.message_ids_cannot_be_empty": "Το `message_ids` δεν μπορεί να είναι άδειο.", "messages.message_ids_cannot_be_empty": "Το `message_ids` δεν μπορεί να είναι άδειο.",
"messages.messages_array_required": "Ο πίνακας `messages` απαιτείται και δεν μπορεί να είναι άδειος.", "messages.messages_array_required": "Ο πίνακας `messages` απαιτείται και δεν μπορεί να είναι άδειος.",
"messages.messages_with_snapshots_cannot_be_edited": "Δεν μπορείς να επεξεργαστείς μηνύματα με στιγμιότυπα.", "messages.messages_with_snapshots_cannot_be_edited": "Δεν μπορείς να επεξεργαστείς μηνύματα με στιγμιότυπα.",
"messages.scheduled_messages_max_30_days": "Τα προγραμματισμένα μηνύματα μπορούν να οριστούν έως και 30 ημέρες από τώρα.",
"messages.scheduled_send_datetime_invalid": "Άκυρη ημερομηνία ή ώρα για προγραμματισμένη αποστολή.",
"messages.scheduled_time_must_be_future": "Η προγραμματισμένη ώρα πρέπει να είναι στο μέλλον.",
"messages.voice_messages_attachment_must_be_audio": "Τα συνημμένα φωνητικά μηνύματα πρέπει να είναι αρχεία ήχου.", "messages.voice_messages_attachment_must_be_audio": "Τα συνημμένα φωνητικά μηνύματα πρέπει να είναι αρχεία ήχου.",
"messages.voice_messages_cannot_have_content": "Τα φωνητικά μηνύματα δεν μπορούν να έχουν κείμενο.", "messages.voice_messages_cannot_have_content": "Τα φωνητικά μηνύματα δεν μπορούν να έχουν κείμενο.",
"messages.voice_messages_cannot_have_embeds": "Τα φωνητικά μηνύματα δεν μπορούν να έχουν ενσωματώσεις.", "messages.voice_messages_cannot_have_embeds": "Τα φωνητικά μηνύματα δεν μπορούν να έχουν ενσωματώσεις.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "Forwarded messages can't include content, embeds, attachments, or stickers.", "messages.forward_messages_cannot_include_extra_content": "Forwarded messages can't include content, embeds, attachments, or stickers.",
"messages.forward_reference_requires_channel_and_message": "Forward message reference must include `channel_id` and `message_id`.", "messages.forward_reference_requires_channel_and_message": "Forward message reference must include `channel_id` and `message_id`.",
"messages.invalid_message_data": "Invalid message data.", "messages.invalid_message_data": "Invalid message data.",
"messages.invalid_scheduled_message_payload": "Invalid scheduled message payload.",
"messages.message_ids_cannot_be_empty": "`message_ids` can't be empty.", "messages.message_ids_cannot_be_empty": "`message_ids` can't be empty.",
"messages.messages_array_required": "`messages` array is required and must not be empty.", "messages.messages_array_required": "`messages` array is required and must not be empty.",
"messages.messages_with_snapshots_cannot_be_edited": "Messages with snapshots can't be edited.", "messages.messages_with_snapshots_cannot_be_edited": "Messages with snapshots can't be edited.",
"messages.scheduled_messages_max_30_days": "Scheduled messages can be at most 30 days in the future.",
"messages.scheduled_send_datetime_invalid": "Invalid date or time for scheduled send.",
"messages.scheduled_time_must_be_future": "Scheduled time must be in the future.",
"messages.voice_messages_attachment_must_be_audio": "Voice message attachments must be audio files.", "messages.voice_messages_attachment_must_be_audio": "Voice message attachments must be audio files.",
"messages.voice_messages_cannot_have_content": "Voice messages can't have text content.", "messages.voice_messages_cannot_have_content": "Voice messages can't have text content.",
"messages.voice_messages_cannot_have_embeds": "Voice messages can't have embeds.", "messages.voice_messages_cannot_have_embeds": "Voice messages can't have embeds.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "Los mensajes reenviados no pueden incluir contenido, elementos incrustados, archivos adjuntos o stickers.", "messages.forward_messages_cannot_include_extra_content": "Los mensajes reenviados no pueden incluir contenido, elementos incrustados, archivos adjuntos o stickers.",
"messages.forward_reference_requires_channel_and_message": "La referencia del mensaje reenviado debe incluir `channel_id` y `message_id`.", "messages.forward_reference_requires_channel_and_message": "La referencia del mensaje reenviado debe incluir `channel_id` y `message_id`.",
"messages.invalid_message_data": "Mensaje con datos no válidos.", "messages.invalid_message_data": "Mensaje con datos no válidos.",
"messages.invalid_scheduled_message_payload": "Carga útil de mensaje programado no válida.",
"messages.message_ids_cannot_be_empty": "`message_ids` no puede estar vacío.", "messages.message_ids_cannot_be_empty": "`message_ids` no puede estar vacío.",
"messages.messages_array_required": "El arreglo `messages` es requerido y no puede estar vacío.", "messages.messages_array_required": "El arreglo `messages` es requerido y no puede estar vacío.",
"messages.messages_with_snapshots_cannot_be_edited": "Los mensajes con capturas no se pueden editar.", "messages.messages_with_snapshots_cannot_be_edited": "Los mensajes con capturas no se pueden editar.",
"messages.scheduled_messages_max_30_days": "Los mensajes programados pueden enviarse hasta con 30 días de anticipación.",
"messages.scheduled_send_datetime_invalid": "La fecha o la hora del envío programado no es válida.",
"messages.scheduled_time_must_be_future": "La hora programada debe estar en el futuro.",
"messages.voice_messages_attachment_must_be_audio": "Los archivos adjuntos de los mensajes de voz deben ser archivos de audio.", "messages.voice_messages_attachment_must_be_audio": "Los archivos adjuntos de los mensajes de voz deben ser archivos de audio.",
"messages.voice_messages_cannot_have_content": "Los mensajes de voz no pueden incluir texto.", "messages.voice_messages_cannot_have_content": "Los mensajes de voz no pueden incluir texto.",
"messages.voice_messages_cannot_have_embeds": "Los mensajes de voz no pueden tener embeds.", "messages.voice_messages_cannot_have_embeds": "Los mensajes de voz no pueden tener embeds.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "Los mensajes reenviados no pueden incluir contenido, elementos incrustados, archivos adjuntos o stickers.", "messages.forward_messages_cannot_include_extra_content": "Los mensajes reenviados no pueden incluir contenido, elementos incrustados, archivos adjuntos o stickers.",
"messages.forward_reference_requires_channel_and_message": "La referencia del mensaje reenviado debe incluir `channel_id` y `message_id`.", "messages.forward_reference_requires_channel_and_message": "La referencia del mensaje reenviado debe incluir `channel_id` y `message_id`.",
"messages.invalid_message_data": "Datos de mensaje no válidos.", "messages.invalid_message_data": "Datos de mensaje no válidos.",
"messages.invalid_scheduled_message_payload": "Carga útil de mensaje programado no válida.",
"messages.message_ids_cannot_be_empty": "`message_ids` no puede estar vacío.", "messages.message_ids_cannot_be_empty": "`message_ids` no puede estar vacío.",
"messages.messages_array_required": "El array `messages` es necesario y no puede estar vacío.", "messages.messages_array_required": "El array `messages` es necesario y no puede estar vacío.",
"messages.messages_with_snapshots_cannot_be_edited": "Los mensajes con instantáneas no se pueden editar.", "messages.messages_with_snapshots_cannot_be_edited": "Los mensajes con instantáneas no se pueden editar.",
"messages.scheduled_messages_max_30_days": "Los mensajes programados pueden ser como máximo 30 días en el futuro.",
"messages.scheduled_send_datetime_invalid": "Fecha u hora no válidas para el envío programado.",
"messages.scheduled_time_must_be_future": "La hora programada debe ser en el futuro.",
"messages.voice_messages_attachment_must_be_audio": "Los adjuntos de los mensajes de voz deben ser archivos de audio.", "messages.voice_messages_attachment_must_be_audio": "Los adjuntos de los mensajes de voz deben ser archivos de audio.",
"messages.voice_messages_cannot_have_content": "Los mensajes de voz no pueden tener contenido de texto.", "messages.voice_messages_cannot_have_content": "Los mensajes de voz no pueden tener contenido de texto.",
"messages.voice_messages_cannot_have_embeds": "Los mensajes de voz no pueden tener embeds.", "messages.voice_messages_cannot_have_embeds": "Los mensajes de voz no pueden tener embeds.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "Edelleenlähetetyt viestit eivät voi sisältää sisältöä, upotuksia, liitteitä tai tarroja.", "messages.forward_messages_cannot_include_extra_content": "Edelleenlähetetyt viestit eivät voi sisältää sisältöä, upotuksia, liitteitä tai tarroja.",
"messages.forward_reference_requires_channel_and_message": "Edelleenlähetetyn viestin viitteen on sisällettävä `channel_id` ja `message_id`.", "messages.forward_reference_requires_channel_and_message": "Edelleenlähetetyn viestin viitteen on sisällettävä `channel_id` ja `message_id`.",
"messages.invalid_message_data": "Virheellinen viestidata.", "messages.invalid_message_data": "Virheellinen viestidata.",
"messages.invalid_scheduled_message_payload": "Virheellinen ajoitetun viestin hyötykuorma.",
"messages.message_ids_cannot_be_empty": "`message_ids` ei voi olla tyhjä.", "messages.message_ids_cannot_be_empty": "`message_ids` ei voi olla tyhjä.",
"messages.messages_array_required": "`messages`-taulukko on pakollinen eikä se saa olla tyhjä.", "messages.messages_array_required": "`messages`-taulukko on pakollinen eikä se saa olla tyhjä.",
"messages.messages_with_snapshots_cannot_be_edited": "Viestit, joissa on tilannekuvia, eivät ole muokattavissa.", "messages.messages_with_snapshots_cannot_be_edited": "Viestit, joissa on tilannekuvia, eivät ole muokattavissa.",
"messages.scheduled_messages_max_30_days": "Ajoitetut viestit voivat olla enintään 30 päivän päässä tulevaisuudessa.",
"messages.scheduled_send_datetime_invalid": "Ajoitetun lähetyksen päivämäärä tai aika on virheellinen.",
"messages.scheduled_time_must_be_future": "Ajoitetun ajan on oltava tulevaisuudessa.",
"messages.voice_messages_attachment_must_be_audio": "Ääniviestin liitteiden täytyy olla äänitiedostoja.", "messages.voice_messages_attachment_must_be_audio": "Ääniviestin liitteiden täytyy olla äänitiedostoja.",
"messages.voice_messages_cannot_have_content": "Ääniviesteissä ei voi olla tekstisisältöä.", "messages.voice_messages_cannot_have_content": "Ääniviesteissä ei voi olla tekstisisältöä.",
"messages.voice_messages_cannot_have_embeds": "Ääniviesteissä ei voi olla upotteita.", "messages.voice_messages_cannot_have_embeds": "Ääniviesteissä ei voi olla upotteita.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "Les messages transférés ne peuvent pas inclure de contenu, d'éléments intégrés, de pièces jointes ou de stickers.", "messages.forward_messages_cannot_include_extra_content": "Les messages transférés ne peuvent pas inclure de contenu, d'éléments intégrés, de pièces jointes ou de stickers.",
"messages.forward_reference_requires_channel_and_message": "La référence du message à transférer doit inclure `channel_id` et `message_id`.", "messages.forward_reference_requires_channel_and_message": "La référence du message à transférer doit inclure `channel_id` et `message_id`.",
"messages.invalid_message_data": "Données de message non valides.", "messages.invalid_message_data": "Données de message non valides.",
"messages.invalid_scheduled_message_payload": "Contenu du message programmé invalide.",
"messages.message_ids_cannot_be_empty": "Le champ `message_ids` ne peut pas être vide.", "messages.message_ids_cannot_be_empty": "Le champ `message_ids` ne peut pas être vide.",
"messages.messages_array_required": "Le tableau `messages` est obligatoire et ne doit pas être vide.", "messages.messages_array_required": "Le tableau `messages` est obligatoire et ne doit pas être vide.",
"messages.messages_with_snapshots_cannot_be_edited": "Les messages avec des captures d'écran ne peuvent pas être modifiés.", "messages.messages_with_snapshots_cannot_be_edited": "Les messages avec des captures d'écran ne peuvent pas être modifiés.",
"messages.scheduled_messages_max_30_days": "Les messages programmés ne peuvent pas être planifiés plus de 30 jours à l'avance.",
"messages.scheduled_send_datetime_invalid": "Date ou heure incorrecte pour l'envoi programmé.",
"messages.scheduled_time_must_be_future": "L'heure programmée doit être dans le futur.",
"messages.voice_messages_attachment_must_be_audio": "Les pièces jointes des messages vocaux doivent être des fichiers audio.", "messages.voice_messages_attachment_must_be_audio": "Les pièces jointes des messages vocaux doivent être des fichiers audio.",
"messages.voice_messages_cannot_have_content": "Les messages vocaux ne peuvent pas avoir de contenu textuel.", "messages.voice_messages_cannot_have_content": "Les messages vocaux ne peuvent pas avoir de contenu textuel.",
"messages.voice_messages_cannot_have_embeds": "Les messages vocaux ne peuvent pas avoir d'intégrations.", "messages.voice_messages_cannot_have_embeds": "Les messages vocaux ne peuvent pas avoir d'intégrations.",
@@ -371,13 +371,9 @@
"messages.forward_messages_cannot_include_extra_content": "הודעות מועברות לא יכולות לכלול תוכן, הטמעות, קבצים מצורפים או סטיקרים.", "messages.forward_messages_cannot_include_extra_content": "הודעות מועברות לא יכולות לכלול תוכן, הטמעות, קבצים מצורפים או סטיקרים.",
"messages.forward_reference_requires_channel_and_message": "הפניית הודעה מועברת צריכה לכלול `channel_id` ו-`message_id`.", "messages.forward_reference_requires_channel_and_message": "הפניית הודעה מועברת צריכה לכלול `channel_id` ו-`message_id`.",
"messages.invalid_message_data": "נתוני הודעה לא חוקיים.", "messages.invalid_message_data": "נתוני הודעה לא חוקיים.",
"messages.invalid_scheduled_message_payload": "מטען נתונים שגוי עבור הודעה מתוזמנת.",
"messages.message_ids_cannot_be_empty": "`message_ids` לא יכול להיות ריק.", "messages.message_ids_cannot_be_empty": "`message_ids` לא יכול להיות ריק.",
"messages.messages_array_required": "מערך `messages` נדרש ולא יכול להיות ריק.", "messages.messages_array_required": "מערך `messages` נדרש ולא יכול להיות ריק.",
"messages.messages_with_snapshots_cannot_be_edited": "לא ניתן לערוך הודעות הכוללות צילומי מסך.", "messages.messages_with_snapshots_cannot_be_edited": "לא ניתן לערוך הודעות הכוללות צילומי מסך.",
"messages.scheduled_messages_max_30_days": "ניתן לתזמן הודעות ל-30 הימים הבאים לכל היותר.",
"messages.scheduled_send_datetime_invalid": "תאריך או שעה לא חוקיים לשליחה מתוזמנת.",
"messages.scheduled_time_must_be_future": "מועד השליחה חייב להיות עתידי.",
"messages.voice_messages_attachment_must_be_audio": "קבצים מצורפים בהודעות קוליות חייבים להיות קובצי שמע.", "messages.voice_messages_attachment_must_be_audio": "קבצים מצורפים בהודעות קוליות חייבים להיות קובצי שמע.",
"messages.voice_messages_cannot_have_content": "הודעות קוליות אינן יכולות לכלול תוכן טקסט.", "messages.voice_messages_cannot_have_content": "הודעות קוליות אינן יכולות לכלול תוכן טקסט.",
"messages.voice_messages_cannot_have_embeds": "הודעות קוליות לא יכולות לכלול הטמעות.", "messages.voice_messages_cannot_have_embeds": "הודעות קוליות לא יכולות לכלול הטמעות.",

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