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:
@@ -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'
|
||||
|
||||
147
packages/shared/src/components-v2.test.ts
Normal file
147
packages/shared/src/components-v2.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
562
packages/shared/src/components-v2.ts
Normal file
562
packages/shared/src/components-v2.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 (5–6 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 (5–6 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 };
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user