feat: implement sendComponentsV2Message for enhanced message handling with attachments
This commit is contained in:
31
.github/copilot-instructions.md
vendored
Normal file
31
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Custom instructions for GitHub Copilot
|
||||||
|
|
||||||
|
Whenever generating code, commit messages, or explanations related to Discord.js or the Discord API:
|
||||||
|
|
||||||
|
1. Always **consult and reference** the following sources for accurate and up-to-date information:
|
||||||
|
- [Discord.js Guide](https://discordjs.guide)
|
||||||
|
- [discord.js GitHub Repository](https://github.com/discordjs/discord.js)
|
||||||
|
- [Discord API Types GitHub Repository](https://github.com/discordjs/discord-api-types)
|
||||||
|
- [Discord Developer Documentation](https://discord.com/developers/docs/intro)
|
||||||
|
|
||||||
|
2. Always **prefer official documentation** over blogs, forums, or tutorials.
|
||||||
|
- If no clear answer is found in docs, **inspect the installed dependency in `node_modules`** to confirm if a method, property, or class truly exists.
|
||||||
|
|
||||||
|
3. When suggesting code, ensure it matches the **latest stable release of Discord.js and related packages (June 2025 or newer)**.
|
||||||
|
- If examples belong to an older version, adapt them to the most recent stable release.
|
||||||
|
- If uncertain, indicate that verification against the `node_modules` implementation is required.
|
||||||
|
|
||||||
|
4. When installing or suggesting npm packages related to Discord.js:
|
||||||
|
- Verify against their **GitHub repository** for recent updates, breaking changes, or migration notes.
|
||||||
|
- Always check if the changelog or releases page contains updates after **June 2025** and adjust suggestions accordingly.
|
||||||
|
|
||||||
|
5. Always provide **links to relevant official documentation** (Discord.js Guide, GitHub repos, or Discord Developer Docs).
|
||||||
|
- If the info is missing in docs, but exists in the actual installed package, explain that and provide the discovered usage.
|
||||||
|
|
||||||
|
6. **Error validation requirement:**
|
||||||
|
- After generating or modifying code, always remind the user to check for TypeScript errors by running:
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
(or equivalent `tsc` command).
|
||||||
|
- If type errors are found, propose fixes before finalizing the solution.
|
||||||
15
.github/prompts/discord-api-expert.prompt.md
vendored
Normal file
15
.github/prompts/discord-api-expert.prompt.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Prompt: Discord.js + Discord API Expert Mode
|
||||||
|
|
||||||
|
Whenever assisting with Discord.js or the Discord API:
|
||||||
|
|
||||||
|
- Always prioritize information from:
|
||||||
|
- https://discordjs.guide
|
||||||
|
- https://github.com/discordjs/discord.js
|
||||||
|
- https://github.com/discordjs/discord-api-types
|
||||||
|
- https://discord.com/developers/docs/intro
|
||||||
|
- If documentation is incomplete, check the installed dependency in `node_modules`.
|
||||||
|
- Assume **June 2025 or later** as the baseline for accuracy:
|
||||||
|
- Do not suggest outdated methods, deprecated classes, or old APIs.
|
||||||
|
- Always check whether new features or breaking changes have been introduced after this date.
|
||||||
|
- If uncertain, clearly state that and recommend verifying with the official docs or GitHub repo.
|
||||||
|
- Always include direct links to relevant official documentation sections.
|
||||||
14
.github/prompts/discord-helper.prompt.md
vendored
Normal file
14
.github/prompts/discord-helper.prompt.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Prompt: Discord.js Expert Mode (Post-June 2025)
|
||||||
|
|
||||||
|
Whenever assisting with Discord.js or related dependencies:
|
||||||
|
|
||||||
|
- Always check official resources:
|
||||||
|
- https://discordjs.guide
|
||||||
|
- https://github.com/discordjs/discord.js
|
||||||
|
- https://github.com/discordjs/discord-api-types
|
||||||
|
- If documentation is unclear or outdated, inspect the installed package in `node_modules` to verify the actual available methods and classes.
|
||||||
|
- Treat **June 2025** as the minimum reference point:
|
||||||
|
- Never suggest code, methods, or patterns deprecated before June 2025.
|
||||||
|
- Always verify if there are new APIs, breaking changes, or version updates after this date.
|
||||||
|
- Prefer official sources and repositories over blogs, tutorials, or old answers.
|
||||||
|
- Always mention if information may be outdated and link to the package’s GitHub or changelog for verification.
|
||||||
7
.github/prompts/ts-validation.prompt.md
vendored
Normal file
7
.github/prompts/ts-validation.prompt.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Prompt: TypeScript Validation Mode
|
||||||
|
|
||||||
|
Whenever Copilot generates or modifies TypeScript code:
|
||||||
|
|
||||||
|
- Immediately remind the user to validate changes by running:
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
14
discordjs-helper.prompt.md
Normal file
14
discordjs-helper.prompt.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Prompt: Discord.js Expert Mode (Post-June 2025)
|
||||||
|
|
||||||
|
Whenever assisting with Discord.js or related dependencies:
|
||||||
|
|
||||||
|
- Always check official resources:
|
||||||
|
- https://discordjs.guide
|
||||||
|
- https://github.com/discordjs/discord.js
|
||||||
|
- https://github.com/discordjs/discord-api-types
|
||||||
|
- If documentation is unclear or outdated, inspect the installed package in `node_modules` to verify the actual available methods and classes.
|
||||||
|
- Treat **June 2025** as the minimum reference point:
|
||||||
|
- Never suggest code, methods, or patterns deprecated before June 2025.
|
||||||
|
- Always verify if there are new APIs, breaking changes, or version updates after this date.
|
||||||
|
- Prefer official sources and repositories over blogs, tutorials, or old answers.
|
||||||
|
- Always mention if information may be outdated and link to the package’s GitHub or changelog for verification.
|
||||||
@@ -2,11 +2,92 @@ import logger from "../lib/logger";
|
|||||||
import { REST } from "discord.js";
|
import { REST } from "discord.js";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { Routes } from "discord-api-types/v10";
|
import { Routes } from "discord-api-types/v10";
|
||||||
|
import type {
|
||||||
|
APIMessageTopLevelComponent,
|
||||||
|
APIMessage
|
||||||
|
} from "discord-api-types/v10";
|
||||||
import { commands } from "../loaders/loader";
|
import { commands } from "../loaders/loader";
|
||||||
|
import type { RawFile } from "discord.js";
|
||||||
|
|
||||||
// Reutilizamos una instancia REST singleton
|
// Reutilizamos una instancia REST singleton
|
||||||
const rest = new REST({ version: '10' }).setToken(process.env.TOKEN ?? "");
|
const rest = new REST({ version: '10' }).setToken(process.env.TOKEN ?? "");
|
||||||
|
|
||||||
|
// Tipado minimal para enviar mensajes con Display Components (Components v2)
|
||||||
|
export interface ComponentV2Attachment {
|
||||||
|
name: string; // nombre de archivo, p.ej. image.png o SPOILER_image.png
|
||||||
|
data: Buffer | Uint8Array; // contenido del archivo
|
||||||
|
description?: string; // alt text
|
||||||
|
spoiler?: boolean; // si true, prefija el nombre con SPOILER_
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estructura de allowed_mentions según la doc oficial
|
||||||
|
// https://discord.com/developers/docs/resources/channel#allowed-mentions-object
|
||||||
|
export type AllowedMentions = {
|
||||||
|
parse?: ("roles" | "users" | "everyone")[];
|
||||||
|
roles?: string[];
|
||||||
|
users?: string[];
|
||||||
|
replied_user?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SendComponentsV2Options {
|
||||||
|
components: APIMessageTopLevelComponent[]; // top-level components (Container, Section, TextDisplay, etc.)
|
||||||
|
attachments?: ComponentV2Attachment[]; // adjuntos opcionales a referenciar con attachment://
|
||||||
|
allowed_mentions?: AllowedMentions; // respetar estructura oficial
|
||||||
|
nonce?: string | number;
|
||||||
|
replyToMessageId?: string; // si se establece, emula message.reply
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envía un mensaje a un canal usando Display Components (Components v2) con tipado y adjuntos opcionales.
|
||||||
|
* Para referenciar archivos dentro de los componentes, usa urls tipo attachment://<filename>.
|
||||||
|
*
|
||||||
|
* Docs:
|
||||||
|
* - Component Reference: https://discord.com/developers/docs/components/reference
|
||||||
|
* - MessageFlags.IsComponentsV2: https://discord.com/developers/docs/resources/message#message-object-message-flags
|
||||||
|
*/
|
||||||
|
export async function sendComponentsV2Message(
|
||||||
|
channelId: string,
|
||||||
|
options: SendComponentsV2Options
|
||||||
|
): Promise<APIMessage> {
|
||||||
|
const files: RawFile[] = [];
|
||||||
|
const attachmentsMeta = [] as Array<{ id: number; filename: string; description?: string | null }>;
|
||||||
|
|
||||||
|
if (options.attachments && options.attachments.length > 0) {
|
||||||
|
options.attachments.forEach((att, idx) => {
|
||||||
|
let filename = att.name?.trim() || `file-${idx}`;
|
||||||
|
if (att.spoiler && !filename.startsWith('SPOILER_')) {
|
||||||
|
filename = `SPOILER_${filename}`;
|
||||||
|
}
|
||||||
|
files.push({ name: filename, data: att.data });
|
||||||
|
attachmentsMeta.push({ id: idx, filename, description: att.description ?? null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: any = {
|
||||||
|
components: options.components,
|
||||||
|
flags: 32768, // MessageFlags.IsComponentsV2
|
||||||
|
};
|
||||||
|
|
||||||
|
if (attachmentsMeta.length) body.attachments = attachmentsMeta;
|
||||||
|
if (options.allowed_mentions) body.allowed_mentions = options.allowed_mentions;
|
||||||
|
if (options.nonce !== undefined) body.nonce = options.nonce;
|
||||||
|
if (options.replyToMessageId) {
|
||||||
|
body.message_reference = { message_id: options.replyToMessageId };
|
||||||
|
if (!body.allowed_mentions) body.allowed_mentions = {};
|
||||||
|
if (body.allowed_mentions.replied_user === undefined) body.allowed_mentions.replied_user = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await rest.post(Routes.channelMessages(channelId), {
|
||||||
|
body,
|
||||||
|
files: files.length ? files : undefined,
|
||||||
|
}) as APIMessage;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, '❌ Error enviando mensaje con Components v2');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function registeringCommands(): Promise<void> {
|
export async function registeringCommands(): Promise<void> {
|
||||||
const commandsToRegister: any[] = [];
|
const commandsToRegister: any[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { prisma } from "../../core/database/prisma";
|
import { prisma } from "../../core/database/prisma";
|
||||||
import { replaceVars } from "../../core/lib/vars";
|
import { replaceVars } from "../../core/lib/vars";
|
||||||
import logger from "../../core/lib/logger";
|
import logger from "../../core/lib/logger";
|
||||||
|
import { sendComponentsV2Message } from "../../core/api/discordAPI";
|
||||||
|
|
||||||
|
|
||||||
// Regex para detectar URLs válidas (corregido)
|
// Regex para detectar URLs válidas (corregido)
|
||||||
@@ -258,12 +259,14 @@ async function sendBlockConfigV2(message: Message, blockConfigName: string, guil
|
|||||||
// Convertir el JSON plano a la estructura de Display Components correcta
|
// Convertir el JSON plano a la estructura de Display Components correcta
|
||||||
const displayComponent = await convertConfigToDisplayComponent(processedConfig, message.author, message.guild!);
|
const displayComponent = await convertConfigToDisplayComponent(processedConfig, message.author, message.guild!);
|
||||||
|
|
||||||
// Enviar usando Display Components con la flag correcta
|
// Construir adjuntos desde la config si existen
|
||||||
// Usar la misma estructura que el editor: flag 32768 y type 17
|
const attachments = buildAttachmentsFromConfig(processedConfig);
|
||||||
//@ts-ignore
|
|
||||||
await message.reply({
|
// Enviar usando Display Components con la flag correcta a través del cliente REST tipado
|
||||||
flags: 32768, // Equivalente a MessageFlags.IsComponentsV2
|
await sendComponentsV2Message(message.channel.id, {
|
||||||
components: [displayComponent]
|
components: [displayComponent],
|
||||||
|
replyToMessageId: message.id,
|
||||||
|
attachments: attachments.length ? attachments : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -280,20 +283,59 @@ async function sendBlockConfigV2(message: Message, blockConfigName: string, guil
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: parsear emojis (unicode o personalizados <:name:id> / <a:name:id>)
|
// Extrae adjuntos desde la config (base64) para usar attachment://<filename>
|
||||||
function parseEmojiInput(input?: string): any | null {
|
function buildAttachmentsFromConfig(config: any) {
|
||||||
if (!input) return null;
|
const results: { name: string; data: Buffer; description?: string; spoiler?: boolean }[] = [];
|
||||||
const trimmed = input.trim();
|
if (!config || typeof config !== 'object') return results;
|
||||||
if (!trimmed) return null;
|
|
||||||
const match = trimmed.match(/^<(a?):(\w+):(\d+)>$/);
|
const arr = Array.isArray(config.attachments) ? config.attachments : [];
|
||||||
if (match) {
|
for (const item of arr) {
|
||||||
const animated = match[1] === 'a';
|
if (!item || typeof item !== 'object') continue;
|
||||||
const name = match[2];
|
const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null;
|
||||||
const id = match[3];
|
const description = typeof item.description === 'string' ? item.description : undefined;
|
||||||
return { id, name, animated };
|
const spoiler = Boolean(item.spoiler);
|
||||||
|
const raw = typeof item.dataBase64 === 'string' ? item.dataBase64 : (typeof item.data === 'string' ? item.data : null);
|
||||||
|
if (!name || !raw) continue;
|
||||||
|
const buf = decodeBase64Payload(raw);
|
||||||
|
if (!buf) continue;
|
||||||
|
results.push({ name, data: buf, description, spoiler });
|
||||||
}
|
}
|
||||||
// Asumimos unicode si no es formato de emoji personalizado
|
return results;
|
||||||
return { name: trimmed };
|
}
|
||||||
|
|
||||||
|
function decodeBase64Payload(raw: string): Buffer | null {
|
||||||
|
try {
|
||||||
|
let base64 = raw.trim();
|
||||||
|
// Soportar formatos: "base64:..." o data URLs "data:mime/type;base64,...."
|
||||||
|
if (base64.startsWith('base64:')) {
|
||||||
|
base64 = base64.slice('base64:'.length);
|
||||||
|
} else if (base64.startsWith('data:')) {
|
||||||
|
const comma = base64.indexOf(',');
|
||||||
|
if (comma !== -1) base64 = base64.slice(comma + 1);
|
||||||
|
}
|
||||||
|
return Buffer.from(base64, 'base64');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: URLs http/https únicamente
|
||||||
|
function isHttpUrl(url: unknown): url is string {
|
||||||
|
if (typeof url !== 'string' || !url) return false;
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: permitir http/https y attachment:// para medios (thumbnail/media/file)
|
||||||
|
function isMediaUrl(url: unknown): boolean {
|
||||||
|
if (typeof url !== 'string' || !url) return false;
|
||||||
|
if (isHttpUrl(url)) return true;
|
||||||
|
const s = url as string;
|
||||||
|
return s.startsWith('attachment://');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: construir accessory de Link Button para Display Components
|
// Helper: construir accessory de Link Button para Display Components
|
||||||
@@ -302,7 +344,8 @@ async function buildLinkAccessory(link: any, user: any, guild: any) {
|
|||||||
if (!link || !link.url) return null;
|
if (!link || !link.url) return null;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const processedUrl = await replaceVars(link.url, user, guild);
|
const processedUrl = await replaceVars(link.url, user, guild);
|
||||||
if (!isValidUrl(processedUrl)) return null;
|
// En botones de enlace solo se permite http/https
|
||||||
|
if (!isHttpUrl(processedUrl)) return null;
|
||||||
const accessory: any = { type: 2, style: 5, url: processedUrl };
|
const accessory: any = { type: 2, style: 5, url: processedUrl };
|
||||||
if (link.label && typeof link.label === 'string' && link.label.trim()) {
|
if (link.label && typeof link.label === 'string' && link.label.trim()) {
|
||||||
accessory.label = link.label.trim().slice(0, 80);
|
accessory.label = link.label.trim().slice(0, 80);
|
||||||
@@ -324,10 +367,10 @@ async function convertConfigToDisplayComponent(config: any, user: any, guild: an
|
|||||||
const previewComponents: any[] = [];
|
const previewComponents: any[] = [];
|
||||||
|
|
||||||
// Añadir imagen de portada primero si existe
|
// Añadir imagen de portada primero si existe
|
||||||
if (config.coverImage && isValidUrl(config.coverImage)) {
|
if (config.coverImage) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const processedCoverUrl = await replaceVars(config.coverImage, user, guild);
|
const processedCoverUrl = await replaceVars(config.coverImage, user, guild);
|
||||||
if (isValidUrl(processedCoverUrl)) {
|
if (isMediaUrl(processedCoverUrl)) {
|
||||||
previewComponents.push({ type: 12, items: [{ media: { url: processedCoverUrl } }] });
|
previewComponents.push({ type: 12, items: [{ media: { url: processedCoverUrl } }] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,7 +398,7 @@ async function convertConfigToDisplayComponent(config: any, user: any, guild: an
|
|||||||
if (c.linkButton) {
|
if (c.linkButton) {
|
||||||
accessory = await buildLinkAccessory(c.linkButton, user, guild);
|
accessory = await buildLinkAccessory(c.linkButton, user, guild);
|
||||||
}
|
}
|
||||||
if (!accessory && processedThumbnail && isValidUrl(processedThumbnail)) {
|
if (!accessory && processedThumbnail && isMediaUrl(processedThumbnail)) {
|
||||||
accessory = { type: 11, media: { url: processedThumbnail } };
|
accessory = { type: 11, media: { url: processedThumbnail } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,10 +410,10 @@ async function convertConfigToDisplayComponent(config: any, user: any, guild: an
|
|||||||
} else if (c.type === 14) {
|
} else if (c.type === 14) {
|
||||||
previewComponents.push({ type: 14, divider: c.divider ?? true, spacing: c.spacing ?? 1 });
|
previewComponents.push({ type: 14, divider: c.divider ?? true, spacing: c.spacing ?? 1 });
|
||||||
} else if (c.type === 12) {
|
} else if (c.type === 12) {
|
||||||
// Imagen - validar URL también
|
// Imagen - validar http/https o attachment://
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const processedImageUrl = await replaceVars(c.url, user, guild);
|
const processedImageUrl = await replaceVars(c.url, user, guild);
|
||||||
if (isValidUrl(processedImageUrl)) {
|
if (isMediaUrl(processedImageUrl)) {
|
||||||
previewComponents.push({ type: 12, items: [{ media: { url: processedImageUrl } }] });
|
previewComponents.push({ type: 12, items: [{ media: { url: processedImageUrl } }] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,17 +429,24 @@ async function convertConfigToDisplayComponent(config: any, user: any, guild: an
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Función helper para validar URLs
|
// Helper: parsear emojis (unicode o personalizados <:name:id> / <a:name:id>)
|
||||||
function isValidUrl(url: unknown): url is string {
|
function parseEmojiInput(input?: string): any | null {
|
||||||
if (typeof url !== 'string' || !url) return false;
|
if (!input) return null;
|
||||||
try {
|
const trimmed = input.trim();
|
||||||
new URL(url);
|
if (!trimmed) return null;
|
||||||
return url.startsWith('http://') || url.startsWith('https://');
|
const match = trimmed.match(/^<(a?):(\w+):(\d+)>$/);
|
||||||
} catch {
|
if (match) {
|
||||||
return false;
|
const animated = match[1] === 'a';
|
||||||
|
const name = match[2];
|
||||||
|
const id = match[3];
|
||||||
|
return { id, name, animated };
|
||||||
}
|
}
|
||||||
|
// Asumimos unicode si no es formato de emoji personalizado
|
||||||
|
return { name: trimmed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Función helper para validar URLs (http/https y attachment:// para medios)
|
||||||
|
|
||||||
async function processConfigVariables(config: any, user: any, guild: any, userStats?: any, inviteObject?: any): Promise<any> {
|
async function processConfigVariables(config: any, user: any, guild: any, userStats?: any, inviteObject?: any): Promise<any> {
|
||||||
if (typeof config === 'string') {
|
if (typeof config === 'string') {
|
||||||
// Usar la función unificada replaceVars con todos los parámetros
|
// Usar la función unificada replaceVars con todos los parámetros
|
||||||
|
|||||||
Reference in New Issue
Block a user