diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..40e7779 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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. diff --git a/.github/prompts/discord-api-expert.prompt.md b/.github/prompts/discord-api-expert.prompt.md new file mode 100644 index 0000000..a37a8c2 --- /dev/null +++ b/.github/prompts/discord-api-expert.prompt.md @@ -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. diff --git a/.github/prompts/discord-helper.prompt.md b/.github/prompts/discord-helper.prompt.md new file mode 100644 index 0000000..0c5115b --- /dev/null +++ b/.github/prompts/discord-helper.prompt.md @@ -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. diff --git a/.github/prompts/ts-validation.prompt.md b/.github/prompts/ts-validation.prompt.md new file mode 100644 index 0000000..564ff92 --- /dev/null +++ b/.github/prompts/ts-validation.prompt.md @@ -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 diff --git a/discordjs-helper.prompt.md b/discordjs-helper.prompt.md new file mode 100644 index 0000000..0c5115b --- /dev/null +++ b/discordjs-helper.prompt.md @@ -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. diff --git a/src/core/api/discordAPI.ts b/src/core/api/discordAPI.ts index 6e83b47..dabe72e 100644 --- a/src/core/api/discordAPI.ts +++ b/src/core/api/discordAPI.ts @@ -2,11 +2,92 @@ import logger from "../lib/logger"; import { REST } from "discord.js"; // @ts-ignore import { Routes } from "discord-api-types/v10"; +import type { + APIMessageTopLevelComponent, + APIMessage +} from "discord-api-types/v10"; import { commands } from "../loaders/loader"; +import type { RawFile } from "discord.js"; // Reutilizamos una instancia REST singleton 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://. + * + * 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 { + 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 { const commandsToRegister: any[] = []; diff --git a/src/events/extras/alliace.ts b/src/events/extras/alliace.ts index 1605fc2..60713ae 100644 --- a/src/events/extras/alliace.ts +++ b/src/events/extras/alliace.ts @@ -5,6 +5,7 @@ import { import { prisma } from "../../core/database/prisma"; import { replaceVars } from "../../core/lib/vars"; import logger from "../../core/lib/logger"; +import { sendComponentsV2Message } from "../../core/api/discordAPI"; // 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 const displayComponent = await convertConfigToDisplayComponent(processedConfig, message.author, message.guild!); - // Enviar usando Display Components con la flag correcta - // Usar la misma estructura que el editor: flag 32768 y type 17 - //@ts-ignore - await message.reply({ - flags: 32768, // Equivalente a MessageFlags.IsComponentsV2 - components: [displayComponent] + // Construir adjuntos desde la config si existen + const attachments = buildAttachmentsFromConfig(processedConfig); + + // Enviar usando Display Components con la flag correcta a través del cliente REST tipado + await sendComponentsV2Message(message.channel.id, { + components: [displayComponent], + replyToMessageId: message.id, + attachments: attachments.length ? attachments : undefined, }); } catch (error) { @@ -280,20 +283,59 @@ async function sendBlockConfigV2(message: Message, blockConfigName: string, guil } } -// Helper: parsear emojis (unicode o personalizados <:name:id> / ) -function parseEmojiInput(input?: string): any | null { - if (!input) return null; - const trimmed = input.trim(); - if (!trimmed) return null; - const match = trimmed.match(/^<(a?):(\w+):(\d+)>$/); - if (match) { - const animated = match[1] === 'a'; - const name = match[2]; - const id = match[3]; - return { id, name, animated }; +// Extrae adjuntos desde la config (base64) para usar attachment:// +function buildAttachmentsFromConfig(config: any) { + const results: { name: string; data: Buffer; description?: string; spoiler?: boolean }[] = []; + if (!config || typeof config !== 'object') return results; + + const arr = Array.isArray(config.attachments) ? config.attachments : []; + for (const item of arr) { + if (!item || typeof item !== 'object') continue; + const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null; + const description = typeof item.description === 'string' ? item.description : undefined; + 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 { name: trimmed }; + return results; +} + +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 @@ -302,7 +344,8 @@ async function buildLinkAccessory(link: any, user: any, guild: any) { if (!link || !link.url) return null; // @ts-ignore 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 }; if (link.label && typeof link.label === 'string' && link.label.trim()) { accessory.label = link.label.trim().slice(0, 80); @@ -324,10 +367,10 @@ async function convertConfigToDisplayComponent(config: any, user: any, guild: an const previewComponents: any[] = []; // Añadir imagen de portada primero si existe - if (config.coverImage && isValidUrl(config.coverImage)) { + if (config.coverImage) { // @ts-ignore const processedCoverUrl = await replaceVars(config.coverImage, user, guild); - if (isValidUrl(processedCoverUrl)) { + if (isMediaUrl(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) { accessory = await buildLinkAccessory(c.linkButton, user, guild); } - if (!accessory && processedThumbnail && isValidUrl(processedThumbnail)) { + if (!accessory && processedThumbnail && isMediaUrl(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) { previewComponents.push({ type: 14, divider: c.divider ?? true, spacing: c.spacing ?? 1 }); } else if (c.type === 12) { - // Imagen - validar URL también + // Imagen - validar http/https o attachment:// // @ts-ignore const processedImageUrl = await replaceVars(c.url, user, guild); - if (isValidUrl(processedImageUrl)) { + if (isMediaUrl(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 -function isValidUrl(url: unknown): url is string { - if (typeof url !== 'string' || !url) return false; - try { - new URL(url); - return url.startsWith('http://') || url.startsWith('https://'); - } catch { - return false; +// Helper: parsear emojis (unicode o personalizados <:name:id> / ) +function parseEmojiInput(input?: string): any | null { + if (!input) return null; + const trimmed = input.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^<(a?):(\w+):(\d+)>$/); + if (match) { + 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 { if (typeof config === 'string') { // Usar la función unificada replaceVars con todos los parámetros