Enhance component message handling and introduce new features

- Added `ComponentMessageBinding` model to manage message components in the database.
- Updated `WelcomeConfig`, `Tag`, and `ScheduledMessage` models to support new component fields.
- Integrated component handling in various modules, including Scheduler and Tags, to improve message customization.
- Enhanced user experience by implementing new commands for creating and managing component messages in the utility module.
- Updated localization files to reflect new component-related features and improve user guidance.
This commit is contained in:
TheOnlyMace
2026-07-22 22:55:37 +02:00
parent 95eed45ec4
commit 4e135bcf43
46 changed files with 4505 additions and 194 deletions

View File

@@ -592,13 +592,37 @@ export const commandLocales = {
en: 'Show last edited message'
},
'utility.embed.description': {
de: 'Embeds erstellen',
en: 'Create embeds'
de: 'Embeds und Components V2 erstellen',
en: 'Create embeds and Components V2'
},
'utility.embed.builder.description': {
de: 'Embed per Modal bauen und senden',
en: 'Build and send an embed via modal'
},
'utility.embed.components.description': {
de: 'Schnelle Components-V2-Nachricht senden',
en: 'Send a quick Components V2 message'
},
'utility.embed.components.options.channel': {
de: 'Zielkanal',
en: 'Target channel'
},
'utility.embed.components.options.text': {
de: 'Textinhalt',
en: 'Text content'
},
'utility.embed.components.options.image_url': {
de: 'Optionale Bild-URL',
en: 'Optional image URL'
},
'utility.embed.components.options.button_label': {
de: 'Optionaler Button-Text',
en: 'Optional button label'
},
'utility.embed.components.options.button_url': {
de: 'Optionale Button-URL',
en: 'Optional button URL'
},
'fun.8ball.description': {
de: 'Magische 8-Ball-Antwort auf deine Frage',
en: 'Magic 8-ball answer to your question'

View File

@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import {
countComponentsV2,
decodeComponentCustomId,
encodeComponentCustomId,
emptyComponentsV2,
mapComponentsV2TextFields,
MessageComponentsV2Schema
} from './components-v2.js';
describe('MessageComponentsV2Schema', () => {
it('accepts a minimal container + text display', () => {
const parsed = MessageComponentsV2Schema.parse(emptyComponentsV2());
expect(parsed.components).toHaveLength(1);
expect(parsed.components[0]?.type).toBe('container');
});
it('accepts interactive button with matching action', () => {
const parsed = MessageComponentsV2Schema.parse({
components: [
{
type: 'action_row',
components: [
{
type: 'button',
style: 'Primary',
label: 'Toggle',
actionId: 'toggle_vip'
}
]
}
],
actions: {
toggle_vip: { type: 'TOGGLE_ROLE', roleId: '123456789012345678' }
}
});
expect(parsed.actions.toggle_vip?.type).toBe('TOGGLE_ROLE');
});
it('accepts link buttons without action', () => {
const parsed = MessageComponentsV2Schema.parse({
components: [
{
type: 'action_row',
components: [
{
type: 'button',
style: 'Link',
label: 'Docs',
url: 'https://example.com'
}
]
}
],
actions: {}
});
expect(parsed.components[0]?.type).toBe('action_row');
});
it('rejects missing action definitions', () => {
const result = MessageComponentsV2Schema.safeParse({
components: [
{
type: 'action_row',
components: [{ type: 'button', style: 'Success', label: 'Go', actionId: 'missing' }]
}
],
actions: {}
});
expect(result.success).toBe(false);
});
it('rejects trees above 40 components', () => {
const texts = Array.from({ length: 41 }, (_, i) => ({
type: 'text_display' as const,
content: `Line ${i}`
}));
const result = MessageComponentsV2Schema.safeParse({
components: texts,
actions: {}
});
expect(result.success).toBe(false);
});
it('counts nested container children', () => {
const count = countComponentsV2([
{
type: 'container',
components: [
{ type: 'text_display', content: 'a' },
{ type: 'separator' },
{
type: 'section',
text: 'hello',
accessory: { type: 'thumbnail', url: 'https://example.com/a.png' }
}
]
}
]);
// container + 3 children + section accessory
expect(count).toBe(5);
});
});
describe('custom_id helpers', () => {
it('encodes and decodes cv2 custom ids', () => {
const customId = encodeComponentCustomId('t', 'tag_abc', 'reply1');
expect(customId).toBe('cv2:t:tag_abc:reply1');
expect(decodeComponentCustomId(customId)).toEqual({
source: 't',
ref: 'tag_abc',
actionId: 'reply1'
});
});
it('returns null for unrelated custom ids', () => {
expect(decodeComponentCustomId('sr:btn:1:2')).toBeNull();
});
});
describe('mapComponentsV2TextFields', () => {
it('maps text and action reply content', () => {
const mapped = mapComponentsV2TextFields(
{
components: [
{
type: 'container',
components: [{ type: 'text_display', content: 'Hi {user}' }]
}
],
actions: {
a1: { type: 'EPHEMERAL_REPLY', content: 'Welcome {user}' }
}
},
(value) => value.replaceAll('{user}', '@Mace')
);
const container = mapped.components[0];
expect(container?.type).toBe('container');
if (container?.type === 'container') {
expect(container.components[0]).toMatchObject({
type: 'text_display',
content: 'Hi @Mace'
});
}
expect(mapped.actions.a1?.content).toBe('Welcome @Mace');
});
});

View File

@@ -0,0 +1,562 @@
import { z } from 'zod';
/** Discord MessageFlags.IsComponentsV2 */
export const COMPONENTS_V2_FLAG = 32768 as const;
export const ComponentV2SourceSchema = z.enum(['w', 'l', 't', 's', 'm']);
export type ComponentV2Source = z.infer<typeof ComponentV2SourceSchema>;
export const ComponentActionTypeSchema = z.enum([
'EPHEMERAL_REPLY',
'PUBLIC_REPLY',
'ASSIGN_ROLE',
'REMOVE_ROLE',
'TOGGLE_ROLE',
'ASSIGN_SELECTED_ROLES',
'NONE'
]);
export type ComponentActionType = z.infer<typeof ComponentActionTypeSchema>;
export const ComponentActionSchema = z
.object({
type: ComponentActionTypeSchema,
content: z.string().max(2000).optional(),
roleId: z.string().max(32).optional()
})
.superRefine((value, ctx) => {
if (
(value.type === 'EPHEMERAL_REPLY' || value.type === 'PUBLIC_REPLY') &&
!value.content?.trim()
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Reply actions require content',
path: ['content']
});
}
if (
(value.type === 'ASSIGN_ROLE' ||
value.type === 'REMOVE_ROLE' ||
value.type === 'TOGGLE_ROLE') &&
!value.roleId?.trim()
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Role actions require roleId',
path: ['roleId']
});
}
});
export type ComponentAction = z.infer<typeof ComponentActionSchema>;
export const ComponentButtonStyleSchema = z.enum([
'Primary',
'Secondary',
'Success',
'Danger',
'Link'
]);
export type ComponentButtonStyle = z.infer<typeof ComponentButtonStyleSchema>;
export const SeparatorSpacingSchema = z.enum(['Small', 'Large']);
export type SeparatorSpacing = z.infer<typeof SeparatorSpacingSchema>;
export const MediaGalleryItemSchema = z.object({
url: z.string().max(2048),
description: z.string().max(1024).optional(),
spoiler: z.boolean().optional()
});
export type MediaGalleryItem = z.infer<typeof MediaGalleryItemSchema>;
/** Plain object (no effects) so it can sit in Zod discriminated unions. */
export const ComponentButtonSchema = z.object({
type: z.literal('button'),
id: z.string().min(1).max(40).optional(),
style: ComponentButtonStyleSchema.default('Secondary'),
label: z.string().min(1).max(80),
emoji: z.string().max(64).optional(),
url: z.string().max(2048).optional(),
actionId: z.string().min(1).max(40).optional(),
disabled: z.boolean().optional()
});
export type ComponentButton = z.infer<typeof ComponentButtonSchema>;
export const ComponentThumbnailSchema = z.object({
type: z.literal('thumbnail'),
id: z.string().min(1).max(40).optional(),
url: z.string().max(2048),
description: z.string().max(1024).optional(),
spoiler: z.boolean().optional()
});
export type ComponentThumbnail = z.infer<typeof ComponentThumbnailSchema>;
export const StringSelectOptionSchema = z.object({
label: z.string().min(1).max(100),
value: z.string().min(1).max(100),
description: z.string().max(100).optional(),
emoji: z.string().max(64).optional(),
actionId: z.string().min(1).max(40).optional()
});
export type StringSelectOption = z.infer<typeof StringSelectOptionSchema>;
export const ComponentStringSelectSchema = z.object({
type: z.literal('string_select'),
id: z.string().min(1).max(40).optional(),
placeholder: z.string().max(150).optional(),
minValues: z.number().int().min(0).max(25).optional(),
maxValues: z.number().int().min(1).max(25).optional(),
options: z.array(StringSelectOptionSchema).min(1).max(25),
actionId: z.string().min(1).max(40).optional(),
disabled: z.boolean().optional()
});
export type ComponentStringSelect = z.infer<typeof ComponentStringSelectSchema>;
const EntitySelectBase = {
id: z.string().min(1).max(40).optional(),
placeholder: z.string().max(150).optional(),
minValues: z.number().int().min(0).max(25).optional(),
maxValues: z.number().int().min(1).max(25).optional(),
actionId: z.string().min(1).max(40),
disabled: z.boolean().optional()
};
export const ComponentUserSelectSchema = z.object({
type: z.literal('user_select'),
...EntitySelectBase
});
export type ComponentUserSelect = z.infer<typeof ComponentUserSelectSchema>;
export const ComponentRoleSelectSchema = z.object({
type: z.literal('role_select'),
...EntitySelectBase
});
export type ComponentRoleSelect = z.infer<typeof ComponentRoleSelectSchema>;
export const ComponentChannelSelectSchema = z.object({
type: z.literal('channel_select'),
...EntitySelectBase
});
export type ComponentChannelSelect = z.infer<typeof ComponentChannelSelectSchema>;
export const ComponentMentionableSelectSchema = z.object({
type: z.literal('mentionable_select'),
...EntitySelectBase
});
export type ComponentMentionableSelect = z.infer<typeof ComponentMentionableSelectSchema>;
export const ComponentSelectSchema = z.discriminatedUnion('type', [
ComponentStringSelectSchema,
ComponentUserSelectSchema,
ComponentRoleSelectSchema,
ComponentChannelSelectSchema,
ComponentMentionableSelectSchema
]);
export type ComponentSelect = z.infer<typeof ComponentSelectSchema>;
export const ComponentTextDisplaySchema = z.object({
type: z.literal('text_display'),
id: z.string().min(1).max(40).optional(),
content: z.string().min(1).max(4000)
});
export type ComponentTextDisplay = z.infer<typeof ComponentTextDisplaySchema>;
export const ComponentSeparatorSchema = z.object({
type: z.literal('separator'),
id: z.string().min(1).max(40).optional(),
divider: z.boolean().optional(),
spacing: SeparatorSpacingSchema.optional()
});
export type ComponentSeparator = z.infer<typeof ComponentSeparatorSchema>;
export const ComponentMediaGallerySchema = z.object({
type: z.literal('media_gallery'),
id: z.string().min(1).max(40).optional(),
items: z.array(MediaGalleryItemSchema).min(1).max(10)
});
export type ComponentMediaGallery = z.infer<typeof ComponentMediaGallerySchema>;
export const ComponentSectionAccessorySchema = z.discriminatedUnion('type', [
ComponentThumbnailSchema,
ComponentButtonSchema
]);
export const ComponentSectionSchema = z.object({
type: z.literal('section'),
id: z.string().min(1).max(40).optional(),
text: z.union([z.string().min(1).max(4000), z.array(z.string().min(1).max(4000)).min(1).max(3)]),
accessory: ComponentSectionAccessorySchema
});
export type ComponentSection = z.infer<typeof ComponentSectionSchema>;
export const ComponentActionRowChildSchema = z.union([
ComponentButtonSchema,
ComponentStringSelectSchema,
ComponentUserSelectSchema,
ComponentRoleSelectSchema,
ComponentChannelSelectSchema,
ComponentMentionableSelectSchema
]);
export const ComponentActionRowSchema = z.object({
type: z.literal('action_row'),
id: z.string().min(1).max(40).optional(),
components: z.array(ComponentActionRowChildSchema).min(1).max(5)
});
export type ComponentActionRow = z.infer<typeof ComponentActionRowSchema>;
export type ComponentV2Node =
| ComponentTextDisplay
| ComponentSeparator
| ComponentThumbnail
| ComponentMediaGallery
| ComponentSection
| ComponentActionRow
| ComponentButton
| ComponentSelect
| {
type: 'container';
id?: string;
accentColor?: number;
spoiler?: boolean;
components: ComponentV2Node[];
};
type ComponentContainer = Extract<ComponentV2Node, { type: 'container' }>;
const leafComponentSchemas = [
ComponentTextDisplaySchema,
ComponentSeparatorSchema,
ComponentThumbnailSchema,
ComponentMediaGallerySchema,
ComponentSectionSchema,
ComponentActionRowSchema,
ComponentButtonSchema,
ComponentStringSelectSchema,
ComponentUserSelectSchema,
ComponentRoleSelectSchema,
ComponentChannelSelectSchema,
ComponentMentionableSelectSchema
] as const;
export const ComponentContainerSchema: z.ZodType<ComponentContainer> = z.lazy(() =>
z.object({
type: z.literal('container'),
id: z.string().min(1).max(40).optional(),
accentColor: z.number().int().min(0).max(0xffffff).optional(),
spoiler: z.boolean().optional(),
components: z.array(ComponentV2NodeSchema).min(1).max(40)
})
) as z.ZodType<ComponentContainer>;
export const ComponentV2NodeSchema: z.ZodType<ComponentV2Node> = z.lazy(() =>
z.union([...leafComponentSchemas, ComponentContainerSchema])
) as z.ZodType<ComponentV2Node>;
const MAX_COMPONENTS = 40;
/** Counts every node in the tree (including nested container children). */
export function countComponentsV2(nodes: ComponentV2Node[]): number {
let count = 0;
for (const node of nodes) {
count += 1;
if (node.type === 'container') {
count += countComponentsV2(node.components);
} else if (node.type === 'action_row') {
count += node.components.length;
} else if (node.type === 'section') {
count += 1;
} else if (node.type === 'media_gallery') {
count += node.items.length;
}
}
return count;
}
function collectActionIds(nodes: ComponentV2Node[], ids: Set<string>): void {
for (const node of nodes) {
switch (node.type) {
case 'button':
if (node.actionId) {
ids.add(node.actionId);
}
break;
case 'string_select':
if (node.actionId) {
ids.add(node.actionId);
}
for (const option of node.options) {
if (option.actionId) {
ids.add(option.actionId);
}
}
break;
case 'user_select':
case 'role_select':
case 'channel_select':
case 'mentionable_select':
ids.add(node.actionId);
break;
case 'action_row':
collectActionIds(node.components as ComponentV2Node[], ids);
break;
case 'section':
if (node.accessory.type === 'button' && node.accessory.actionId) {
ids.add(node.accessory.actionId);
}
break;
case 'container':
collectActionIds(node.components, ids);
break;
default:
break;
}
}
}
function validateButtonRules(
button: ComponentButton,
ctx: z.RefinementCtx,
path: (string | number)[]
): void {
if (button.style === 'Link') {
if (!button.url?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Link buttons require url',
path: [...path, 'url']
});
}
} else if (!button.actionId?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Interactive buttons require actionId',
path: [...path, 'actionId']
});
}
}
function validateNodeRules(
nodes: ComponentV2Node[],
ctx: z.RefinementCtx,
path: (string | number)[]
): void {
nodes.forEach((node, index) => {
const nodePath = [...path, index];
if (node.type === 'button') {
validateButtonRules(node, ctx, nodePath);
} else if (node.type === 'action_row') {
node.components.forEach((child, childIndex) => {
if (child.type === 'button') {
validateButtonRules(child, ctx, [...nodePath, 'components', childIndex]);
}
});
} else if (node.type === 'section' && node.accessory.type === 'button') {
validateButtonRules(node.accessory, ctx, [...nodePath, 'accessory']);
} else if (node.type === 'container') {
validateNodeRules(node.components, ctx, [...nodePath, 'components']);
}
});
}
export const MessageComponentsV2Schema = z
.object({
components: z.array(ComponentV2NodeSchema).min(1).max(MAX_COMPONENTS),
actions: z.record(z.string().min(1).max(40), ComponentActionSchema).default({})
})
.superRefine((value, ctx) => {
const total = countComponentsV2(value.components);
if (total > MAX_COMPONENTS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `At most ${MAX_COMPONENTS} components allowed (got ${total})`,
path: ['components']
});
}
validateNodeRules(value.components, ctx, ['components']);
const referenced = new Set<string>();
collectActionIds(value.components, referenced);
for (const actionId of referenced) {
if (!value.actions[actionId]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Missing action definition for "${actionId}"`,
path: ['actions', actionId]
});
}
}
});
export type MessageComponentsV2 = z.infer<typeof MessageComponentsV2Schema>;
export function componentsV2HasContent(
payload: MessageComponentsV2 | null | undefined
): boolean {
return Boolean(payload && payload.components.length > 0);
}
/**
* Applies a string renderer to every text/URL field in a Components V2 tree.
* Used by Welcome/Tags/Scheduler before handing data to discord.js.
*/
export function mapComponentsV2TextFields(
payload: MessageComponentsV2,
render: (value: string) => string
): MessageComponentsV2 {
const mapOptional = (value: string | undefined): string | undefined => {
if (!value?.trim()) {
return undefined;
}
return render(value);
};
const mapNode = (node: ComponentV2Node): ComponentV2Node => {
switch (node.type) {
case 'text_display':
return { ...node, content: render(node.content) };
case 'thumbnail':
return {
...node,
url: render(node.url),
description: mapOptional(node.description)
};
case 'media_gallery':
return {
...node,
items: node.items.map((item) => ({
...item,
url: render(item.url),
description: mapOptional(item.description)
}))
};
case 'section': {
const text = Array.isArray(node.text)
? node.text.map((part) => render(part))
: render(node.text);
const accessory =
node.accessory.type === 'thumbnail'
? {
...node.accessory,
url: render(node.accessory.url),
description: mapOptional(node.accessory.description)
}
: {
...node.accessory,
label: render(node.accessory.label),
url: mapOptional(node.accessory.url)
};
return { ...node, text, accessory };
}
case 'button':
return {
...node,
label: render(node.label),
url: mapOptional(node.url)
};
case 'string_select':
return {
...node,
placeholder: mapOptional(node.placeholder),
options: node.options.map((option) => ({
...option,
label: render(option.label),
description: mapOptional(option.description)
}))
};
case 'user_select':
case 'role_select':
case 'channel_select':
case 'mentionable_select':
return { ...node, placeholder: mapOptional(node.placeholder) };
case 'action_row':
return {
...node,
components: node.components.map((child) =>
mapNode(child as ComponentV2Node)
) as typeof node.components
};
case 'container':
return {
...node,
components: node.components.map(mapNode)
};
case 'separator':
default:
return node;
}
};
const actions: MessageComponentsV2['actions'] = {};
for (const [id, action] of Object.entries(payload.actions)) {
actions[id] = {
...action,
content: mapOptional(action.content)
};
}
return {
components: payload.components.map(mapNode),
actions
};
}
const CUSTOM_ID_PREFIX = 'cv2';
export function encodeComponentCustomId(
source: ComponentV2Source,
ref: string,
actionId: string
): string {
const customId = `${CUSTOM_ID_PREFIX}:${source}:${ref}:${actionId}`;
if (customId.length > 100) {
throw new Error(`custom_id exceeds 100 characters (${customId.length})`);
}
return customId;
}
export function decodeComponentCustomId(customId: string): {
source: ComponentV2Source;
ref: string;
actionId: string;
} | null {
if (!customId.startsWith(`${CUSTOM_ID_PREFIX}:`)) {
return null;
}
const parts = customId.split(':');
if (parts.length < 4) {
return null;
}
const source = ComponentV2SourceSchema.safeParse(parts[1]);
if (!source.success) {
return null;
}
const actionId = parts[parts.length - 1]!;
const ref = parts.slice(2, -1).join(':');
if (!ref || !actionId) {
return null;
}
return { source: source.data, ref, actionId };
}
export function isComponentV2CustomId(customId: string): boolean {
return decodeComponentCustomId(customId) !== null;
}
/** Empty payload helper for UI builders. */
export function emptyComponentsV2(): MessageComponentsV2 {
return {
components: [
{
type: 'container',
components: [{ type: 'text_display', content: 'Hello from Nexumi!' }]
}
],
actions: {}
};
}
export function parseMessageComponentsV2(raw: unknown): MessageComponentsV2 | null {
const parsed = MessageComponentsV2Schema.safeParse(raw);
return parsed.success ? parsed.data : null;
}

View File

@@ -1,5 +1,13 @@
import { z } from 'zod';
import { embedHasContent, WelcomeEmbedSchema } from './phase2.js';
import {
componentsV2HasContent,
MessageComponentsV2Schema
} from './components-v2.js';
import {
embedHasContent,
LeaveMessageTypeSchema,
WelcomeEmbedSchema
} from './phase2.js';
import {
SelfRoleBehaviorSchema,
SelfRoleEntrySchema,
@@ -188,7 +196,13 @@ export type LoggingConfigDashboardPatch = z.infer<typeof LoggingConfigDashboardP
// Welcome & Leave
// ---------------------------------------------------------------------------
export const WelcomeMessageTypeDashboardSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']);
export const WelcomeMessageTypeDashboardSchema = z.enum([
'TEXT',
'EMBED',
'IMAGE',
'COMPONENTS_V2'
]);
export const LeaveMessageTypeDashboardSchema = LeaveMessageTypeSchema;
export const WelcomeConfigDashboardSchema = z.object({
welcomeEnabled: z.boolean(),
@@ -198,8 +212,11 @@ export const WelcomeConfigDashboardSchema = z.object({
welcomeType: WelcomeMessageTypeDashboardSchema,
welcomeContent: z.string().max(2000).nullable().optional(),
welcomeEmbed: DashboardEmbedSchema.nullable().optional(),
welcomeComponents: MessageComponentsV2Schema.nullable().optional(),
leaveType: LeaveMessageTypeDashboardSchema.default('TEXT'),
leaveContent: z.string().max(2000).nullable().optional(),
leaveEmbed: DashboardEmbedSchema.nullable().optional(),
leaveComponents: MessageComponentsV2Schema.nullable().optional(),
welcomeDmEnabled: z.boolean(),
welcomeDmContent: z.string().max(2000).nullable().optional(),
userAutoroleIds: z.array(z.string()),
@@ -396,6 +413,7 @@ export const TagDashboardSchema = z.object({
name: z.string().min(1).max(50),
content: z.string().max(2000).nullable().optional(),
embed: DashboardEmbedSchema.nullable().optional(),
components: MessageComponentsV2Schema.nullable().optional(),
responseType: TagResponseTypeSchema,
triggerWord: z.string().max(100).nullable().optional(),
allowedRoleIds: z.array(z.string()),
@@ -403,13 +421,25 @@ export const TagDashboardSchema = z.object({
});
export type TagDashboard = z.infer<typeof TagDashboardSchema>;
export const TagDashboardCreateSchema = TagDashboardSchema.omit({ id: true }).refine(
(value) =>
value.responseType !== 'EMBED' ||
embedHasContent(value.embed) ||
value.embed?.color !== undefined,
{ message: 'Embed content is required for EMBED tags', path: ['embed'] }
);
export const TagDashboardCreateSchema = TagDashboardSchema.omit({ id: true })
.refine(
(value) =>
value.responseType !== 'EMBED' ||
embedHasContent(value.embed) ||
value.embed?.color !== undefined,
{ message: 'Embed content is required for EMBED tags', path: ['embed'] }
)
.refine(
(value) =>
value.responseType !== 'COMPONENTS_V2' || componentsV2HasContent(value.components),
{ message: 'Components are required for COMPONENTS_V2 tags', path: ['components'] }
)
.refine(
(value) =>
value.responseType !== 'COMPONENTS_V2' ||
(!embedHasContent(value.embed) && value.embed?.color === undefined),
{ message: 'Embed and Components V2 are mutually exclusive', path: ['embed'] }
);
export type TagDashboardCreate = z.infer<typeof TagDashboardCreateSchema>;
export const TagDashboardUpdateSchema = nonEmptyPatch(TagDashboardSchema.omit({ id: true }));
@@ -560,6 +590,7 @@ export const ScheduledMessageDashboardSchema = z.object({
creatorId: z.string(),
content: z.string().nullable(),
embed: DashboardEmbedSchema.nullable().optional(),
components: MessageComponentsV2Schema.nullable().optional(),
rolePingId: z.string().nullable(),
cron: z.string().nullable(),
runAt: z.string().nullable(),
@@ -573,6 +604,7 @@ export const ScheduledMessageDashboardCreateSchema = z
channelId: SnowflakeSchema,
content: z.string().max(2000).nullable().optional(),
embed: DashboardEmbedSchema.nullable().optional(),
components: MessageComponentsV2Schema.nullable().optional(),
rolePingId: OptionalSnowflakeSchema.optional(),
cron: z.string().min(1).max(100).nullable().optional(),
runAt: z
@@ -585,14 +617,69 @@ export const ScheduledMessageDashboardCreateSchema = z
message: 'Either cron or runAt is required'
})
.refine(
(value) =>
Boolean(value.content?.trim()) ||
embedHasContent(value.embed) ||
value.embed?.color !== undefined,
{ message: 'Content or embed is required' }
(value) => {
const hasComponents = componentsV2HasContent(value.components);
const hasEmbed = embedHasContent(value.embed) || value.embed?.color !== undefined;
if (hasComponents && hasEmbed) {
return false;
}
if (hasComponents) {
return true;
}
return Boolean(value.content?.trim()) || hasEmbed;
},
{ message: 'Content, embed, or Components V2 is required (embed and V2 are exclusive)' }
);
export type ScheduledMessageDashboardCreate = z.infer<typeof ScheduledMessageDashboardCreateSchema>;
// ---------------------------------------------------------------------------
// Dashboard message sender (Embed | Components V2)
// ---------------------------------------------------------------------------
export const DashboardMessageModeSchema = z.enum(['EMBED', 'COMPONENTS_V2']);
export type DashboardMessageMode = z.infer<typeof DashboardMessageModeSchema>;
export const DashboardMessageSendSchema = z
.object({
channelId: SnowflakeSchema,
mode: DashboardMessageModeSchema,
embed: DashboardEmbedSchema.nullable().optional(),
components: MessageComponentsV2Schema.nullable().optional()
})
.superRefine((value, ctx) => {
if (value.mode === 'EMBED') {
if (!embedHasContent(value.embed) && value.embed?.color === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Embed content is required',
path: ['embed']
});
}
if (componentsV2HasContent(value.components)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Components V2 not allowed in EMBED mode',
path: ['components']
});
}
} else if (!componentsV2HasContent(value.components)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Components are required for COMPONENTS_V2 mode',
path: ['components']
});
}
});
export type DashboardMessageSend = z.infer<typeof DashboardMessageSendSchema>;
export const DashboardMessageSendResultSchema = z.object({
bindingId: z.string().nullable(),
messageId: z.string().nullable(),
channelId: z.string(),
confirmed: z.boolean()
});
export type DashboardMessageSendResult = z.infer<typeof DashboardMessageSendResultSchema>;
// ---------------------------------------------------------------------------
// Guild Backups
// ---------------------------------------------------------------------------

View File

@@ -12,6 +12,7 @@ export * from './dashboard.js';
export * from './owner.js';
export * from './public.js';
export * from './premium.js';
export * from './components-v2.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;
@@ -575,13 +576,29 @@ const de: Dictionary = {
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
'scheduler.list.cron': 'Cron `{cron}`',
'scheduler.list.embedPreview': '[Embed]',
'scheduler.list.componentsPreview': '[Components V2]',
'scheduler.error.missing_schedule_time': 'Gib `when` oder `cron` an.',
'scheduler.error.invalid_when': 'Ungültige Zeit. Nutze z. B. 1h, 2d oder ein ISO-Datum.',
'scheduler.error.invalid_cron': 'Ungültiger Cron-Ausdruck (56 Felder).',
'scheduler.error.invalid_channel': 'Ungültiger Kanal.',
'scheduler.error.channel_unavailable': 'Der Bot kann in diesem Kanal nicht senden.',
'scheduler.error.not_found': 'Geplante Nachricht nicht gefunden.',
'scheduler.error.content_required': 'Inhalt oder Embed-Titel/Beschreibung erforderlich.'
'scheduler.error.content_required': 'Inhalt oder Embed-Titel/Beschreibung erforderlich.',
'componentsV2.error.notFound': 'Diese Komponenten-Nachricht ist nicht mehr verfügbar.',
'componentsV2.error.actionMissing': 'Für diese Interaktion ist keine Aktion konfiguriert.',
'componentsV2.error.botMissingManageRoles': 'Dem Bot fehlt die Berechtigung Rollen zu verwalten.',
'componentsV2.error.roleNotFound': 'Rolle nicht gefunden.',
'componentsV2.error.roleManaged': 'Integrierte Rollen können nicht vergeben werden.',
'componentsV2.error.roleHierarchy': 'Die Rolle liegt über der höchsten Bot-Rolle.',
'componentsV2.error.noRolesSelected': 'Keine Rollen ausgewählt.',
'componentsV2.role.assigned': 'Rolle vergeben.',
'componentsV2.role.removed': 'Rolle entfernt.',
'componentsV2.role.assignedSelected': 'Ausgewählte Rollen vergeben.',
'utility.embed.components.sent': 'Components-V2-Nachricht gesendet.',
'utility.embed.components.dashboardHint':
'Für den vollen Builder inkl. Interaktionen: {url}',
'utility.embed.components.missingText': 'Text ist erforderlich.',
'tag.error.components_required': 'Components-V2-Tags benötigen Komponenten.'
};
const en: Dictionary = {
'generic.noPermission': 'You do not have permission for this action.',
@@ -1111,13 +1128,29 @@ const en: Dictionary = {
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
'scheduler.list.cron': 'Cron `{cron}`',
'scheduler.list.embedPreview': '[Embed]',
'scheduler.list.componentsPreview': '[Components V2]',
'scheduler.error.missing_schedule_time': 'Provide `when` or `cron`.',
'scheduler.error.invalid_when': 'Invalid time. Use e.g. 1h, 2d, or an ISO date.',
'scheduler.error.invalid_cron': 'Invalid cron expression (56 fields).',
'scheduler.error.invalid_channel': 'Invalid channel.',
'scheduler.error.channel_unavailable': 'The bot cannot send messages in that channel.',
'scheduler.error.not_found': 'Scheduled message not found.',
'scheduler.error.content_required': 'Content or embed title/description is required.'
'scheduler.error.content_required': 'Content or embed title/description is required.',
'componentsV2.error.notFound': 'This component message is no longer available.',
'componentsV2.error.actionMissing': 'No action is configured for this interaction.',
'componentsV2.error.botMissingManageRoles': 'The bot lacks Manage Roles permission.',
'componentsV2.error.roleNotFound': 'Role not found.',
'componentsV2.error.roleManaged': 'Managed roles cannot be assigned.',
'componentsV2.error.roleHierarchy': 'The role is above the bot\'s highest role.',
'componentsV2.error.noRolesSelected': 'No roles selected.',
'componentsV2.role.assigned': 'Role assigned.',
'componentsV2.role.removed': 'Role removed.',
'componentsV2.role.assignedSelected': 'Selected roles assigned.',
'utility.embed.components.sent': 'Components V2 message sent.',
'utility.embed.components.dashboardHint':
'For the full builder including interactions: {url}',
'utility.embed.components.missingText': 'Text is required.',
'tag.error.components_required': 'Components V2 tags require components.'
};
const locales: Record<Locale, Dictionary> = { de, en };

View File

@@ -127,9 +127,12 @@ export const LogEventTypeSchema = z.enum([
]);
export type LogEventType = z.infer<typeof LogEventTypeSchema>;
export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']);
export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE', 'COMPONENTS_V2']);
export type WelcomeMessageType = z.infer<typeof WelcomeMessageTypeSchema>;
export const LeaveMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'COMPONENTS_V2']);
export type LeaveMessageType = z.infer<typeof LeaveMessageTypeSchema>;
export const WelcomeEmbedSchema = z.object({
title: z.string().max(256).optional(),
description: z.string().max(4096).optional(),

View File

@@ -26,7 +26,7 @@ export const SelfRoleEntrySchema = z.object({
});
export type SelfRoleEntry = z.infer<typeof SelfRoleEntrySchema>;
export const TagResponseTypeSchema = z.enum(['TEXT', 'EMBED']);
export const TagResponseTypeSchema = z.enum(['TEXT', 'EMBED', 'COMPONENTS_V2']);
export type TagResponseType = z.infer<typeof TagResponseTypeSchema>;
export const SuggestionStatusSchema = z.enum([

View File

@@ -22,7 +22,8 @@ export const DashboardModuleIdSchema = z.enum([
'stats',
'feeds',
'scheduler',
'guildbackup'
'guildbackup',
'messages'
]);
export type DashboardModuleId = z.infer<typeof DashboardModuleIdSchema>;
@@ -50,6 +51,7 @@ export const DASHBOARD_MODULES = [
{ id: 'tickets', group: 'community', href: 'tickets' },
{ id: 'selfroles', group: 'community', href: 'selfroles' },
{ id: 'tags', group: 'utility', href: 'tags' },
{ id: 'messages', group: 'utility', href: 'messages' },
{ id: 'starboard', group: 'community', href: 'starboard' },
{ id: 'suggestions', group: 'community', href: 'suggestions' },
{ id: 'birthdays', group: 'community', href: 'birthdays' },