Commit inicial con archivos existentes

This commit is contained in:
2026-01-17 16:14:00 -06:00
parent 48671dc88e
commit 4c48c279de
2539 changed files with 2412708 additions and 0 deletions

7
.env Executable file
View File

@@ -0,0 +1,7 @@
# Auto-generado desde variables de entorno
# NO editar manualmente - los cambios se perderán al reiniciar
APP_ENV=pruebas
APP_DEBUG=false
APP_ENVIRONMENT=pruebas

23
.env.example Executable file
View File

@@ -0,0 +1,23 @@
# Configuración de la aplicación
APP_ENV=development
APP_DEBUG=true
APP_URL=http://localhost
# Configuración de la base de datos
DB_HOST=localhost
DB_PORT=3306
DB_NAME=nombre_base_datos
DB_USER=usuario
DB_PASS=contraseña
DB_DIALECT=mysql
# Configuración de JWT
JWT_SECRET=clave_secreta_segura
JWT_ALGORITHM=HS256
JWT_EXPIRATION=3600
# Configuración de Discord
DISCORD_GUILD_ID=tu_guild_id
DISCORD_CLIENT_ID=tu_client_id
DISCORD_CLIENT_SECRET=tu_client_secret
DISCORD_BOT_TOKEN=tu_bot_token

49
.env.pruebas Executable file
View File

@@ -0,0 +1,49 @@
# Configuración de la aplicación
APP_ENV=production
APP_DEBUG=false
APP_URL=https://ponsprueba.ddns.net
# Configuración de la base de datos
DB_HOST=10.10.4.17
DB_PORT=3391
DB_NAME=bot2
DB_USER=nickpons666
DB_PASS=MiPo6425@@
DB_DIALECT=mysql
# Configuración de JWT
JWT_SECRET=19c5020fa8207d2c3b9e82f430784667e001f1eb733848922f7bcb9be98f93c2
JWT_ALGORITHM=HS256
JWT_EXPIRATION=3600
# Configuración de Discord
DISCORD_GUILD_ID=1338327171013541999
DISCORD_CLIENT_ID=1385790344594985061
DISCORD_CLIENT_SECRET=hK9SNiYdenHQVxakt8Mx3RoMkZ5oOJvk
DISCORD_BOT_TOKEN=MTM4NTc5MDM0NDU5NDk4NTA2MQ.GvobiS.TRQM9dX7vDjmuGVa3Ckp6YRtGEWxdW0gBDbvCI
# Configuración de Telegram
TELEGRAM_BOT_TOKEN=8469229183:AAEVIV5e7rjDXKNgFTX0dnCW6JWB88X4p2I
TELEGRAM_WEBHOOK_TOKEN=webhook_secure_token_12345
TEST_ENV_LOAD=caos_cargado
LIBRETRANSLATE_URL=http://10.10.4.17:5000
N8N_URL=https://n8n-dragon.ddns.net
N8N_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4MWY4YjU3YS0wMTg2LTQ1NTctOWZlMC1jYWUxNjZlYzZlMTkiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzU1OTMwODM5fQ.2tLbddyhMTKplp9n-qVNiAgQCUj2YEvVASwLnNjgCt0
# -----------------------------------------
# --- Configuración para la migración a n8n ---
# -----------------------------------------
# URL base de esta aplicación, para que n8n pueda llamarla.
APP_BASE_URL=https://ponsprueba.ddns.net
# Clave secreta para la comunicación segura entre n8n y api_handler.php.
# DEBE SER UNA CADENA LARGA Y ALEATORIA. Genera una con: openssl rand -hex 32
INTERNAL_API_KEY="b5dda33b8eb062e06e100c98a8947c0248b6e38973dfd689e81f725af238d23c"
# URL completa del webhook de n8n que procesa la cola de mensajes (process_queue_workflow).
# La obtienes del nodo Webhook en tu flujo de n8n.
N8N_PROCESS_QUEUE_WEBHOOK_URL="https://n8n-dragon.ddns.net/webhooktest/ia"
N8N_IA_WEBHOOK_URL="https://n8n-dragon.ddns.net/webhook/ia"
N8N_IA_WEBHOOK_URL_DISCORD="https://n8n-dragon.ddns.net/webhook/42e803ae-8aee-4b1c-858a-6c6d3fbb6230"

49
.env.reod Executable file
View File

@@ -0,0 +1,49 @@
# Configuración de la aplicación
APP_ENV=production
APP_DEBUG=false
APP_URL=https://reod-dragon.ddns.net
# Configuración de la base de datos
DB_HOST=10.10.4.17
DB_PORT=3390
DB_NAME=bot
DB_USER=nickpons666
DB_PASS=MiPo6425@@
DB_DIALECT=mysql
# Configuración de JWT
JWT_SECRET=19c5020fa8207d2c3b9e82f430784667e001f1eb733848922f7bcb9be98f93c2
JWT_ALGORITHM=HS256
JWT_EXPIRATION=3600
# Configuración de Discord
DISCORD_GUILD_ID=1338327171013541999
DISCORD_CLIENT_ID=1385790344594985061
DISCORD_CLIENT_SECRET=hK9SNiYdenHQVxakt8Mx3RoMkZ5oOJvk
DISCORD_BOT_TOKEN=MTM4NTc5MDM0NDU5NDk4NTA2MQ.GvobiS.TRQM9dX7vDjmuGVa3Ckp6YRtGEWxdW0gBDbvCI
# Configuración de Telegram
TELEGRAM_BOT_TOKEN=8469229183:AAEVIV5e7rjDXKNgFTX0dnCW6JWB88X4p2I
TELEGRAM_WEBHOOK_TOKEN=webhook_secure_token_12345
TEST_ENV_LOAD=caos_cargado
LIBRETRANSLATE_URL=http://10.10.4.17:5000
N8N_URL=https://n8n-dragon.ddns.net
N8N_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4MWY4YjU3YS0wMTg2LTQ1NTctOWZlMC1jYWUxNjZlYzZlMTkiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzU1OTMwODM5fQ.2tLbddyhMTKplp9n-qVNiAgQCUj2YEvVASwLnNjgCt0
# -----------------------------------------
# --- Configuración para la migración a n8n ---
# -----------------------------------------
# URL base de esta aplicación, para que n8n pueda llamarla.
APP_BASE_URL=https://reod-dragon.ddns.net
# Clave secreta para la comunicación segura entre n8n y api_handler.php.
# DEBE SER UNA CADENA LARGA Y ALEATORIA. Genera una con: openssl rand -hex 32
INTERNAL_API_KEY="b5dda33b8eb062e06e100c98a8947c0248b6e38973dfd689e81f725af238d23c"
# URL completa del webhook de n8n que procesa la cola de mensajes (process_queue_workflow).
# La obtienes del nodo Webhook en tu flujo de n8n.
N8N_PROCESS_QUEUE_WEBHOOK_URL="https://n8n-dragon.ddns.net/webhook/telegram-unified"
N8N_IA_WEBHOOK_URL="https://n8n-dragon.ddns.net/webhook/ia"
N8N_IA_WEBHOOK_URL_DISCORD="https://n8n-dragon.ddns.net/webhook/42e803ae-8aee-4b1c-858a-6c6d3fbb6230"

114
.htaccess Executable file
View File

@@ -0,0 +1,114 @@
# Configuración básica
Options -Indexes +FollowSymLinks -MultiViews
# Habilitar reescritura de URLs
<IfModule mod_rewrite.c>
RewriteEngine On
# Asegurar que el servidor siga los enlaces simbólicos
Options +FollowSymLinks
# Regla para el webhook de Telegram - debe ser lo primero
RewriteCond %{REQUEST_URI} ^/telegram_bot_webhook\.php [NC]
RewriteRule ^ - [L]
# Reglas para otros archivos de webhook
RewriteRule ^(telegram_webhook|test_webhook|set_webhook)\.php$ - [L,NC]
# Excluir archivos específicos de la verificación de sesión
RewriteCond %{REQUEST_URI} !^/login\.php [NC]
RewriteCond %{REQUEST_URI} !^/assets/ [NC]
RewriteCond %{REQUEST_URI} !^/translate_proxy\.php [NC]
RewriteCond %{REQUEST_URI} !\.(css|js|jpe?g|png|gif|ico|svg|woff2?|ttf|eot|json|txt|map)$ [NC]
# Para el resto de las rutas, redirigir a login.php si no hay sesión
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !^/translate_proxy\.php [NC]
RewriteRule ^(.*)$ /login.php [L,QSA]
</IfModule>
# Configuración de seguridad
<IfModule mod_headers.c>
# Protección básica de cabeceras
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff"
# Habilita la política de seguridad de contenido (CSP) - Ajusta según sea necesario
# Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;"
# Habilita HSTS (solo para HTTPS)
# Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Configuración de referrer policy
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# Eliminar cabeceras que revelan información
Header unset X-Powered-By
Header unset X-Pingback
Header unset Server
Header unset X-AspNet-Version
Header unset X-AspNetMvc-Version
</IfModule>
# Proteger archivos sensibles
<FilesMatch "^\.env$|composer\.(json|lock)|package(-lock)?\.json|.*\.(sql|log|bak|swp|swo|gitignore|gitattributes|htaccess|htpasswd|DS_Store)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# Deshabilitar la visualización de directorios
Options -Indexes
# Prevenir acceso a archivos ocultos
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# Configuración de caché para mejorar el rendimiento
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/pdf "access plus 1 month"
ExpiresByType text/x-javascript "access plus 1 month"
ExpiresByType application/x-shockwave-flash "access plus 1 month"
ExpiresByType image/x-icon "access plus 1 year"
ExpiresDefault "access plus 2 days"
</IfModule>
# Comprimir archivos para mejorar el rendimiento
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/plain
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType DEFLATE text/xml
AddOutputFilterByType DEFLATE text/css
AddOutputFilterByType DEFLATE application/xml
AddOutputFilterByType DEFLATE application/xhtml+xml
AddOutputFilterByType DEFLATE application/rss+xml
AddOutputFilterByType DEFLATE application/javascript
AddOutputFilterByType DEFLATE application/x-javascript
AddOutputFilterByType DEFLATE image/svg+xml
</IfModule>
# Configuración de PHP
<IfModule mod_php7.c>
php_flag display_errors off
php_value max_execution_time 30
php_value max_input_time 60
php_value max_input_vars 1000
php_value memory_limit 128M
php_value post_max_size 32M
php_value upload_max_filesize 32M
php_flag log_errors on
php_value error_log /var/log/php_errors.log
</IfModule>

84
admin/activity.php Executable file
View File

@@ -0,0 +1,84 @@
<?php
require_once __DIR__ . '/../includes/session_check.php';
require_once __DIR__ . '/../includes/db.php';
// Only admins can access this page
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header("Location: ../index.php");
exit;
}
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$limit = 25;
$offset = ($page - 1) * $limit;
// Get total number of records
$total_stmt = $pdo->query("SELECT COUNT(*) FROM activity_log");
$total_records = $total_stmt->fetchColumn();
$total_pages = ceil($total_records / $limit);
// Fetch activity logs
$stmt = $pdo->prepare("SELECT * FROM activity_log ORDER BY timestamp DESC LIMIT :limit OFFSET :offset");
$stmt->bindParam(':limit', $limit, PDO::PARAM_INT);
$stmt->bindParam(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
require_once __DIR__ . '/../templates/header.php';
?>
<div class="container-fluid">
<h1 class="mt-4">Registro de Actividad</h1>
<div class="card mb-4">
<div class="card-body">
<table class="table table-striped table-bordered">
<thead class="thead-dark">
<tr>
<th>Usuario</th>
<th>Acción</th>
<th>Detalles</th>
<th>Fecha y Hora</th>
</tr>
</thead>
<tbody>
<?php if (empty($logs)): ?>
<tr>
<td colspan="4" class="text-center">No hay registros de actividad.</td>
</tr>
<?php else: ?>
<?php foreach ($logs as $log): ?>
<tr>
<td><?php echo htmlspecialchars($log['username'] ?? 'N/A'); ?></td>
<td><?php echo htmlspecialchars($log['action']); ?></td>
<td><?php echo htmlspecialchars($log['details'] ?? 'N/A'); ?></td>
<td><?php echo htmlspecialchars(date('d/m/Y H:i:s', strtotime($log['timestamp']))); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
<?php if ($page > 1): ?>
<li class="page-item"><a class="page-link" href="?page=<?php echo $page - 1; ?>">Anterior</a></li>
<?php endif; ?>
<?php for ($i = 1; $i <= $total_pages; $i++): ?>
<li class="page-item <?php echo ($i == $page) ? 'active' : ''; ?>">
<a class="page-link" href="?page=<?php echo $i; ?>"><?php echo $i; ?></a>
</li>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<li class="page-item"><a class="page-link" href="?page=<?php echo $page + 1; ?>">Siguiente</a></li>
<?php endif; ?>
</ul>
</nav>
</div>
<?php require_once __DIR__ . '/../templates/footer.php'; ?>

54
admin/comandos.php Executable file
View File

@@ -0,0 +1,54 @@
<?php
require_once __DIR__ . '/../includes/session_check.php';
require_once __DIR__ . '/../includes/db.php';
// Fetch recurrent messages with commands
try {
$stmt = $pdo->query("SELECT name, telegram_command FROM recurrent_messages WHERE telegram_command IS NOT NULL AND telegram_command != '' ORDER BY name ASC");
$commands = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die("Error: No se pudieron cargar los comandos.");
}
require_once __DIR__ . '/../templates/header.php';
?>
<div class="container-fluid">
<h1 class="mt-4" data-translate="true">Lista de Comandos de Telegram</h1>
<p class="text-muted" data-translate="true">Esta es una lista de todos los comandos de plantilla que has configurado. Los usuarios pueden usar estos comandos en Telegram para recibir el mensaje correspondiente.</p>
<div class="card card-body p-4">
<?php if (empty($commands)): ?>
<div class="alert alert-info text-center" role="alert">
<i class="bi bi-info-circle-fill me-2"></i>
<span data-translate="true">No has configurado ningún comando de Telegram todavía.</span>
<br>
<span data-translate="true">Puedes asignar comandos a tus plantillas desde la página de</span>
<a href="recurrentes.php" class="alert-link" data-translate="true">Mensajes Recurrentes</a>.
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th scope="col" data-translate="true">Comando de Telegram</th>
<th scope="col" data-translate="true">Nombre de la Plantilla</th>
</tr>
</thead>
<tbody>
<?php foreach ($commands as $command): ?>
<tr>
<td>
<code class="fs-5">#<?php echo htmlspecialchars($command['telegram_command']); ?></code>
</td>
<td><?php echo htmlspecialchars($command['name']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?php require_once __DIR__ . '/../templates/footer.php'; ?>

45
admin/get_user_groups.php Executable file
View File

@@ -0,0 +1,45 @@
<?php
require_once __DIR__ . '/../includes/session_check.php';
require_once __DIR__ . '/../includes/db.php';
header('Content-Type: application/json');
// Admin-only access
if ($_SESSION['role'] !== 'admin') {
echo json_encode(['error' => 'Unauthorized']);
exit();
}
$recipientId = $_GET['recipient_id'] ?? null;
if (!$recipientId) {
echo json_encode(['error' => 'Recipient ID missing']);
exit();
}
try {
// Obtener el nombre del usuario
$stmt = $pdo->prepare("SELECT name FROM recipients WHERE id = ?");
$stmt->execute([$recipientId]);
$userName = $stmt->fetchColumn();
// Obtener los grupos a los que pertenece el usuario
$stmt = $pdo->prepare("
SELECT tgm.chat_id, r.name as group_name
FROM telegram_group_members tgm
JOIN recipients r ON tgm.chat_id = r.platform_id AND r.platform = 'telegram' AND r.type = 'channel'
WHERE tgm.recipient_id = ?
");
$stmt->execute([$recipientId]);
$groups = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'userName' => $userName, 'groups' => $groups]);
} catch (PDOException $e) {
error_log("Error fetching user groups: " . $e->getMessage());
echo json_encode(['error' => 'Database error']);
} catch (Exception $e) {
error_log("Error: " . $e->getMessage());
echo json_encode(['error' => 'Server error']);
}
?>

214
admin/languages.php Executable file
View File

@@ -0,0 +1,214 @@
<?php
require_once __DIR__ . '/../includes/session_check.php';
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/db.php';
// Solo para administradores
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: /login.php');
exit;
}
$pageTitle = 'Gestionar Idiomas de Traducción';
require_once __DIR__ . '/../templates/header.php';
try {
$stmt = $pdo->query("SELECT * FROM supported_languages ORDER BY language_name ASC");
$languages = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
$languages = [];
$errorMessage = "Error al cargar los idiomas: " . $e->getMessage();
}
?>
<div class="container-fluid">
<h1 class="mt-4">Gestionar Idiomas de Traducción</h1>
<p class="text-muted">Activa o desactiva los idiomas a los que el bot traducirá automáticamente los mensajes.</p>
<?php if (isset($errorMessage)): ?>
<div class="alert alert-danger"><?= htmlspecialchars($errorMessage) ?></div>
<?php endif; ?>
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Idiomas Soportados</h5>
<button id="sync-languages-btn" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-repeat me-1"></i> Sincronizar con LibreTranslate
</button>
</div>
<div class="card-body">
<div id="language-list" class="list-group">
<?php if (empty($languages)): ?>
<div class="list-group-item">No se encontraron idiomas.</div>
<?php else: ?>
<?php foreach ($languages as $lang): ?>
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="d-flex align-items-center">
<div class="flag-container me-2" style="min-width: 40px;">
<span class="fs-4 flag-emoji"><?= htmlspecialchars($lang['flag_emoji'] ?? '') ?></span>
<div class="edit-flag-form d-none">
<div class="input-group input-group-sm">
<input type="text" class="form-control flag-input" value="<?= htmlspecialchars($lang['flag_emoji'] ?? '') ?>" placeholder=" पेस्ट">
<button class="btn btn-success btn-sm save-flag-btn" data-lang-id="<?= $lang['id'] ?>">✓</button>
</div>
</div>
</div>
<i class="bi bi-pencil-square edit-flag-btn me-2" style="cursor: pointer;" data-lang-id="<?= $lang['id'] ?>"></i>
<div>
<span class="fw-bold"><?= htmlspecialchars($lang['language_name']) ?></span>
<small class="text-muted">(<?= htmlspecialchars($lang['language_code']) ?>)</small>
</div>
</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input language-toggle" type="checkbox" role="switch"
id="lang-toggle-<?= $lang['id'] ?>"
data-lang-id="<?= $lang['id'] ?>"
<?= $lang['is_active'] ? 'checked' : '' ?>>
<label class="form-check-label" for="lang-toggle-<?= $lang['id'] ?>"></label>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<div id="alert-container" class="position-fixed bottom-0 end-0 p-3" style="z-index: 11"></div>
</div>
<?php require_once __DIR__ . '/../templates/footer.php'; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
const languageToggles = document.querySelectorAll('.language-toggle');
const syncBtn = document.getElementById('sync-languages-btn');
if (syncBtn) {
syncBtn.addEventListener('click', function () {
const originalHtml = this.innerHTML;
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Sincronizando...';
fetch('sync_languages.php', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(`Sincronización completada. Se añadieron ${data.new_languages} nuevos idiomas.`, 'success');
setTimeout(() => window.location.reload(), 2000);
} else {
showAlert('Error en la sincronización: ' + data.error, 'danger');
this.disabled = false;
this.innerHTML = originalHtml;
}
})
.catch(error => {
showAlert('Error de red durante la sincronización.', 'danger');
this.disabled = false;
this.innerHTML = originalHtml;
});
});
}
languageToggles.forEach(toggle => {
toggle.addEventListener('change', function () {
const langId = this.dataset.langId;
const isActive = this.checked;
fetch('update_language_status.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ id: langId, is_active: isActive })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('Estado del idioma actualizado con éxito.', 'success');
} else {
showAlert('Error: ' + data.error, 'danger');
// Revertir el cambio visual si falla la actualización
this.checked = !isActive;
}
})
.catch(error => {
showAlert('Error de red al actualizar el idioma.', 'danger');
this.checked = !isActive;
});
});
});
// Lógica para editar la bandera
const languageList = document.getElementById('language-list');
if (languageList) {
languageList.addEventListener('click', function(e) {
// Botón de editar
if (e.target.classList.contains('edit-flag-btn')) {
const listItem = e.target.closest('.list-group-item');
listItem.querySelector('.flag-emoji').classList.add('d-none');
e.target.classList.add('d-none');
listItem.querySelector('.edit-flag-form').classList.remove('d-none');
listItem.querySelector('.flag-input').focus();
}
// Botón de guardar
if (e.target.classList.contains('save-flag-btn')) {
const langId = e.target.dataset.langId;
const listItem = e.target.closest('.list-group-item');
const input = listItem.querySelector('.flag-input');
const newEmoji = input.value;
fetch('update_language_flag.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ id: langId, flag_emoji: newEmoji })
})
.then(response => response.json())
.then(data => {
if (data.success) {
const flagEmojiSpan = listItem.querySelector('.flag-emoji');
flagEmojiSpan.textContent = newEmoji;
flagEmojiSpan.classList.remove('d-none');
listItem.querySelector('.edit-flag-form').classList.add('d-none');
listItem.querySelector('.edit-flag-btn').classList.remove('d-none');
showAlert('Bandera actualizada.', 'success');
} else {
showAlert('Error: ' + data.error, 'danger');
}
})
.catch(error => {
showAlert('Error de red al actualizar la bandera.', 'danger');
});
}
});
}
function showAlert(message, type = 'success') {
const alertContainer = document.getElementById('alert-container');
const alert = document.createElement('div');
alert.className = `alert alert-${type} alert-dismissible fade show`;
alert.role = 'alert';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
alertContainer.appendChild(alert);
setTimeout(() => {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}, 3000);
}
});
</script>

538
admin/recipients.php Executable file
View File

@@ -0,0 +1,538 @@
<?php
require_once __DIR__ . '/../includes/session_check.php';
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/activity_logger.php';
// Admin-only access
if ($_SESSION['role'] !== 'admin') {
header('Location: ../index.php?error=unauthorized');
exit();
}
// Initialize variables for edit mode
$edit_mode = false;
$edit_recipient = [
'id' => null,
'name' => '',
'platform_id' => '',
'type' => 'channel',
'platform' => 'discord',
'language_code' => 'es'
];
// Handle form submissions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$current_user_id = $_SESSION['user_id'];
$current_username = $_SESSION['username'];
$action = $_POST['action'] ?? null;
// Action: Add or Update Recipient
if (isset($_POST['add_recipient']) || isset($_POST['update_recipient'])) {
$platform = $_POST['platform'] ?? 'discord';
$type = $_POST['type'] ?? 'channel';
$name = $_POST['name'] ?? '';
$platform_id = $_POST['platform_id'] ?? '';
$language_code = $_POST['language_code'] ?? 'es';
$id = $_POST['id'] ?? null;
if (empty($name) || empty($platform_id) || empty($type) || empty($platform)) {
$error = "Todos los campos son obligatorios.";
} elseif (!is_numeric($platform_id)) {
$error = "El ID de Plataforma debe ser un número.";
} else {
try {
if (isset($_POST['update_recipient'])) { // UPDATE
$stmt = $pdo->prepare("UPDATE recipients SET name = ?, platform_id = ?, type = ?, platform = ?, language_code = ? WHERE id = ?");
$stmt->execute([$name, $platform_id, $type, $platform, $language_code, $id]);
$details = 'Admin ' . $current_username . ' updated recipient: ' . $name . ' (' . $platform . ':' . $platform_id . ')';
log_activity($current_user_id, 'Recipient Updated', $details);
header('Location: recipients.php?success=updated');
exit();
} else { // ADD
$stmt = $pdo->prepare("INSERT INTO recipients (name, platform_id, type, platform, language_code) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$name, $platform_id, $type, $platform, $language_code]);
$new_recipient_id = $pdo->lastInsertId();
$details = 'Admin ' . $current_username . ' added new recipient: ' . $name . ' (' . $platform . ':' . $platform_id . ')';
log_activity($current_user_id, 'Recipient Added', $details);
$success = "Destinatario añadido con éxito.";
}
} catch (PDOException $e) {
if ($e->errorInfo[1] == 1062) {
$error = "El ID de Plataforma ('$platform_id') ya existe.";
} else {
$error = "Error en la base de datos: " . $e->getMessage();
}
// Keep form data on error
$edit_mode = isset($_POST['update_recipient']);
$edit_recipient = ['id' => $id, 'name' => $name, 'platform_id' => $platform_id, 'type' => $type, 'platform' => $platform, 'language_code' => $language_code];
}
}
}
// Action: Delete Single Recipient
elseif (isset($_POST['delete_recipient'])) {
$id_to_delete = $_POST['id_to_delete'];
try {
$stmt_recipient = $pdo->prepare("SELECT name, platform, platform_id FROM recipients WHERE id = ?");
$stmt_recipient->execute([$id_to_delete]);
$recipient_info = $stmt_recipient->fetch(PDO::FETCH_ASSOC);
$details = 'Admin ' . $current_username . ' deleted recipient: ' . ($recipient_info['name'] ?? 'Unknown') . ' (' . ($recipient_info['platform'] ?? 'N/A') . ':' . ($recipient_info['platform_id'] ?? 'N/A') . ')';
$stmt = $pdo->prepare("DELETE FROM recipients WHERE id = ?");
$stmt->execute([$id_to_delete]);
log_activity($current_user_id, 'Recipient Deleted', $details);
$success = "Destinatario eliminado con éxito.";
} catch (PDOException $e) {
$error = "Error al eliminar. Es posible que el destinatario esté en uso.";
}
}
// Action: Kick Telegram User
elseif ($action === 'kick_telegram_user') {
$recipient_id_to_kick = $_POST['recipient_id_to_kick'] ?? null;
$chat_id_to_kick_from = $_POST['chat_id_to_kick_from'] ?? null;
if ($recipient_id_to_kick && $chat_id_to_kick_from) {
try {
// Get recipient's platform_id (Telegram user ID)
$stmt = $pdo->prepare("SELECT platform_id, name FROM recipients WHERE id = ? AND platform = 'telegram' AND type = 'user'");
$stmt->execute([$recipient_id_to_kick]);
$recipient_info = $stmt->fetch(PDO::FETCH_ASSOC);
$telegram_user_id = $recipient_info['platform_id'] ?? null;
$recipient_name = $recipient_info['name'] ?? 'Unknown';
if ($telegram_user_id) {
// Get bot token
$botToken = $_ENV['TELEGRAM_BOT_TOKEN'] ?? '';
if (empty($botToken)) {
throw new Exception("Token de bot de Telegram no configurado.");
}
// Telegram API URL for banning a chat member
$telegramApiUrl = "https://api.telegram.org/bot{$botToken}/banChatMember";
$params = [
'chat_id' => $chat_id_to_kick_from,
'user_id' => $telegram_user_id,
'until_date' => time() + 30 // Ban for 30 seconds to ensure they are removed, then they can rejoin
];
$options = [
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($params),
],
];
$context = stream_context_create($options);
$result = file_get_contents($telegramApiUrl, false, $context);
$response = json_decode($result, true);
if ($response && $response['ok']) {
// If successful, unban immediately to allow re-entry
$unbanTelegramApiUrl = "https://api.telegram.org/bot{$botToken}/unbanChatMember";
$unbanParams = [
'chat_id' => $chat_id_to_kick_from,
'user_id' => $telegram_user_id,
'only_if_banned' => true // Only try to unban if they are actually banned
];
$unbanOptions = [
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($unbanParams),
],
];
$unbanContext = stream_context_create($unbanOptions);
$unbanResult = file_get_contents($unbanTelegramApiUrl, false, $unbanContext);
$unbanResponse = json_decode($unbanResult, true);
if ($unbanResponse && $unbanResponse['ok']) {
$success = "Usuario de Telegram expulsado del grupo (permite reingreso) y eliminado de la base de datos.";
} else {
// Log unban error but still proceed with local deletion as kick was successful
error_log("Error al desbanear usuario de Telegram después de la expulsión: " . ($unbanResponse['description'] ?? 'Error desconocido'));
$success = "Usuario de Telegram expulsado del grupo (error al permitir reingreso) y eliminado de la base de datos.";
}
// Delete from local DB regardless of unban success, as the kick itself was successful
$stmt = $pdo->prepare("DELETE FROM recipients WHERE id = ?");
$stmt->execute([$recipient_id_to_kick]);
$details = 'Admin ' . $current_username . ' kicked Telegram user: ' . $recipient_name . ' (ID: ' . $telegram_user_id . ') from group ID: ' . $chat_id_to_kick_from;
log_activity($current_user_id, 'Telegram User Kicked', $details);
} else {
$error = "Error al expulsar usuario de Telegram: " . ($response['description'] ?? 'Error desconocido');
}
} else {
$error = "Usuario de Telegram no encontrado o no es un usuario válido para expulsar.";
}
} catch (Exception $e) {
$error = "Error al procesar la expulsión: " . $e->getMessage();
}
} else {
$error = "Faltan parámetros para expulsar al usuario de Telegram.";
}
}
// Action: Delete Multiple Recipients
elseif ($action === 'delete_selected' && !empty($_POST['selected_recipients'])) {
$deleted_count = 0;
$error_count = 0;
foreach ($_POST['selected_recipients'] as $recipient_id) {
try {
$stmt_recipient = $pdo->prepare("SELECT name, platform, platform_id FROM recipients WHERE id = ?");
$stmt_recipient->execute([$recipient_id]);
$recipient_info = $stmt_recipient->fetch(PDO::FETCH_ASSOC);
$details = 'Admin ' . $current_username . ' deleted recipient: ' . ($recipient_info['name'] ?? 'Unknown') . ' (' . ($recipient_info['platform'] ?? 'N/A') . ':' . ($recipient_info['platform_id'] ?? 'N/A') . ')';
$stmt = $pdo->prepare("DELETE FROM recipients WHERE id = ?");
$stmt->execute([$recipient_id]);
log_activity($current_user_id, 'Recipient Deleted', $details);
$deleted_count++;
} catch (PDOException $e) {
$error_count++;
error_log("Error al eliminar destinatario ID $recipient_id: " . $e->getMessage());
}
}
if ($deleted_count > 0) {
$success = "Se eliminaron $deleted_count destinatarios correctamente.";
if ($error_count > 0) {
$error = "Hubo errores al eliminar $error_count destinatarios.";
}
header('Location: recipients.php?success=deleted_multiple&deleted=' . $deleted_count . '&errors=' . $error_count);
exit();
} else if ($error_count > 0) {
$error = "No se pudo eliminar ningún destinatario. Por favor, inténtalo de nuevo.";
}
}
}
// Handle entering edit mode via GET request
if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
$edit_mode = true;
$stmt = $pdo->prepare("SELECT * FROM recipients WHERE id = ?");
$stmt->execute([$_GET['id']]);
$recipient_to_edit = $stmt->fetch();
if ($recipient_to_edit) {
$edit_recipient = $recipient_to_edit;
}
}
// Fetch all recipients to display
$recipients = $pdo->query("SELECT * FROM recipients ORDER BY platform, type, name ASC")->fetchAll();
$pageTitle = 'Gestionar Destinatarios';
require_once __DIR__ . '/../templates/header.php';
// Display feedback messages
if (isset($error)) echo "<div class='alert alert-danger'>$error</div>";
if (isset($success)) echo "<div class='alert alert-success'>$success</div>";
if (isset($_GET['success'])) {
if ($_GET['success'] === 'deleted_multiple') {
$deleted = isset($_GET['deleted']) ? (int)$_GET['deleted'] : 0;
$errors = isset($_GET['errors']) ? (int)$_GET['errors'] : 0;
if ($deleted > 0) {
echo '<div class="alert alert-success">Se eliminaron ' . $deleted . ' destinatarios correctamente.</div>';
}
if ($errors > 0) {
echo '<div class="alert alert-danger">Hubo errores al eliminar ' . $errors . ' destinatarios.</div>';
}
} else {
echo "<div class='alert alert-success'>Operación completada con éxito.</div>";
}
}
?>
<div class="container-fluid">
<h1 class="mt-4" data-translate="true">Gestionar Destinatarios</h1>
<!-- Add/Edit Recipient Form -->
<div class="card shadow-sm mb-4">
<div class="card-header">
<h5 class="mb-0" data-translate="true"><?= $edit_mode ? 'Editar Destinatario' : 'Añadir Nuevo Destinatario' ?></h5>
</div>
<div class="card-body">
<form action="recipients.php" method="POST">
<?php if ($edit_mode): ?><input type="hidden" name="id" value="<?= $edit_recipient['id'] ?>"><?php endif; ?>
<div class="row align-items-end">
<div class="col-md-2 mb-3">
<label for="platform" class="form-label" data-translate="true">Plataforma</label>
<select class="form-select" id="platform" name="platform" required>
<option value="discord" <?= ($edit_recipient['platform'] === 'discord') ? 'selected' : '' ?> data-translate="true">Discord</option>
<option value="telegram" <?= ($edit_recipient['platform'] === 'telegram') ? 'selected' : '' ?> data-translate="true">Telegram</option>
</select>
</div>
<div class="col-md-3 mb-3">
<label for="name" class="form-label" data-translate="true">Nombre (identificador)</label>
<input type="text" class="form-control" id="name" name="name" value="<?= htmlspecialchars($edit_recipient['name']) ?>" required>
</div>
<div class="col-md-2 mb-3">
<label for="platform_id" class="form-label" data-translate="true">ID de Plataforma</label>
<input type="text" class="form-control" id="platform_id" name="platform_id" value="<?= htmlspecialchars($edit_recipient['platform_id']) ?>" required>
</div>
<div class="col-md-2 mb-3">
<label for="type" class="form-label" data-translate="true">Tipo</label>
<select class="form-select" id="type" name="type" required>
<option value="channel" <?= ($edit_recipient['type'] === 'channel') ? 'selected' : '' ?> data-translate="true">Canal/Grupo</option>
<option value="user" <?= ($edit_recipient['type'] === 'user') ? 'selected' : '' ?> data-translate="true">Usuario</option>
</select>
</div>
<div class="col-md-1 mb-3">
<label for="language_code" class="form-label" data-translate="true">Idioma</label>
<select class="form-select" id="language_code" name="language_code" required>
<option value="es" <?= ($edit_recipient['language_code'] === 'es') ? 'selected' : '' ?>>ES</option>
<option value="en" <?= ($edit_recipient['language_code'] === 'en') ? 'selected' : '' ?>>EN</option>
<option value="fr" <?= ($edit_recipient['language_code'] === 'fr') ? 'selected' : '' ?>>FR</option>
<option value="de" <?= ($edit_recipient['language_code'] === 'de') ? 'selected' : '' ?>>DE</option>
<option value="it" <?= ($edit_recipient['language_code'] === 'it') ? 'selected' : '' ?>>IT</option>
<option value="pt" <?= ($edit_recipient['language_code'] === 'pt') ? 'selected' : '' ?>>PT</option>
<option value="ru" <?= ($edit_recipient['language_code'] === 'ru') ? 'selected' : '' ?>>RU</option>
<option value="ja" <?= ($edit_recipient['language_code'] === 'ja') ? 'selected' : '' ?>>JA</option>
<option value="zh" <?= ($edit_recipient['language_code'] === 'zh') ? 'selected' : '' ?>>ZH</option>
</select>
</div>
<div class="col-md-2 mb-3">
<?php if ($edit_mode): ?>
<button type="submit" name="update_recipient" class="btn btn-success w-100">
<i class="bi bi-save"></i> <span data-translate="true">Guardar</span>
</button>
<?php else: ?>
<button type="submit" name="add_recipient" class="btn btn-primary w-100">
<i class="bi bi-plus-circle"></i> <span data-translate="true">Añadir</span>
</button>
<?php endif; ?>
</div>
<?php if ($edit_mode): ?>
<div class="col-md-12"><a href="recipients.php" class="btn btn-sm btn-outline-secondary" data-translate="true">Cancelar Edición</a></div>
<?php endif; ?>
</div>
</form>
</div>
</div>
<!-- Recipients List -->
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" data-translate="true">Lista de Destinatarios</h5>
<div>
<button type="button" id="selectAllBtn" class="btn btn-sm btn-outline-secondary me-2">
<i class="bi bi-check2-square"></i> <span data-translate="true">Seleccionar todo</span>
</button>
<button type="button" id="deleteSelectedBtn" class="btn btn-sm btn-danger" disabled>
<i class="bi bi-trash"></i> <span data-translate="true">Eliminar seleccionados</span>
</button>
</div>
</div>
<div class="card-body">
<form id="recipientsForm" action="" method="POST" onsubmit="return confirm('¿Estás seguro de que quieres eliminar los destinatarios seleccionados?');">
<input type="hidden" name="action" value="delete_selected">
<input type="hidden" name="confirm_message" value="¿Estás seguro de que quieres eliminar los destinatarios seleccionados?" data-translate-confirm="¿Estás seguro de que quieres eliminar los destinatarios seleccionados?">
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th width="40"><input type="checkbox" id="selectAll"></th>
<th data-translate="true">Plataforma</th>
<th data-translate="true">Nombre</th>
<th data-translate="true">ID de Plataforma</th>
<th data-translate="true">Tipo</th>
<th data-translate="true">Idioma</th>
<th data-translate="true">Añadido en</th>
<th class="text-center" data-translate="true">Acciones</th>
</tr>
</thead>
<tbody>
<?php foreach ($recipients as $recipient): ?>
<tr>
<td><input type="checkbox" name="selected_recipients[]" value="<?= $recipient['id'] ?>" class="recipient-checkbox"></td>
<td>
<span class="badge <?= $recipient['platform'] === 'discord' ? 'bg-primary' : 'bg-info' ?>">
<?= htmlspecialchars(ucfirst($recipient['platform'])) ?>
</span>
</td>
<td><?= htmlspecialchars($recipient['name']) ?></td>
<td><?= htmlspecialchars($recipient['platform_id']) ?></td>
<td><?= htmlspecialchars(ucfirst($recipient['type'])) ?></td>
<td><span class="badge bg-secondary"><?= strtoupper(htmlspecialchars($recipient['language_code'] ?? 'es')) ?></span></td>
<td><?= date('d/m/Y H:i', strtotime($recipient['created_at'])) ?></td>
<td class="text-center">
<div class="btn-group" role="group">
<a href="?action=edit&id=<?= $recipient['id'] ?>" class="btn btn-sm btn-primary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
<form action="recipients.php" method="POST" onsubmit="return confirm(this.querySelector('[data-translate-confirm]').getAttribute('data-translate-confirm'));" class="d-inline ms-1">
<input type="hidden" name="id_to_delete" value="<?= $recipient['id'] ?>">
<input type="hidden" name="confirm_message" value="¿Estás seguro de que quieres eliminar este destinatario?" data-translate-confirm="¿Estás seguro de que quieres eliminar este destinatario?">
<button type="submit" name="delete_recipient" class="btn btn-sm btn-danger" title="Eliminar" data-translate-title="true">
<i class="bi bi-trash"></i>
</button>
</form>
<?php if ($recipient['platform'] === 'telegram' && $recipient['type'] === 'user'): ?>
<button type="button" class="btn btn-sm btn-warning ms-1 kick-telegram-user-btn"
data-bs-toggle="modal" data-bs-target="#kickUserModal"
data-recipient-id="<?= $recipient['id'] ?>"
title="Expulsar de Telegram y Eliminar" data-translate-title="true">
<i class="bi bi-person-x"></i>
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</form>
</div>
</div>
</div>
<?php require_once __DIR__ . '/../templates/footer.php'; ?>
<!-- Modal para Expulsar Usuario de Telegram -->
<div class="modal fade" id="kickUserModal" tabindex="-1" aria-labelledby="kickUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="kickUserModalLabel">Expulsar Usuario de Telegram</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="kickUserForm" action="recipients.php" method="POST">
<div class="modal-body">
<input type="hidden" name="action" value="kick_telegram_user">
<input type="hidden" name="recipient_id_to_kick" id="modalRecipientId">
<p>Selecciona el grupo del que deseas expulsar a <strong id="modalUserName"></strong>:</p>
<div id="groupListContainer">
<!-- Los grupos se cargarán aquí dinámicamente -->
<p class="text-muted">Cargando grupos...</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-danger" id="confirmKickBtn" disabled>Expulsar y Eliminar</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Selección múltiple
const selectAllCheckbox = document.getElementById('selectAll');
const recipientCheckboxes = document.querySelectorAll('.recipient-checkbox');
const deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
const selectAllBtn = document.getElementById('selectAllBtn');
// Seleccionar/deseleccionar todos los checkboxes
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
recipientCheckboxes.forEach(checkbox => {
checkbox.checked = isChecked;
});
updateDeleteButton();
});
}
// Botón "Seleccionar todo"
if (selectAllBtn) {
selectAllBtn.addEventListener('click', function() {
const allChecked = Array.from(recipientCheckboxes).every(checkbox => checkbox.checked);
recipientCheckboxes.forEach(checkbox => {
checkbox.checked = !allChecked;
});
selectAllCheckbox.checked = !allChecked;
updateDeleteButton();
});
}
// Actualizar el estado del botón de eliminar seleccionados
function updateDeleteButton() {
const checkedBoxes = document.querySelectorAll('.recipient-checkbox:checked');
deleteSelectedBtn.disabled = checkedBoxes.length === 0;
}
// Actualizar el checkbox "Seleccionar todo" cuando se marcan/desmarcan checkboxes individuales
recipientCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateDeleteButton();
selectAllCheckbox.checked = Array.from(recipientCheckboxes).every(checkbox => checkbox.checked);
});
});
// Manejar el clic en el botón de eliminar seleccionados
if (deleteSelectedBtn) {
deleteSelectedBtn.addEventListener('click', function() {
if (confirm('¿Estás seguro de que quieres eliminar los destinatarios seleccionados?')) {
document.getElementById('recipientsForm').submit();
}
});
}
// Lógica para el modal de expulsar usuario de Telegram
const kickUserModal = document.getElementById('kickUserModal');
const modalRecipientId = document.getElementById('modalRecipientId');
const modalUserName = document.getElementById('modalUserName');
const groupListContainer = document.getElementById('groupListContainer');
const confirmKickBtn = document.getElementById('confirmKickBtn');
const kickUserForm = document.getElementById('kickUserForm');
kickUserModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget; // Botón que activó el modal
const recipientId = button.getAttribute('data-recipient-id');
modalRecipientId.value = recipientId;
modalUserName.textContent = 'Cargando...';
groupListContainer.innerHTML = '<p class="text-muted">Cargando grupos...</p>';
confirmKickBtn.disabled = true;
// Cargar los grupos del usuario vía AJAX
fetch(`get_user_groups.php?recipient_id=${recipientId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
modalUserName.textContent = data.userName;
if (data.groups.length > 0) {
let groupsHtml = '';
data.groups.forEach(group => {
groupsHtml += `
<div class="form-check">
<input class="form-check-input" type="radio" name="chat_id_to_kick_from" id="group_${group.chat_id}" value="${group.chat_id}">
<label class="form-check-label" for="group_${group.chat_id}">
${group.group_name} (ID: ${group.chat_id})
</label>
</div>
`;
});
groupListContainer.innerHTML = groupsHtml;
// Habilitar el botón de confirmar cuando se selecciona un radio
groupListContainer.querySelectorAll('input[type="radio"]').forEach(radio => {
radio.addEventListener('change', () => {
confirmKickBtn.disabled = false;
});
});
} else {
groupListContainer.innerHTML = '<p class="text-info">Este usuario no está registrado en ningún grupo de Telegram.</p>';
}
} else {
groupListContainer.innerHTML = `<p class="text-danger">Error al cargar grupos: ${data.error}</p>`;
}
})
.catch(error => {
console.error('Error fetching groups:', error);
groupListContainer.innerHTML = '<p class="text-danger">Error de red al cargar grupos.</p>';
});
});
// Confirmación final antes de enviar el formulario de expulsión
kickUserForm.addEventListener('submit', function(event) {
if (!confirm('¿Estás seguro de que quieres expulsar a este usuario del grupo seleccionado y eliminarlo de la base de datos?')) {
event.preventDefault();
}
});
});
</script>

65
admin/set_test_webhook.php Executable file
View File

@@ -0,0 +1,65 @@
<?php
// Cargar configuración del entorno de pruebas
$envFile = __DIR__ . '/.env.pruebas';
if (!file_exists($envFile)) {
die("Error: No se encontró el archivo de configuración de pruebas (.env.pruebas)\n");
}
// Cargar variables de entorno
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) continue;
list($name, $value) = explode('=', $line, 2);
$name = trim($name);
$value = trim($value, "'\" \t\n\r\0\x0B");
if (!empty($name)) $_ENV[$name] = $value;
}
// Configuración
$botToken = $_ENV['TELEGRAM_BOT_TOKEN'] ?? '';
$webhookToken = $_ENV['TELEGRAM_WEBHOOK_TOKEN'] ?? 'webhook_secure_token_12345';
$webhookUrl = 'https://pruebaspons.duckdns.org/telegram_bot_webhook.php?auth_token=' . urlencode($webhookToken);
if (empty($botToken)) die("Error: No se configuró TELEGRAM_BOT_TOKEN\n");
echo "Configurando webhook para pruebas...\nURL: $webhookUrl\n";
// Configurar webhook
$apiUrl = "https://api.telegram.org/bot{$botToken}/setWebhook";
$postData = ['url' => $webhookUrl, 'allowed_updates' => json_encode(['message', 'callback_query'])];
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
// Mostrar resultados
echo "\nRespuesta de la API de Telegram (HTTP $httpCode):\n";
if ($error) {
echo "Error: $error\n";
} else {
$result = json_decode($response, true);
if (json_last_error() === JSON_ERROR_NONE) {
if ($result['ok'] ?? false) {
echo "✅ Webhook configurado correctamente.\n";
echo "URL: " . ($result['result']['url'] ?? 'N/A') . "\n";
echo "Tiene certificado: " . ($result['result']['has_custom_certificate'] ? 'Sí' : 'No') . "\n";
echo "Updates pendientes: " . ($result['result']['pending_update_count'] ?? '0') . "\n";
} else {
echo "❌ Error al configurar el webhook: " . ($result['description'] ?? 'Error desconocido') . "\n";
}
} else {
echo "Respuesta no válida: " . substr($response, 0, 200) . "...\n";
}
}
echo "\nPara verificar la configuración actual del webhook, ejecuta:\n";
echo "curl -s 'https://api.telegram.org/bot{$botToken}/getWebhookInfo' | jq\n\n";

66
admin/sync_languages.php Executable file
View File

@@ -0,0 +1,66 @@
<?php
// admin/sync_languages.php
require_once __DIR__ . '/../includes/session_check.php';
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/logger.php';
require_once __DIR__ . '/../src/Translate.php';
header('Content-Type: application/json');
// Envolver todo en un manejador de errores para capturar hasta los errores fatales
register_shutdown_function(function () {
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
// Limpiar cualquier salida anterior
if (ob_get_length()) {
ob_end_clean();
}
echo json_encode(['success' => false, 'error' => 'Error fatal en el servidor: ' . $error['message'] . ' en ' . $error['file'] . ' línea ' . $error['line']]);
}
});
// Solo para administradores
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
echo json_encode(['success' => false, 'error' => 'Acceso denegado.']);
exit;
}
try {
// 1. Verificar que la URL de LibreTranslate esté configurada
if (empty($_ENV['LIBRETRANSLATE_URL'])) {
throw new Exception("La variable de entorno LIBRETRANSLATE_URL no está configurada en tu archivo .env");
}
$translator = new Translate();
$libreLanguages = $translator->getSupportedLanguages();
if ($libreLanguages === null) {
throw new Exception("No se pudo obtener la lista de idiomas de LibreTranslate. Revisa que la URL ('" . htmlspecialchars($_ENV['LIBRETRANSLATE_URL']) . "') sea correcta y que el servicio esté funcionando.");
}
if (empty($libreLanguages)) {
throw new Exception("LibreTranslate devolvió una lista de idiomas vacía.");
}
$newLanguagesCount = 0;
$sql = "INSERT IGNORE INTO supported_languages (language_code, language_name, is_active) VALUES (?, ?, 0)";
$stmt = $pdo->prepare($sql);
foreach ($libreLanguages as $lang) {
if (isset($lang['code']) && isset($lang['name'])) {
$stmt->execute([$lang['code'], $lang['name']]);
if ($stmt->rowCount() > 0) {
$newLanguagesCount++;
}
}
}
echo json_encode(['success' => true, 'new_languages' => $newLanguagesCount]);
} catch (Throwable $e) { // Captura Throwable para errores y excepciones
error_log("Error en sync_languages.php: " . $e->getMessage());
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
?>

408
admin/test_discord_connection.php Executable file
View File

@@ -0,0 +1,408 @@
<?php
require_once __DIR__ . '/../includes/session_check.php';
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../src/DiscordSender.php';
require_once __DIR__ . '/../config/config.php';
// Verificar que el usuario sea administrador
if ($_SESSION['role'] !== 'admin') {
header('HTTP/1.0 403 Forbidden');
die('Acceso denegado. Solo los administradores pueden acceder a esta página.');
}
$message = '';
$error = '';
$success = '';
$discordResponse = null;
$permissionsInfo = [];
$telegramWebhookInfo = null;
// Manejar el envío del formulario de prueba
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Verificar si es una solicitud de verificación de permisos de Discord
if (isset($_POST['check_permissions']) && !empty($_POST['guild_id'])) {
$guildId = trim($_POST['guild_id']);
try {
$ch = curl_init("https://discord.com/api/v10/guilds/{$guildId}");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => [
"Authorization: Bot " . DISCORD_BOT_TOKEN,
"Content-Type: application/json"
],
CURLOPT_RETURNTRANSFER => true
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
$guild = json_decode($response, true);
$ch = curl_init("https://discord.com/api/v10/users/@me");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bot " . DISCORD_BOT_TOKEN],
CURLOPT_RETURNTRANSFER => true
]);
$botInfo = json_decode(curl_exec($ch), true);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode !== 200 || !isset($botInfo['id'])) {
throw new Exception("No se pudo obtener la información del bot de Discord. Verifica que el token sea correcto. Código HTTP: $httpCode");
}
$botId = $botInfo['id'];
$ch = curl_init("https://discord.com/api/v10/guilds/{$guildId}/roles");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bot " . DISCORD_BOT_TOKEN],
CURLOPT_RETURNTRANSFER => true
]);
$roles = json_decode(curl_exec($ch), true);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200 && is_array($roles)) {
$ch = curl_init("https://discord.com/api/v10/guilds/{$guildId}/members/{$botId}");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bot " . DISCORD_BOT_TOKEN],
CURLOPT_RETURNTRANSFER => true
]);
$member = json_decode(curl_exec($ch), true);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 404) {
throw new Exception("El bot no está en el servidor de Discord especificado.");
} elseif ($httpCode !== 200) {
throw new Exception("Error al obtener información del bot en el servidor. Código HTTP: $httpCode");
}
if (isset($member['roles']) && is_array($member['roles'])) {
$botRoles = array_filter($roles, fn($role) => in_array($role['id'], $member['roles']));
$permissions = 0;
foreach ($botRoles as $role) {
$permissions |= intval($role['permissions']);
}
$permissionsInfo = [
'guild_name' => $guild['name'] ?? 'Desconocido',
'bot_has_admin' => ($permissions & 0x8) === 0x8,
'can_kick' => ($permissions & 0x2) === 0x2,
'can_ban' => ($permissions & 0x4) === 0x4,
'permissions_int' => $permissions,
'permissions_bin' => decbin($permissions),
'roles' => array_column($botRoles, 'name')
];
$success = "Permisos de Discord verificados para el servidor: " . htmlspecialchars($permissionsInfo['guild_name']);
} else {
$error = "No se pudo obtener la información de roles del bot en el servidor de Discord.";
}
} else {
$error = "No se pudieron obtener los roles del servidor de Discord. Código HTTP: $httpCode";
}
} else {
$error = "Error al obtener información del servidor de Discord. Código HTTP: $httpCode";
}
} catch (Exception $e) {
$error = "Error al verificar permisos de Discord: " . $e->getMessage();
}
}
// Verificar si es una solicitud de verificación de webhook de Telegram
elseif (isset($_POST['check_telegram_webhook'])) {
try {
if (!defined('TELEGRAM_BOT_TOKEN') || empty(TELEGRAM_BOT_TOKEN)) {
throw new Exception("La constante TELEGRAM_BOT_TOKEN no está definida o está vacía.");
}
$botToken = TELEGRAM_BOT_TOKEN;
$apiUrl = "https://api.telegram.org/bot{$botToken}/getWebhookInfo";
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10, // Segundos para esperar la conexión
CURLOPT_TIMEOUT => 20, // Segundos para la ejecución total de cURL
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false) {
throw new Exception('Error en cURL al contactar la API de Telegram: ' . curl_error($ch));
}
if ($httpCode !== 200) {
throw new Exception("La API de Telegram devolvió un código HTTP {$httpCode}. Respuesta: " . $response);
}
$result = json_decode($response, true);
if (!$result['ok']) {
throw new Exception("Error de la API de Telegram: " . ($result['description'] ?? 'Error desconocido'));
}
$telegramWebhookInfo = $result['result'];
$success = "Información del webhook de Telegram obtenida correctamente.";
} catch (Exception $e) {
$error = "Error al verificar el webhook de Telegram: " . $e->getMessage();
}
}
// Manejar la eliminación del webhook de Telegram
elseif (isset($_POST['delete_telegram_webhook'])) {
try {
if (!defined('TELEGRAM_BOT_TOKEN') || empty(TELEGRAM_BOT_TOKEN)) {
throw new Exception("La constante TELEGRAM_BOT_TOKEN no está definida o está vacía.");
}
$botToken = TELEGRAM_BOT_TOKEN;
$apiUrl = "https://api.telegram.org/bot{$botToken}/deleteWebhook";
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$result = json_decode($response, true);
if ($httpCode === 200 && $result['ok']) {
$success = "Webhook de Telegram eliminado correctamente.";
} else {
throw new Exception("Error al eliminar el webhook: " . ($result['description'] ?? 'Respuesta inválida de la API.'));
}
} catch (Exception $e) {
$error = "Error al eliminar el webhook de Telegram: " . $e->getMessage();
}
}
// Manejar la configuración del webhook de Telegram
elseif (isset($_POST['set_telegram_webhook'])) {
try {
// Las constantes son definidas en config.php
if (!defined('TELEGRAM_BOT_TOKEN') || !defined('BOT_BASE_URL') || !defined('TELEGRAM_WEBHOOK_TOKEN')) {
throw new Exception("Una o más constantes requeridas (TELEGRAM_BOT_TOKEN, BOT_BASE_URL, TELEGRAM_WEBHOOK_TOKEN) no están definidas. Revisa tu archivo .env y la configuración.");
}
$botToken = TELEGRAM_BOT_TOKEN;
$webhookUrl = rtrim(BOT_BASE_URL, '/') . '/telegram/webhook/telegram_bot_webhook.php?auth_token=' . TELEGRAM_WEBHOOK_TOKEN;
$apiUrl = "https://api.telegram.org/bot{$botToken}/setWebhook?url=" . urlencode($webhookUrl);
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$result = json_decode($response, true);
if ($httpCode === 200 && $result['ok']) {
$success = "Webhook de Telegram configurado correctamente en: " . htmlspecialchars($webhookUrl);
} else {
throw new Exception("Error al configurar el webhook: " . ($result['description'] ?? 'Respuesta inválida de la API.'));
}
} catch (Exception $e) {
$error = "Error al configurar el webhook de Telegram: " . $e->getMessage();
}
}
// Si es un envío de mensaje de prueba de Discord
elseif (isset($_POST['discord_id']) && !empty($_POST['test_content'])) {
$recipientId = trim($_POST['discord_id']);
$testContent = trim($_POST['test_content']);
$testRecipientType = $_POST['test_recipient_type'] ?? 'channel';
try {
$logDir = __DIR__ . '/../logs';
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
error_log("Test Discord: ID={$recipientId}, Type={$testRecipientType}, Token=" . substr(DISCORD_BOT_TOKEN, 0, 8) . "...", 3, $logDir . '/discord_api.log');
$discordSender = new DiscordSender(DISCORD_BOT_TOKEN);
$discordResponse = $discordSender->sendMessage($recipientId, $testContent, $testRecipientType);
$success = "Mensaje de prueba de Discord enviado con éxito.";
} catch (Exception $e) {
$error = "Error al enviar mensaje de prueba de Discord: " . $e->getMessage();
}
} else {
$error = "Por favor, completa todos los campos requeridos.";
}
}
// Incluir el encabezado que contiene el menú lateral
require_once __DIR__ . '/../templates/header.php';
?>
<!-- Contenido principal -->
<div id="page-content-wrapper">
<div class="container-fluid">
<h1 class="mt-4">Probar Conexiones de Bots</h1>
<?php if ($error): ?>
<div class="alert alert-danger"><?php echo $error; ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success"><?php echo $success; ?></div>
<?php endif; ?>
<div class="card shadow-sm mb-4">
<div class="card-header">
<h5 class="mb-0">Enviar Mensaje de Prueba a Discord</h5>
</div>
<div class="card-body">
<form action="" method="POST">
<div class="mb-3">
<label for="discord_id" class="form-label">ID de Canal o Usuario de Discord</label>
<input type="text" class="form-control" id="discord_id" name="discord_id" required
value="<?php echo htmlspecialchars($_POST['discord_id'] ?? ''); ?>">
</div>
<div class="mb-3">
<label class="form-label">Tipo de Destinatario</label>
<div>
<input type="radio" id="testRecipientTypeChannel" name="test_recipient_type" value="channel"
<?php echo (($_POST['test_recipient_type'] ?? 'channel') === 'channel') ? 'checked' : ''; ?>>
<label for="testRecipientTypeChannel">Canal</label>
<input type="radio" id="testRecipientTypeUser" name="test_recipient_type" value="user" class="ms-3"
<?php echo (($_POST['test_recipient_type'] ?? 'channel') === 'user') ? 'checked' : ''; ?>>
<label for="testRecipientTypeUser">Usuario</label>
</div>
</div>
<div class="mb-3">
<label for="test_content" class="form-label">Contenido del Mensaje</label>
<textarea class="form-control" id="test_content" name="test_content" rows="3" required><?php
echo htmlspecialchars($_POST['test_content'] ?? 'Mensaje de prueba desde el bot.');
?></textarea>
</div>
<button type="submit" class="btn btn-primary">Enviar Prueba a Discord</button>
</form>
</div>
</div>
<?php if (isset($discordResponse) && $discordResponse !== null): ?>
<div class="card shadow-sm mb-4">
<div class="card-header"><h5 class="mb-0">Respuesta de la API de Discord</h5></div>
<div class="card-body"><pre><code><?php print_r($discordResponse); ?></code></pre></div>
</div>
<?php endif; ?>
<div class="card shadow-sm mb-4">
<div class="card-header"><h5 class="mb-0">Verificar Permisos del Bot de Discord</h5></div>
<div class="card-body">
<form action="" method="POST" class="mb-4">
<input type="hidden" name="check_permissions" value="1">
<div class="mb-3">
<label for="guild_id" class="form-label">ID del Servidor de Discord</label>
<input type="text" class="form-control" id="guild_id" name="guild_id" required
value="<?php echo htmlspecialchars($_POST['guild_id'] ?? ''); ?>"
placeholder="Ej: 123456789012345678">
</div>
<button type="submit" class="btn btn-info">Verificar Permisos de Discord</button>
</form>
<?php if (!empty($permissionsInfo)): ?>
<div class="alert alert-info">
<h6>Resultado de la verificación de Discord:</h6>
<ul class="mb-0">
<li>Servidor: <strong><?php echo htmlspecialchars($permissionsInfo['guild_name']); ?></strong></li>
<li>Es Administrador:
<span class="badge bg-<?php echo $permissionsInfo['bot_has_admin'] ? 'success' : 'danger'; ?>">
<?php echo $permissionsInfo['bot_has_admin'] ? 'Sí' : 'No'; ?>
</span>
</li>
</ul>
</div>
<?php endif; ?>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header"><h5 class="mb-0">Verificar Estado del Webhook de Telegram</h5></div>
<div class="card-body">
<p>Obtén el estado actual de tu webhook directamente desde la API de Telegram para diagnosticar problemas de conexión.</p>
<form action="" method="POST">
<input type="hidden" name="check_telegram_webhook" value="1">
<button type="submit" class="btn btn-info">Verificar Webhook de Telegram</button>
</form>
<?php if (isset($telegramWebhookInfo) && is_array($telegramWebhookInfo)): ?>
<div class="mt-4">
<h6>Resultado de la verificación de Telegram:</h6>
<?php if (empty($telegramWebhookInfo['url'])): ?>
<div class="alert alert-danger">
<strong>No hay webhook configurado.</strong> El bot está funcionando en modo 'getUpdates'.
</div>
<?php else: ?>
<ul class="list-group">
<li class="list-group-item"><strong>URL:</strong> <code><?php echo htmlspecialchars($telegramWebhookInfo['url']); ?></code></li>
<li class="list-group-item"><strong>Actualizaciones pendientes:</strong>
<span class="badge bg-<?php echo $telegramWebhookInfo['pending_update_count'] > 0 ? 'warning' : 'success'; ?>">
<?php echo $telegramWebhookInfo['pending_update_count']; ?>
</span>
</li>
<?php if (!empty($telegramWebhookInfo['last_error_date'])): ?>
<li class="list-group-item list-group-item-danger">
<strong>Último error:</strong> <?php echo date('Y-m-d H:i:s', $telegramWebhookInfo['last_error_date']); ?>
<br>
<strong>Mensaje:</strong> <?php echo htmlspecialchars($telegramWebhookInfo['last_error_message']); ?>
</li>
<?php else: ?>
<li class="list-group-item list-group-item-success"><strong>Último error:</strong> Ninguno reportado.</li>
<?php endif; ?>
<li class="list-group-item"><strong>Máx. Conexiones:</strong> <?php echo $telegramWebhookInfo['max_connections'] ?? 'No definido'; ?></li>
<li class="list-group-item"><strong>Actualizaciones permitidas:</strong>
<?php if (!empty($telegramWebhookInfo['allowed_updates']) && count($telegramWebhookInfo['allowed_updates']) > 0): ?>
<code><?php echo implode(', ', $telegramWebhookInfo['allowed_updates']); ?></code>
<?php else: ?>
<span>Todos los tipos (por defecto).</span>
<?php endif; ?>
</li>
</ul>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header"><h5 class="mb-0">Gestionar Webhook de Telegram</h5></div>
<div class="card-body">
<p>Usa estos botones para eliminar o re-configurar el webhook de Telegram. Esto es útil para forzar a Telegram a actualizar la dirección IP de tu servidor si ha cambiado (por ejemplo, con DuckDNS).</p>
<form action="" method="POST" class="d-inline me-2">
<input type="hidden" name="delete_telegram_webhook" value="1">
<button type="submit" class="btn btn-danger" onclick="return confirm('¿Estás seguro de que quieres eliminar el webhook? El bot dejará de recibir actualizaciones.');">Eliminar Webhook</button>
</form>
<form action="" method="POST" class="d-inline">
<input type="hidden" name="set_telegram_webhook" value="1">
<button type="submit" class="btn btn-success">Configurar Webhook</button>
</form>
</div>
</div>
</div>
</div>
<!-- Scripts de Bootstrap -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<script>
// Toggle del menú lateral
function toggleSidebar() {
document.getElementById('wrapper').classList.toggle('toggled');
}
// Función para copiar al portapapeles
function copyToClipboard(elementId) {
var copyText = document.getElementById(elementId);
copyText.select();
copyText.setSelectionRange(0, 99999);
document.execCommand("copy");
var button = event.target;
var originalText = button.innerHTML;
button.innerHTML = '¡Copiado!';
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-success');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('btn-success');
button.classList.add('btn-outline-secondary');
}, 2000);
}
</script>
<?php require_once __DIR__ . '/../templates/footer.php'; ?>

42
admin/update_language_flag.php Executable file
View File

@@ -0,0 +1,42 @@
<?php
// admin/update_language_flag.php
require_once __DIR__ . '/../includes/session_check.php';
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/db.php';
header('Content-Type: application/json');
// Solo para administradores
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
echo json_encode(['success' => false, 'error' => 'Acceso denegado.']);
exit;
}
// Verificar que la solicitud sea AJAX y POST
if (strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') !== 'xmlhttprequest' || $_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'error' => 'Solicitud no válida.']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
$langId = $data['id'] ?? null;
$flagEmoji = $data['flag_emoji'] ?? '';
if ($langId === null) {
echo json_encode(['success' => false, 'error' => 'ID de idioma no proporcionado.']);
exit;
}
try {
$stmt = $pdo->prepare("UPDATE supported_languages SET flag_emoji = ? WHERE id = ?");
$stmt->execute([$flagEmoji, $langId]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
error_log("Error en update_language_flag.php: " . $e->getMessage());
echo json_encode(['success' => false, 'error' => 'Error en la base de datos.']);
}
?>

View File

@@ -0,0 +1,49 @@
<?php
// admin/update_language_status.php
require_once __DIR__ . '/../includes/session_check.php';
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/db.php';
header('Content-Type: application/json');
// Verificar que la solicitud sea AJAX y POST
if (strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') !== 'xmlhttprequest' || $_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'error' => 'Solicitud no válida.']);
exit;
}
// Solo para administradores
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
echo json_encode(['success' => false, 'error' => 'Acceso denegado.']);
exit;
}
// Obtener datos del cuerpo de la solicitud
$data = json_decode(file_get_contents('php://input'), true);
$langId = $data['id'] ?? null;
$isActive = isset($data['is_active']) ? (int)$data['is_active'] : null;
if ($langId === null || $isActive === null) {
echo json_encode(['success' => false, 'error' => 'Datos incompletos.']);
exit;
}
try {
$stmt = $pdo->prepare("UPDATE supported_languages SET is_active = ? WHERE id = ?");
$stmt->execute([$isActive, $langId]);
if ($stmt->rowCount() > 0) {
echo json_encode(['success' => true]);
} else {
// No se actualizó ninguna fila, podría ser que el ID no exista
echo json_encode(['success' => false, 'error' => 'El idioma no fue encontrado o el estado ya era el mismo.']);
}
} catch (PDOException $e) {
// Loguear el error real en el servidor
error_log("Error en update_language_status.php: " . $e->getMessage());
echo json_encode(['success' => false, 'error' => 'Error en la base de datos.']);
}
?>

201
admin/users.php Executable file
View File

@@ -0,0 +1,201 @@
<?php
require_once __DIR__ . '/../includes/session_check.php';
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/activity_logger.php';
// Admin-only access
if ($_SESSION['role'] !== 'admin') {
header('Location: ../index.php?error=unauthorized');
exit();
}
// Inicializar variables para el modo edición
$edit_mode = false;
$edit_id = null;
$edit_username = '';
$edit_role = 'user';
$edit_telegram_chat_id = '';
// Handle GET actions for editing
if (isset($_GET['edit'])) {
$edit_id = $_GET['edit'];
$stmt = $pdo->prepare("SELECT id, username, role, telegram_chat_id FROM users WHERE id = ?");
$stmt->execute([$edit_id]);
$user_to_edit = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user_to_edit) {
$edit_mode = true;
$edit_username = $user_to_edit['username'];
$edit_role = $user_to_edit['role'];
$edit_telegram_chat_id = $user_to_edit['telegram_chat_id'];
} else {
$error = "Usuario no encontrado.";
}
}
// Handle POST actions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Action: Create or Update User
if (isset($_POST['save_user'])) {
$username = $_POST['username'];
$role = $_POST['role'];
$telegram_chat_id = trim($_POST['telegram_chat_id']);
$is_edit = isset($_POST['edit_id']);
if (empty($username) || empty($role)) {
$error = "El nombre de usuario y el rol son obligatorios.";
} elseif (!empty($telegram_chat_id) && !is_numeric($telegram_chat_id)) {
$error = "El ID de Chat de Telegram debe ser un número.";
} else {
$chat_id_to_save = empty($telegram_chat_id) ? null : $telegram_chat_id;
try {
if ($is_edit) {
$edit_id = $_POST['edit_id'];
$details = 'Admin ' . $_SESSION['username'] . ' updated user: ' . $username . ' (ID: ' . $edit_id . ').';
if (!empty($_POST['password'])) {
$hashedPassword = password_hash($_POST['password'], PASSWORD_DEFAULT);
$stmt = $pdo->prepare("UPDATE users SET username = ?, password = ?, role = ?, telegram_chat_id = ? WHERE id = ?");
$stmt->execute([$username, $hashedPassword, $role, $chat_id_to_save, $edit_id]);
$details .= ' Password was changed.';
} else {
$stmt = $pdo->prepare("UPDATE users SET username = ?, role = ?, telegram_chat_id = ? WHERE id = ?");
$stmt->execute([$username, $role, $chat_id_to_save, $edit_id]);
}
log_activity($_SESSION['user_id'], 'User Updated', $details);
header('Location: users.php?success=updated');
exit();
} else {
if (empty($_POST['password'])) {
$error = "La contraseña es obligatoria para nuevos usuarios.";
} else {
$hashedPassword = password_hash($_POST['password'], PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO users (username, password, role, telegram_chat_id) VALUES (?, ?, ?, ?)");
$stmt->execute([$username, $hashedPassword, $role, $chat_id_to_save]);
$new_user_id = $pdo->lastInsertId();
log_activity($_SESSION['user_id'], 'User Created', 'Admin ' . $_SESSION['username'] . ' created new user: ' . $username . ' (ID: ' . $new_user_id . ')');
header('Location: users.php?success=created');
exit();
}
}
} catch (PDOException $e) {
$error = ($e->errorInfo[1] == 1062) ? "El nombre de usuario ya existe." : "Error al guardar el usuario: " . $e->getMessage();
if ($is_edit) {
$edit_mode = true;
$edit_id = $_POST['edit_id'];
$edit_username = $username;
$edit_role = $role;
$edit_telegram_chat_id = $telegram_chat_id;
}
}
}
}
// ... (Otras acciones POST como eliminar, etc. se mantienen aquí)
}
// Fetch all users to display
$users = $pdo->query("SELECT id, username, role, created_at, telegram_chat_id FROM users ORDER BY username ASC")->fetchAll(PDO::FETCH_ASSOC);
require_once __DIR__ . '/../templates/header.php';
?>
<div class="container-fluid">
<h1 class="mt-4" data-translate="true">Administrar Usuarios</h1>
<?php if (isset($error)): ?><div class="alert alert-danger"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php if (isset($_GET['success'])): /* ... Lógica de mensajes de éxito ... */ endif; ?>
<!-- Create/Edit User Form -->
<div class="card shadow-sm mb-4">
<div class="card-header">
<h5 class="mb-0" data-translate="true"><?= $edit_mode ? 'Editar Usuario' : 'Crear Nuevo Usuario' ?></h5>
</div>
<div class="card-body">
<form action="users.php" method="POST">
<?php if ($edit_mode): ?>
<input type="hidden" name="edit_id" value="<?= $edit_id ?>">
<?php endif; ?>
<div class="row align-items-end">
<div class="col-md-3 mb-3">
<label for="username" class="form-label" data-translate="true">Usuario</label>
<input type="text" class="form-control" id="username" name="username"
value="<?= htmlspecialchars($edit_username) ?>" required>
</div>
<div class="col-md-3 mb-3">
<label for="password" class="form-label">
<span data-translate="true">Contraseña</span> <?php if ($edit_mode): ?><small class="text-muted" data-translate="true">(no cambiar)</small><?php endif; ?>
</label>
<input type="password" class="form-control" id="password" name="password" <?= !$edit_mode ? 'required' : '' ?>>
</div>
<div class="col-md-2 mb-3">
<label for="role" class="form-label" data-translate="true">Rol</label>
<select class="form-select" id="role" name="role" required>
<option value="user" <?= ($edit_role === 'user') ? 'selected' : '' ?> data-translate="true">Usuario</option>
<option value="admin" <?= ($edit_role === 'admin') ? 'selected' : '' ?> data-translate="true">Admin</option>
</select>
</div>
<div class="col-md-2 mb-3">
<label for="telegram_chat_id" class="form-label" data-translate="true">ID Chat Telegram</label>
<input type="text" class="form-control" id="telegram_chat_id" name="telegram_chat_id"
value="<?= htmlspecialchars($edit_telegram_chat_id) ?>" placeholder="(Opcional)">
</div>
<div class="col-md-2 mb-3">
<button type="submit" name="save_user" class="btn btn-<?= $edit_mode ? 'success' : 'primary' ?> w-100" data-translate="true">
<?= $edit_mode ? 'Actualizar' : 'Crear' ?>
</button>
<?php if ($edit_mode): ?>
<a href="users.php" class="btn btn-outline-secondary w-100 mt-2" data-translate="true">Cancelar</a>
<?php endif; ?>
</div>
</div>
</form>
</div>
</div>
<!-- Users List -->
<div class="card shadow-sm">
<div class="card-header"><h5 data-translate="true">Lista de Usuarios</h5></div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th data-translate="true">ID</th>
<th data-translate="true">Usuario</th>
<th data-translate="true">Rol</th>
<th data-translate="true">ID Chat Telegram</th>
<th data-translate="true">Creado en</th>
<th class="text-center" data-translate="true">Acciones</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr>
<td><?= $user['id'] ?></td>
<td><?= htmlspecialchars($user['username']) ?></td>
<td><?= ucfirst($user['role']) ?></td>
<td>
<?php if (!empty($user['telegram_chat_id'])): ?>
<span class="badge bg-info text-dark"><?= htmlspecialchars($user['telegram_chat_id']) ?></span>
<?php else: ?>
<span class="badge bg-secondary" data-translate="true">No vinculado</span>
<?php endif; ?>
</td>
<td><?= date('d/m/Y H:i', strtotime($user['created_at'])) ?></td>
<td class="text-center">
<a href="?edit=<?= $user['id'] ?>#username" class="btn btn-sm btn-primary" title="Editar" data-translate-title="true">
<i class="bi bi-pencil"></i>
</a>
<!-- Otros botones de acción (modal, eliminar) -->
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modales y Scripts -->
<?php require_once __DIR__ . '/../templates/footer.php'; ?>

52
admin_send_message.php Executable file
View File

@@ -0,0 +1,52 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/includes/session_check.php';
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/src/TelegramSender.php';
// Solo para administradores
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Acceso denegado.']);
exit;
}
$chatId = $_POST['chat_id'] ?? null;
$message = $_POST['message'] ?? null;
if (!$chatId || !$message) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Faltan parámetros: chat_id y message son requeridos.']);
exit;
}
try {
$telegram = new TelegramSender($_ENV['TELEGRAM_BOT_TOKEN'], $pdo);
// Se envía el mensaje. La clase TelegramSender se encargará de:
// 1. Formatear el texto si es necesario.
// 2. Añadir el botón de traducción (true).
// 3. Registrar la interacción en la base de datos.
$result = $telegram->sendMessage(
$chatId,
htmlspecialchars($message), // Usamos htmlspecialchars para seguridad básica
['parse_mode' => 'HTML'],
true, // Añadir botón de traducción
'es' // El mensaje del admin se asume que es en español
);
if ($result) {
echo json_encode(['success' => true]);
} else {
// El error específico ya se loguea dentro de TelegramSender
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'El servicio de Telegram no pudo enviar el mensaje.']);
}
} catch (Exception $e) {
custom_log("Error fatal en admin_send_message.php: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Error interno del servidor al procesar la solicitud.']);
}

BIN
assets/css/font/summernote.ttf Executable file

Binary file not shown.

BIN
assets/css/font/summernote.woff Executable file

Binary file not shown.

BIN
assets/css/font/summernote.woff2 Executable file

Binary file not shown.

1
assets/css/summernote-bs5.min.css vendored Executable file

File diff suppressed because one or more lines are too long

BIN
assets/images/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

2
assets/js/jquery-3.6.0.min.js vendored Executable file

File diff suppressed because one or more lines are too long

2
assets/js/summernote-bs5.min.js vendored Executable file

File diff suppressed because one or more lines are too long

114
assets/js/translate_frontend.js Executable file
View File

@@ -0,0 +1,114 @@
document.addEventListener('DOMContentLoaded', () => {
const languageSelector = document.getElementById('language-selector');
const currentLang = localStorage.getItem('appLang') || 'es'; // Default to Spanish
const translatableElements = document.querySelectorAll('[data-translate="true"]');
// Function to translate text
async function translateText(text, sourceLang, targetLang) {
try {
const response = await fetch('/translate_proxy.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'translate',
text: text,
source: sourceLang,
target: targetLang
}),
});
const data = await response.json();
if (response.ok) {
return data.translatedText; // LibreTranslate returns { translatedText: "..." }
} else {
console.error('Error translating:', data.error);
return text; // Return original text on error
}
} catch (error) {
console.error('Network error during translation:', error);
return text; // Return original text on network error
}
}
async function applyTranslation(targetLang) {
for (const element of translatableElements) {
const originalText = element.dataset.originalText || element.textContent;
if (!element.dataset.originalText) {
element.dataset.originalText = originalText;
}
const icon = element.querySelector('i.bi');
const textToTranslate = originalText.trim();
let newText;
if (targetLang === 'es') {
newText = textToTranslate;
} else {
newText = await translateText(textToTranslate, 'es', targetLang);
}
if (icon) {
element.textContent = ' ' + newText;
element.insertAdjacentHTML('afterbegin', icon.outerHTML);
} else {
element.textContent = newText;
}
}
localStorage.setItem('appLang', targetLang);
if (languageSelector) {
languageSelector.value = targetLang;
}
}
// Function to fetch and populate languages
async function fetchAndPopulateLanguages() {
try {
const response = await fetch('/translate_proxy.php?action=languages'); // Assuming translate_proxy.php can handle a 'languages' action
const languages = await response.json();
if (response.ok && Array.isArray(languages)) {
languageSelector.innerHTML = ''; // Clear existing options
languages.forEach(lang => {
const option = document.createElement('option');
option.value = lang.code;
option.textContent = `${lang.flag_emoji} ${lang.name}`;
languageSelector.appendChild(option);
});
// Set the selector to the current language
languageSelector.value = currentLang;
} else {
console.error('Error fetching languages:', languages.error || 'Unknown error');
// Fallback to default options if fetching fails
languageSelector.innerHTML = `
<option value="es">🇪🇸 Español</option>
<option value="en">🇬🇧 English</option>
<option value="pt">🇧🇷 Português</option>
`;
languageSelector.value = currentLang;
}
} catch (error) {
console.error('Network error fetching languages:', error);
// Fallback to default options if network error
languageSelector.innerHTML = `
<option value="es">🇪🇸 Español</option>
<option value="en">🇬🇧 English</option>
<option value="pt">🇧🇷 Português</option>
`;
languageSelector.value = currentLang;
}
}
// Initial application of language based on stored preference
fetchAndPopulateLanguages().then(() => {
applyTranslation(currentLang);
});
// Event listener for the language selector
if (languageSelector) {
languageSelector.addEventListener('change', (event) => {
const newLang = event.target.value;
applyTranslation(newLang);
});
}
});

86
change_language.php Executable file
View File

@@ -0,0 +1,86 @@
<?php
/**
* change_language.php
*
* Este script maneja el cambio de idioma en la aplicación.
* Actualiza la sesión del usuario con el idioma seleccionado.
*/
// Iniciar la sesión si no está iniciada
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Incluir configuración y base de datos
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/translation_helper.php';
// Configurar cabeceras para respuesta JSON
header('Content-Type: application/json');
// Verificar que la solicitud sea POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); // Método no permitido
echo json_encode(['success' => false, 'message' => 'Método no permitido']);
exit;
}
// Obtener el idioma de la solicitud
$language = $_POST['language'] ?? null;
// Validar que se proporcionó un idioma
if (empty($language)) {
http_response_code(400); // Solicitud incorrecta
echo json_encode(['success' => false, 'message' => 'No se proporcionó un idioma']);
exit;
}
// Verificar que el idioma esté disponible
$availableLanguages = get_available_languages();
$validLanguage = false;
foreach ($availableLanguages as $lang) {
if ($lang['code'] === $language) {
$validLanguage = true;
break;
}
}
if (!$validLanguage) {
http_response_code(400); // Solicitud incorrecta
echo json_encode(['success' => false, 'message' => 'Idioma no válido']);
exit;
}
// Actualizar el idioma en la sesión
$_SESSION['language'] = $language;
// Si el usuario está autenticado, actualizar su preferencia de idioma en la base de datos
if (isset($_SESSION['user_id'])) {
try {
$stmt = $pdo->prepare("UPDATE users SET language = ? WHERE id = ?");
$stmt->execute([$language, $_SESSION['user_id']]);
} catch (PDOException $e) {
// Registrar el error pero no fallar la operación
error_log("Error al actualizar el idioma del usuario: " . $e->getMessage());
}
}
// Limpiar la caché de traducciones si existe
if (isset($GLOBALS['_translations_cache'])) {
$GLOBALS['_translations_cache'] = [];
}
// Devolver respuesta exitosa
echo json_encode([
'success' => true,
'message' => 'Idioma actualizado correctamente',
'language' => $language
]);
// Registrar el cambio de idioma
custom_log("Idioma cambiado a: " . $language, 'language');
// Finalizar el script
exit;

621
chat_telegram.php Executable file
View File

@@ -0,0 +1,621 @@
<?php
require_once __DIR__ . '/includes/session_check.php';
require_once __DIR__ . '/includes/db.php';
// Solo para administradores
if ($_SESSION['role'] !== 'admin') {
header('HTTP/1.0 403 Forbidden');
die('Acceso denegado.');
}
// Obtener todos los usuarios de Telegram que han interactuado o son miembros de grupos
$stmt = $pdo->query(
"SELECT DISTINCT r.platform_id, r.name
FROM recipients r
WHERE r.platform = 'telegram' AND r.type = 'user'
ORDER BY r.name ASC"
);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
require_once __DIR__ . '/templates/header.php';
?>
<style>
.chat-container {
display: flex;
height: calc(100vh - 200px); /* Ajustar altura */
}
/* Estilos para el selector de emojis */
.emoji-picker {
position: fixed;
width: 250px;
max-height: 300px;
overflow-y: auto;
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 10px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 9999;
display: none;
flex-wrap: wrap;
gap: 5px;
}
.emoji-option {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 1.5em;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
user-select: none;
}
.emoji-option:hover {
background-color: #f8f9fa;
transform: scale(1.2);
}
.emoji-option:active {
transform: scale(0.95);
}
.input-group-prepend .btn {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.input-group-append .btn {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
#message-text {
border-radius: 0;
}
.message-text {
white-space: pre-wrap;
word-wrap: break-word;
}
.user-list {
border-right: 1px solid #dee2e6;
overflow-y: auto;
}
.chat-history {
flex-grow: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
}
.message-form {
padding: 1rem;
border-top: 1px solid #dee2e6;
}
.message {
max-width: 70%;
padding: 0.5rem 1rem;
border-radius: 1rem;
margin-bottom: 0.5rem;
}
.message.in {
background-color: #6495ED; /* Azul claro (CornflowerBlue) */
color: white;
align-self: flex-start;
}
.message.out {
background-color: #6495ED; /* Azul claro (CornflowerBlue) */
color: white;
align-self: flex-end;
}
.user-list .list-group-item.active {
background-color: #0d6efd;
border-color: #0d6efd;
}
</style>
<div class="container-fluid">
<h1 class="mt-4">Chat de Soporte de Telegram</h1>
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="chat-container">
<!-- Columna de Usuarios -->
<div class="col-md-4 col-lg-3 user-list">
<div class="list-group list-group-flush">
<?php foreach ($users as $user): ?>
<a href="#" class="list-group-item list-group-item-action" data-chat-id="<?= $user['platform_id'] ?>">
<?= htmlspecialchars($user['name']) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<!-- Columna de Chat -->
<div class="col-md-8 col-lg-9 d-flex flex-column">
<div id="chat-history" class="chat-history">
<div class="text-center text-muted my-auto">
<i class="bi bi-arrow-left-circle-fill fs-1"></i>
<p>Selecciona un usuario para ver la conversación.</p>
</div>
</div>
<div id="message-form-container" class="message-form" style="display: none;">
<form id="message-form">
<input type="hidden" id="chat-id-input" name="chat_id">
<div class="input-group" style="position: relative;">
<div class="input-group-prepend">
<button type="button" id="emoji-trigger" class="btn btn-outline-secondary" onclick="toggleEmojiPicker()">
<i class="bi bi-emoji-smile"></i>
</button>
</div>
<input type="text" id="message-text" name="message" class="form-control" placeholder="Escribe tu respuesta..." required>
<div class="input-group-append">
<button type="submit" class="btn btn-primary">Enviar</button>
</div>
<!-- Selector de emojis simplificado -->
<div id="emoji-picker" style="
display: none;
position: absolute;
bottom: 100%;
left: 0;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px;
width: 250px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;">
<div style="display: flex; flex-wrap: wrap; gap: 5px;">
<span class="emoji-option" onclick="insertEmoji('😊')" style="cursor: pointer; font-size: 24px;">😊</span>
<span class="emoji-option" onclick="insertEmoji('😂')" style="cursor: pointer; font-size: 24px;">😂</span>
<span class="emoji-option" onclick="insertEmoji('😍')" style="cursor: pointer; font-size: 24px;">😍</span>
<span class="emoji-option" onclick="insertEmoji('😎')" style="cursor: pointer; font-size: 24px;">😎</span>
<span class="emoji-option" onclick="insertEmoji('😢')" style="cursor: pointer; font-size: 24px;">😢</span>
<span class="emoji-option" onclick="insertEmoji('😡')" style="cursor: pointer; font-size: 24px;">😡</span>
<span class="emoji-option" onclick="insertEmoji('😴')" style="cursor: pointer; font-size: 24px;">😴</span>
<span class="emoji-option" onclick="insertEmoji('😷')" style="cursor: pointer; font-size: 24px;">😷</span>
<span class="emoji-option" onclick="insertEmoji('🤔')" style="cursor: pointer; font-size: 24px;">🤔</span>
<span class="emoji-option" onclick="insertEmoji('🤗')" style="cursor: pointer; font-size: 24px;">🤗</span>
<span class="emoji-option" onclick="insertEmoji('👍')" style="cursor: pointer; font-size: 24px;">👍</span>
<span class="emoji-option" onclick="insertEmoji('👎')" style="cursor: pointer; font-size: 24px;">👎</span>
<span class="emoji-option" onclick="insertEmoji('👏')" style="cursor: pointer; font-size: 24px;">👏</span>
<span class="emoji-option" onclick="insertEmoji('🙌')" style="cursor: pointer; font-size: 24px;">🙌</span>
<span class="emoji-option" onclick="insertEmoji('🤝')" style="cursor: pointer; font-size: 24px;">🤝</span>
<span class="emoji-option" onclick="insertEmoji('📱')" style="cursor: pointer; font-size: 24px;">📱</span>
<span class="emoji-option" onclick="insertEmoji('💻')" style="cursor: pointer; font-size: 24px;">💻</span>
<span class="emoji-option" onclick="insertEmoji('📷')" style="cursor: pointer; font-size: 24px;">📷</span>
<span class="emoji-option" onclick="insertEmoji('🎮')" style="cursor: pointer; font-size: 24px;">🎮</span>
<span class="emoji-option" onclick="insertEmoji('📚')" style="cursor: pointer; font-size: 24px;">📚</span>
</div>
</div>
</div>
<script>
// Función para mostrar/ocultar el selector de emojis
function toggleEmojiPicker() {
const picker = document.getElementById('emoji-picker');
if (picker.style.display === 'none' || !picker.style.display) {
picker.style.display = 'block';
} else {
picker.style.display = 'none';
}
return false;
}
// Función para insertar un emoji en el campo de texto
function insertEmoji(emoji) {
const input = document.getElementById('message-text');
const start = input.selectionStart;
const end = input.selectionEnd;
const text = input.value;
input.value = text.substring(0, start) + emoji + text.substring(end);
input.focus();
input.setSelectionRange(start + emoji.length, start + emoji.length);
document.getElementById('emoji-picker').style.display = 'none';
}
// Cerrar el selector al hacer clic fuera de él
document.addEventListener('click', function(e) {
const picker = document.getElementById('emoji-picker');
const trigger = document.getElementById('emoji-trigger');
if (picker && trigger &&
e.target !== picker && !picker.contains(e.target) &&
e.target !== trigger && !trigger.contains(e.target)) {
picker.style.display = 'none';
}
});
</script>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<?php require_once __DIR__ . '/templates/footer.php'; ?>
<script>
// Función para insertar emojis en el campo de texto
function insertEmoji(emoji) {
const input = document.getElementById('message-text');
const start = input.selectionStart;
const end = input.selectionEnd;
const text = input.value;
// Insertar el emoji en la posición del cursor
input.value = text.substring(0, start) + emoji + text.substring(end);
// Mover el cursor después del emoji insertado
const newPos = start + emoji.length;
input.selectionStart = input.selectionEnd = newPos;
// Enfocar el campo de texto
input.focus();
}
document.addEventListener('DOMContentLoaded', function() {
const userLinks = document.querySelectorAll('.user-list .list-group-item');
const chatHistory = document.getElementById('chat-history');
const formContainer = document.getElementById('message-form-container');
const messageForm = document.getElementById('message-form');
const chatIdInput = document.getElementById('chat-id-input');
const messageTextInput = document.getElementById('message-text');
let activeChatId = null;
let lastMessageId = 0;
let refreshInterval = null;
userLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
// Marcar usuario activo
userLinks.forEach(l => l.classList.remove('active'));
this.classList.add('active');
// Detener el intervalo anterior si existe
if (refreshInterval) {
clearInterval(refreshInterval);
}
// Establecer el nuevo chat activo
activeChatId = this.getAttribute('data-chat-id');
chatIdInput.value = activeChatId;
lastMessageId = 0; // Reiniciar el ID del último mensaje
// Cargar el historial y comenzar a actualizar
loadChatHistory(activeChatId);
// Iniciar la actualización automática con un intervalo dinámico
let refreshDelay = 500; // Comenzar con 0.5 segundos
function startRefreshTimer() {
if (refreshInterval) clearInterval(refreshInterval);
refreshInterval = setInterval(() => {
if (activeChatId && !document.hidden) {
checkForNewMessages(activeChatId);
}
}, refreshDelay);
}
// Iniciar el temporizador
startRefreshTimer();
});
});
function loadChatHistory(chatId, onlyNew = false) {
// Usar un timestamp para evitar caché del navegador
const timestamp = new Date().getTime();
const url = `get_chat_history.php?chat_id=${chatId}${onlyNew ? '&last_id=' + lastMessageId : ''}&_=${timestamp}`;
if (!onlyNew) {
chatHistory.innerHTML = '<div class="text-center text-muted my-auto"><div class="spinner-border" role="status"><span class="visually-hidden">Cargando...</span></div></div>';
formContainer.style.display = 'block';
}
fetch(url)
.then(response => response.json())
.then(data => {
console.log('Datos recibidos:', data); // Depuración
if (onlyNew && (!data.history || data.history.length === 0)) {
return; // No hay mensajes nuevos
}
if (!onlyNew) {
chatHistory.innerHTML = '';
}
let hasNewMessages = false;
if (data.success && data.history && data.history.length > 0) {
// Ordenar los mensajes por ID para asegurar el orden correcto
data.history.sort((a, b) => a.id - b.id);
// Ordenar los mensajes por ID para asegurar el orden correcto
data.history.sort((a, b) => a.id - b);
data.history.forEach(msg => {
// Solo agregar mensajes más recientes que el último mostrado
if (msg.id > lastMessageId || !onlyNew) {
const messageDiv = document.createElement('div');
messageDiv.classList.add('message', msg.direction);
// Usar innerHTML para permitir que el enlace de traducción se añada correctamente
messageDiv.innerHTML = `<div class="message-text">${msg.message_text.replace(/\n/g, '<br>')}</div>`;
// Add translate link if needed
// For testing, always show for incoming messages
if (msg.direction === 'in') { // msg.language_code !== 'es'
const translateLink = document.createElement('a');
translateLink.href = '#';
translateLink.textContent = ' (Traducir)';
translateLink.classList.add('translate-link');
translateLink.setAttribute('data-message-id', msg.id);
messageDiv.appendChild(translateLink);
}
// Agregar el mensaje al historial
chatHistory.appendChild(messageDiv);
// Actualizar el ID del último mensaje
if (msg.id > lastMessageId) {
lastMessageId = msg.id;
}
hasNewMessages = true;
}
});
// Desplazarse al final solo si hay mensajes nuevos
if (hasNewMessages) {
chatHistory.scrollTop = chatHistory.scrollHeight;
}
} else if (!onlyNew) {
chatHistory.innerHTML = '<div class="text-center text-muted my-auto">No hay mensajes en esta conversación.</div>';
}
// Si no es una actualización, desplazarse al final
if (!onlyNew) {
chatHistory.scrollTop = chatHistory.scrollHeight;
}
// Actualizar lastMessageId si viene en la respuesta
if (data.last_id && data.last_id > lastMessageId) {
lastMessageId = data.last_id;
}
})
.catch(error => {
if (!onlyNew) {
console.error('Error al cargar el historial:', error);
chatHistory.innerHTML = '<div class="text-center text-danger my-auto">Error al cargar el historial. Intenta recargar la página.</div>';
}
});
}
// Función para verificar mensajes nuevos
function checkForNewMessages(chatId) {
if (!document.hidden && chatId) {
// Usar fetch con headers para evitar caché
fetch(`get_chat_history.php?chat_id=${chatId}&last_id=${lastMessageId}&_=${new Date().getTime()}`, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.json())
.then(data => {
console.log('Datos recibidos en checkForNewMessages:', data); // Depuración
if (data.success && data.history && data.history.length > 0) {
let hasNewMessages = false;
// Ordenar los mensajes por ID para asegurar el orden correcto
data.history.sort((a, b) => a.id - b);
data.history.forEach(msg => {
if (msg.id > lastMessageId) {
const messageDiv = document.createElement('div');
messageDiv.classList.add('message', msg.direction);
messageDiv.textContent = msg.message_text;
chatHistory.appendChild(messageDiv);
if (msg.id > lastMessageId) {
lastMessageId = msg.id;
}
hasNewMessages = true;
}
});
if (hasNewMessages) {
chatHistory.scrollTop = chatHistory.scrollHeight;
// Si hay mensajes nuevos, reiniciar el temporizador con el intervalo más corto
refreshDelay = 2000;
startRefreshTimer();
}
}
if (data.last_id && data.last_id > lastMessageId) {
lastMessageId = data.last_id;
}
})
.catch(error => {
console.error('Error al verificar mensajes nuevos:', error);
});
}
}
// Actualizar el chat cuando la pestaña vuelve a estar activa
document.addEventListener('visibilitychange', function() {
if (!document.hidden && activeChatId) {
loadChatHistory(activeChatId);
}
// Inicializar el selector de emojis
console.log('Inicializando selector de emojis...');
let emojiPickerVisible = false;
const emojiTrigger = document.getElementById('emoji-trigger');
const emojiPicker = document.getElementById('emoji-picker');
const messageTextInput = document.getElementById('message-text');
console.log('Elementos del selector de emojis:', {
emojiTrigger: emojiTrigger ? 'Encontrado' : 'No encontrado',
emojiPicker: emojiPicker ? 'Encontrado' : 'No encontrado',
messageTextInput: messageTextInput ? 'Encontrado' : 'No encontrado'
});
// Asegurarse de que el selector esté oculto inicialmente
if (emojiPicker) {
emojiPicker.style.display = 'none';
emojiPicker.style.backgroundColor = '#fff';
emojiPicker.style.border = '2px solid red';
emojiPicker.innerHTML = '<div style="padding: 10px;">Selector de emojis</div>' + emojiPicker.innerHTML;
}
// Mostrar/ocultar selector de emojis
if (emojiTrigger) {
emojiTrigger.addEventListener('click', function(e) {
console.log('Botón de emoji clickeado');
e.preventDefault();
e.stopPropagation();
if (!emojiPicker) {
console.error('El selector de emojis no se encontró en el DOM');
return false;
}
if (emojiPickerVisible) {
console.log('Ocultando selector de emojis');
emojiPicker.style.display = 'none';
} else {
console.log('Mostrando selector de emojis');
emojiPicker.style.display = 'block';
// Posicionar el selector debajo del botón
const rect = emojiTrigger.getBoundingClientRect();
emojiPicker.style.position = 'fixed';
emojiPicker.style.top = (rect.bottom + window.scrollY) + 'px';
emojiPicker.style.left = (rect.left + window.scrollX) + 'px';
emojiPicker.style.zIndex = '9999';
console.log('Posición del selector:', emojiPicker.style.top, emojiPicker.style.left);
}
emojiPickerVisible = !emojiPickerVisible;
return false;
});
} else {
console.error('No se pudo encontrar el botón de emoji');
}
// Cerrar el selector al hacer clic fuera
document.addEventListener('click', function(e) {
if (!emojiPicker || !emojiTrigger) return;
const isClickInside = emojiPicker.contains(e.target) || emojiTrigger.contains(e.target);
if (emojiPickerVisible && !isClickInside) {
console.log('Clic fuera del selector, ocultando...');
emojiPicker.style.display = 'none';
emojiPickerVisible = false;
}
});
// Manejar clics en emojis
if (emojiPicker) {
emojiPicker.addEventListener('click', function(e) {
console.log('Clic en el selector de emojis');
const emojiOption = e.target.closest('.emoji-option');
if (emojiOption) {
const emojiChar = emojiOption.getAttribute('data-emoji');
console.log('Emoji seleccionado:', emojiChar);
if (emojiChar) {
insertEmoji(emojiChar);
emojiPicker.style.display = 'none';
emojiPickerVisible = false;
}
}
});
}
});
// Evento para enviar mensaje
messageForm.addEventListener('submit', function(e) {
e.preventDefault();
const messageText = messageTextInput.value.trim();
if (!messageText) return;
const formData = new FormData(this);
// Optimistic UI update
const messageDiv = document.createElement('div');
messageDiv.className = `message out`;
messageDiv.innerHTML = `
<div class="message-text">${messageText.replace(/\n/g, '<br>')}</div>
<div class="message-time">${new Date().toLocaleTimeString()}</div>
`;
chatHistory.appendChild(messageDiv);
chatHistory.scrollTop = chatHistory.scrollHeight;
messageTextInput.value = '';
fetch('admin_send_message.php', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (!data.success) {
alert('Error al enviar el mensaje: ' + data.error);
// Opcional: eliminar el mensaje de la UI si falla
chatHistory.removeChild(messageDiv);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error de red al enviar el mensaje.');
chatHistory.removeChild(messageDiv);
});
});
// Evento para traducir mensaje
chatHistory.addEventListener('click', function(e) {
if (e.target.classList.contains('translate-link')) {
e.preventDefault();
const messageId = e.target.getAttribute('data-message-id');
const messageDiv = e.target.parentElement;
fetch(`translate_message.php?message_id=${messageId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
const translatedText = document.createElement('div');
translatedText.classList.add('translated-text');
translatedText.textContent = data.translated_text;
messageDiv.appendChild(translatedText);
e.target.remove(); // Remove the translate link
} else {
alert('Error al traducir el mensaje: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error de red al traducir el mensaje.');
});
}
});
});
</script>

81
check_webhook.php Executable file
View File

@@ -0,0 +1,81 @@
<?php
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/db.php'; // Necesario para la instancia de PDO
require_once __DIR__ . '/src/TelegramSender.php';
$botToken = $_ENV['TELEGRAM_BOT_TOKEN'];
$webhookUrl = 'https://' . $_SERVER['HTTP_HOST'] . '/telegram_bot_webhook.php?auth_token=' . urlencode($_ENV['TELEGRAM_WEBHOOK_TOKEN']);
// Instanciar TelegramSender con la conexión PDO
$telegramSender = new TelegramSender($botToken, $pdo); // $pdo debe estar disponible desde includes/db.php
// Verificar el webhook actual
$apiUrl = "https://api.telegram.org/bot{$botToken}/getWebhookInfo";
$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
header('Content-Type: text/plain');
echo "=== Estado actual del webhook ===\n";
if ($httpCode === 200) {
$result = json_decode($response, true);
echo "URL actual: " . ($result['result']['url'] ?? 'No configurado') . "\n";
echo "Tiene certificado: " . ($result['result']['has_custom_certificate'] ? 'Sí' : 'No') . "\n";
echo "Último error: " . ($result['result']['last_error_message'] ?? 'Ninguno') . "\n";
echo "\n";
} else {
echo "Error al obtener información del webhook: " . $error . "\n";
}
// Configurar el webhook
echo "\n=== Configurando webhook ===\n";
$apiUrl = "https://api.telegram.org/bot{$botToken}/setWebhook";
$postData = [
'url' => $webhookUrl,
'max_connections' => 40,
'allowed_updates' => ['message', 'callback_query']
];
$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($httpCode === 200) {
$result = json_decode($response, true);
if ($result['ok'] === true) {
echo "✅ Webhook configurado correctamente en: " . $webhookUrl . "\n";
} else {
echo "❌ Error al configurar el webhook: " . ($result['description'] ?? 'Error desconocido') . "\n";
}
} else {
echo "❌ Error HTTP al configurar el webhook: " . $httpCode . " - " . $error . "\n";
if ($response) {
echo "Respuesta: " . $response . "\n";
}
}
echo "\n=== Prueba de envío de mensaje ===\n";
$chatId = 'YOUR_CHAT_ID'; // Reemplaza con tu ID de chat de Telegram
$message = "✅ Webhook configurado correctamente en: " . $webhookUrl;
// Enviar el mensaje usando TelegramSender
$response = $telegramSender->sendMessage($chatId, $message);
if ($response) {
echo "✅ Mensaje de prueba enviado correctamente a tu chat de Telegram.\n";
} else {
echo "❌ No se pudo enviar el mensaje de prueba. Asegúrate de que el bot te haya enviado un mensaje primero.\n";
if ($error) {
echo "Error: " . $error . "\n";
}
if ($response) {
echo "Respuesta: " . json_encode($response) . "\n";
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* Factory para crear Converters según la plataforma
*
* Este factory simplifica la creación de converters HTML para diferentes plataformas
* usando la nueva estructura de directorios.
*
* Ubicación: /common/helpers/converter_factory.php
* Fecha de creación: 2025-11-25
*/
class ConverterFactory {
/**
* Crea un converter para la plataforma especificada
*
* @param string $platform 'telegram' o 'discord'
* @return HtmlToTelegramHtmlConverter|HtmlToDiscordMarkdownConverter
* @throws Exception Si la plataforma no es soportada
*/
public static function create($platform) {
switch(strtolower($platform)) {
case 'telegram':
require_once __DIR__ . '/../../telegram/converters/HtmlToTelegramHtmlConverter.php';
return new HtmlToTelegramHtmlConverter();
case 'discord':
require_once __DIR__ . '/../../discord/converters/HtmlToDiscordMarkdownConverter.php';
return new HtmlToDiscordMarkdownConverter();
default:
throw new Exception("Plataforma no soportada: $platform. Use 'telegram' o 'discord'");
}
}
/**
* Convierte HTML a formato específico de plataforma
*
* @param string $html Contenido HTML a convertir
* @param string $platform Plataforma destino
* @return string Contenido convertido
*/
public static function convert($html, $platform) {
$converter = self::create($platform);
return $converter->convert($html);
}
/**
* Verifica si una plataforma es soportada
*
* @param string $platform
* @return bool
*/
public static function isSupported($platform) {
return in_array(strtolower($platform), ['telegram', 'discord']);
}
}

547
common/helpers/emojis.php Executable file
View File

@@ -0,0 +1,547 @@
<?php
/**
* Funcionalidad para el manejo de emojis en el editor Summernote
*/
// Limpiar cualquier salida previa
while (ob_get_level() > 0) {
ob_end_clean();
}
// Establecer el tipo de contenido como JavaScript
header('Content-Type: application/javascript; charset=utf-8');
// Evitar caché
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
// Iniciar sesión si no está iniciada
if (session_status() === PHP_SESSION_NONE) {
session_start(['read_and_close' => true]);
}
// Inicializar array de emojis personalizados si no existe
if (!isset($_SESSION['custom_emojis'])) {
$_SESSION['custom_emojis'] = [];
}
// Si es una petición POST para agregar un emoji personalizado
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['emoji'])) {
$emoji = trim($_POST['emoji']);
if (!empty($emoji) && !in_array($emoji, $_SESSION['custom_emojis'] ?? [])) {
array_unshift($_SESSION['custom_emojis'], $emoji);
$_SESSION['custom_emojis'] = array_slice($_SESSION['custom_emojis'], 0, 50);
echo json_encode(['success' => true, 'message' => 'Emoji guardado correctamente']);
} else {
echo json_encode(['success' => false, 'message' => 'No se pudo guardar el emoji']);
}
exit;
}
// Si llegamos aquí, es una petición GET para cargar los emojis
// Generamos el código JavaScript directamente
echo "// Asegurarse de que la función no se defina múltiples veces\n";
echo "if (typeof window.setupEmojiHandlers === 'undefined') {\n";
echo " // Función para manejar los eventos de los emojis\n";
echo " window.setupEmojiHandlers = function() {\n";
echo " console.log('Inicializando manejadores de emojis...');\n\n";
echo " // Función para manejar clics en emojis\n";
echo " function handleEmojiClick(e) {\n";
echo " const emoji = e.target.closest('.emoji-item');\n";
echo " if (!emoji) return;\n\n";
echo " e.preventDefault();\n";
echo " e.stopPropagation();\n\n";
echo " const emojiChar = emoji.getAttribute('data-emoji');\n";
echo " if (!emojiChar) return;\n\n";
echo " console.log('Emoji seleccionado:', emojiChar);\n\n";
echo " // Intentar encontrar el editor en diferentes contextos\n";
echo " const contexts = [window, window.parent, window.opener, window.top];\n";
echo " let emojiInserted = false;\n\n";
echo " for (const ctx of contexts) {\n";
echo " try {\n";
echo " if (ctx && ctx.$ && ctx.$('#messageContent').length > 0) {\n";
echo " ctx.$('#messageContent').summernote('editor.saveRange');\n";
echo " ctx.$('#messageContent').summernote('editor.restoreRange');\n";
echo " ctx.$('#messageContent').summernote('editor.focus');\n";
echo " ctx.$('#messageContent').summernote('editor.insertText', emojiChar);\n";
echo " emojiInserted = true;\n";
echo " break;\n";
echo " }\n";
echo " } catch (e) {\n";
echo " console.error('Error al insertar emoji en contexto:', e);\n";
echo " }\n";
echo " }\n\n";
echo " if (!emojiInserted) {\n";
echo " console.warn('No se pudo insertar el emoji: No se encontró el editor Summernote');\n";
echo " alert('No se pudo insertar el emoji. Asegúrate de que el editor esté disponible.');\n";
echo " }\n\n";
echo " // Cerrar el modal después de seleccionar un emoji\n";
echo " $('#emojiModal').modal('hide');\n";
echo " }\n\n";
echo " // Función para manejar clics en pestañas\n";
echo " function handleTabClick(e) {\n";
echo " const tab = e.target.closest('.emoji-tab');\n";
echo " if (!tab) return;\n\n";
echo " e.preventDefault();\n";
echo " e.stopPropagation();\n\n";
echo " const category = tab.getAttribute('data-category');\n";
echo " if (!category) return;\n\n";
echo " console.log('Cambiando a categoría:', category);\n\n";
echo " // Actualizar pestaña activa\n";
echo " document.querySelectorAll('.emoji-tab').forEach(function(t) {\n";
echo " t.classList.remove('btn-primary');\n";
echo " t.classList.add('btn-outline-secondary');\n";
echo " });\n\n";
echo " tab.classList.remove('btn-outline-secondary');\n";
echo " tab.classList.add('btn-primary');\n\n";
echo " // Mostrar la categoría seleccionada\n";
echo " document.querySelectorAll('.emoji-category').forEach(function(cat) {\n";
echo " cat.classList.add('d-none');\n";
echo " });\n\n";
echo " const targetCategory = document.getElementById('emoji-' + category);\n";
echo " if (targetCategory) {\n";
echo " targetCategory.classList.remove('d-none');\n";
echo " }\n";
echo " }\n\n";
echo " // Eliminar manejadores anteriores para evitar duplicados\n";
echo " document.removeEventListener('click', handleEmojiClick);\n";
echo " document.removeEventListener('click', handleTabClick);\n\n";
echo " // Agregar nuevos manejadores\n";
echo " document.addEventListener('click', handleEmojiClick);\n";
echo " document.addEventListener('click', handleTabClick);\n\n";
echo " // Activar la primera pestaña por defecto si no hay ninguna activa\n";
echo " const activeTab = document.querySelector('.emoji-tab.btn-primary');\n";
echo " if (!activeTab) {\n";
echo " const firstTab = document.querySelector('.emoji-tab');\n";
echo " if (firstTab) {\n";
echo " firstTab.click();\n";
echo " }\n";
echo " }\n";
echo " }; // Cierre de la función setupEmojiHandlers\n";
echo "} // Cierre del if que verifica si la función ya está definida\n\n";
// Lista de emojis organizados por categorías
$emojis = [
'caritas' => ['😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🤩'],
'manos' => ['👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '🤲', '🤝', '🙏'],
'simbolos' => ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐'],
'objetos' => ['📱', '📲', '💻', '⌨️', '🖥️', '🖨️', '🖱️', '🖲️', '🕹️', '⌚', '📷', '📸', '📹', '🎥', '📽️', '🎞️', '📞', '☎️', '📟', '📠', '📺', '📻', '🎙️', '🎚️', '🎛️', '🧭', '⏱️', '⏲️', '⏰', '🕰️'],
'naturaleza' => ['🐵', '🐒', '🦍', '🦧', '🐶', '🐕', '🦮', '🐕‍🦺', '🐩', '🐺', '🦊', '🦝', '🐱', '🐈', '🦁', '🐯', '🐅', '🐆', '🐴', '🐎', '🦄', '🦓', '🦌', '🐮', '🐂', '🐃', '🐄', '🐷', '🐖', '🐗']
];
// Agregar emojis personalizados si existen
if (!empty($_SESSION['custom_emojis'])) {
$emojis['personalizados'] = $_SESSION['custom_emojis'];
} else {
$emojis['personalizados'] = [];
}
// Nombres de categorías más amigables
$categoryNames = [
'caritas' => 'Caritas',
'manos' => 'Manos',
'simbolos' => 'Símbolos',
'objetos' => 'Objetos',
'naturaleza' => 'Naturaleza',
'personalizados' => 'Mis Emojis'
];
// Manejar la adición de un nuevo emoji personalizado
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['emoji'])) {
// Limpiar el buffer de salida
while (ob_get_level() > 0) {
ob_end_clean();
}
// Establecer el tipo de contenido como JSON
header('Content-Type: application/json');
// Validar y limpiar el emoji
$emoji = trim($_POST['emoji']);
// Validar que el emoji no esté vacío
if (empty($emoji)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'El emoji no puede estar vacío']);
exit;
}
// Iniciar la sesión si no está iniciada
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Inicializar el array de emojis personalizados si no existe
if (!isset($_SESSION['custom_emojis'])) {
$_SESSION['custom_emojis'] = [];
}
// Verificar si el emoji ya existe
if (!in_array($emoji, $_SESSION['custom_emojis'])) {
// Agregar el nuevo emoji al inicio del array
array_unshift($_SESSION['custom_emojis'], $emoji);
// Mantener solo los últimos 50 emojis
$_SESSION['custom_emojis'] = array_slice($_SESSION['custom_emojis'], 0, 50);
// Devolver éxito
echo json_encode([
'success' => true,
'message' => 'Emoji guardado correctamente',
'emoji' => $emoji
]);
} else {
// El emoji ya existe
echo json_encode([
'success' => false,
'message' => 'Este emoji ya existe en tu colección'
]);
}
// Terminar la ejecución
exit;
}
?>
<div class="container-fluid p-0">
<!-- Campo para emoji personalizado -->
<div class="p-3 border-bottom">
<div class="input-group mb-2">
<input type="text" id="customEmojiInput" class="form-control" placeholder="Pega o escribe un emoji aquí">
<button class="btn btn-primary" id="addCustomEmoji" type="button">
<i class="bi bi-plus-lg"></i> Agregar
</button>
</div>
<small class="text-muted">Puedes pegar cualquier emoji o carácter especial</small>
</div>
<!-- Pestañas de categorías -->
<div class="emoji-tabs d-flex flex-wrap border-bottom px-3 pt-2">
<?php $first = true; ?>
<?php foreach (array_keys($emojis) as $category): ?>
<button type="button"
class="emoji-tab btn btn-sm me-1 mb-1 <?php echo $first ? 'btn-primary' : 'btn-outline-secondary'; ?>"
data-category="<?php echo $category; ?>">
<?php echo $categoryNames[$category] ?? ucfirst($category); ?>
</button>
<?php $first = false; ?>
<?php endforeach; ?>
</div>
<!-- Contenido de emojis por categoría -->
<div class="emoji-categories">
<?php $first = true; ?>
<?php foreach ($emojis as $category => $emojiList): ?>
<div class="emoji-category <?php echo $first ? 'd-block' : 'd-none'; ?>" id="emoji-<?php echo $category; ?>">
<div class="d-flex flex-wrap">
<?php
foreach ($emojiList as $emoji):
// Determinar si es un emoji largo (más de 2 caracteres)
$isLongEmoji = mb_strlen($emoji) > 2;
$emojiClass = $isLongEmoji ? 'long-emoji' : '';
?>
<span class="emoji-option d-flex align-items-center justify-content-center m-1 <?php echo $emojiClass; ?>"
data-emoji="<?php echo htmlspecialchars($emoji); ?>"
title="<?php echo htmlspecialchars($emoji); ?>">
<?php echo $emoji; ?>
</span>
<?php endforeach; ?>
</div>
</div>
<?php $first = false; ?>
<?php endforeach; ?>
</div>
</div>
<style>
#customEmojiInput {
font-size: 1.2em;
height: 45px;
width: 100%;
box-sizing: border-box;
}
.emoji-option {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
max-width: 100%;
height: 40px;
font-size: 20px;
line-height: 1.2;
cursor: pointer;
border-radius: 8px;
margin: 4px;
padding: 4px;
transition: all 0.2s;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
background-color: #ffffff;
border: 1px solid #e0e0e0;
box-sizing: border-box;
text-align: center;
}
.emoji-option:hover {
background-color: #f0f0f0;
transform: scale(1.1);
z-index: 10;
position: relative;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
/* Estilo para el contenedor de emojis */
.emoji-categories {
max-height: 300px;
overflow-y: auto;
padding: 8px;
}
/* Ajustes para emojis largos */
.emoji-option.long-emoji {
font-size: 16px;
padding: 2px;
white-space: normal;
line-height: 1.1;
word-break: break-word;
overflow-wrap: break-word;
display: inline-flex;
align-items: center;
justify-content: center;
}
</style>
<script>
// Manejar el botón de agregar emoji personalizado
document.getElementById('addCustomEmoji').addEventListener('click', async function() {
const emojiInput = document.getElementById('customEmojiInput');
const emoji = emojiInput.value.trim();
const button = this; // Mover la declaración de button al inicio
let originalText = button.innerHTML; // Usar let en lugar de const
if (emoji) {
try {
// Mostrar indicador de carga
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Guardando...';
// Guardar el emoji en la sesión
const response = await fetch('includes/emojis.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'emoji=' + encodeURIComponent(emoji),
credentials: 'same-origin' // Asegurar que se envíen las cookies de sesión
});
const result = await response.json();
if (result.success) {
// Insertar el emoji
if (window.parent && window.parent.insertEmoji) {
window.parent.insertEmoji(emoji + ' ');
// Recargar solo los emojis para actualizar la lista
const emojiContainer = document.querySelector('.emoji-categories');
if (emojiContainer) {
document.addEventListener('DOMContentLoaded', function() {
// Inicializar manejadores
initEmojiHandlers();
// Enfocar el campo de entrada
const emojiInput = document.getElementById('customEmojiInput');
if (emojiInput) {
emojiInput.focus();
}
});
// Inicializar manejadores de eventos
function initEmojiHandlers() {
// Manejador para agregar emojis personalizados
const addButton = document.getElementById('addCustomEmoji');
const emojiInput = document.getElementById('customEmojiInput');
if (addButton && emojiInput) {
// Eliminar manejadores anteriores para evitar duplicados
addButton.replaceWith(addButton.cloneNode(true));
emojiInput.replaceWith(emojiInput.cloneNode(true));
// Manejador para el botón de agregar
document.getElementById('addCustomEmoji').addEventListener('click', handleAddEmoji);
// Manejador para la tecla Enter
document.getElementById('customEmojiInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
handleAddEmoji();
}
});
}
// Inicializar manejadores de emojis y pestañas
setupEmojiHandlers();
}
// Manejar la adición de un nuevo emoji
async function handleAddEmoji() {
const emojiInput = document.getElementById('customEmojiInput');
const button = document.getElementById('addCustomEmoji');
const emoji = emojiInput.value.trim();
if (!emoji) {
showAlert('Por favor, ingresa un emoji', 'warning');
return;
}
// Deshabilitar el botón y mostrar indicador de carga
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Guardando...';
try {
// Usar una ruta relativa desde la ubicación actual
const response = await fetch(window.location.pathname, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'emoji=' + encodeURIComponent(emoji)
});
if (!response.ok) {
throw new Error(`Error HTTP: ${response.status}`);
foreach ($items as $emoji) {
$emojiHtml = htmlspecialchars($emoji);
$html .= "<button type=\"button\" class=\"btn btn-outline-secondary btn-sm emoji-item\" data-emoji=\"{$emojiHtml}\">{$emojiHtml}</button>";
}
$html .= '</div><hr class="my-3"></div>';
}
}
$html .= '</div>';
// Formulario para agregar emojis personalizados
$html .= '<div class="p-3 border-top">';
$html .= '<form id="addCustomEmojiForm" class="d-flex gap-2">';
$html .= '<input type="text" id="customEmojiInput" class="form-control form-control-sm" placeholder="Pega un emoji aquí..." required>';
$html .= '<button type="submit" class="btn btn-primary btn-sm">Agregar</button>';
$html .= '</form></div></div>';
// Devolver el HTML como una cadena de JavaScript
$js = "
document.getElementById('emojiContainer').innerHTML = " . json_encode($html) . ";
";
// Agregar el código JavaScript
$js .= "
// Asegurarse de que la función no se defina múltiples veces
if (typeof window.setupEmojiHandlers === 'undefined') {
// Función para manejar los eventos de los emojis
window.setupEmojiHandlers = function() {
console.log('Inicializando manejadores de emojis...');
// Función para manejar clics en emojis
function handleEmojiClick(e) {
const emoji = e.target.closest('.emoji-item');
if (!emoji) return;
e.preventDefault();
e.stopPropagation();
const emojiChar = emoji.getAttribute('data-emoji');
if (!emojiChar) return;
console.log('Emoji seleccionado:', emojiChar);
// Intentar encontrar el editor en diferentes contextos
const contexts = [window, window.parent, window.opener, window.top];
let emojiInserted = false;
for (const ctx of contexts) {
try {
if (ctx && ctx.$ && ctx.$('#messageContent').length > 0) {
ctx.$('#messageContent').summernote('editor.saveRange');
ctx.$('#messageContent').summernote('editor.restoreRange');
ctx.$('#messageContent').summernote('editor.focus');
ctx.$('#messageContent').summernote('editor.insertText', emojiChar);
emojiInserted = true;
break;
}
} catch (e) {
console.error('Error al insertar emoji en contexto:', e);
}
}
if (!emojiInserted) {
console.warn('No se pudo insertar el emoji: No se encontró el editor Summernote');
alert('No se pudo insertar el emoji. Asegúrate de que el editor esté disponible.');
}
// Cerrar el modal después de seleccionar un emoji
$('#emojiModal').modal('hide');
}
// Función para manejar clics en pestañas
function handleTabClick(e) {
const tab = e.target.closest('.emoji-tab');
if (!tab) return;
e.preventDefault();
e.stopPropagation();
const category = tab.getAttribute('data-category');
if (!category) return;
console.log('Cambiando a categoría:', category);
// Actualizar pestaña activa
document.querySelectorAll('.emoji-tab').forEach(function(t) {
t.classList.remove('btn-primary');
t.classList.add('btn-outline-secondary');
});
tab.classList.remove('btn-outline-secondary');
tab.classList.add('btn-primary');
// Mostrar la categoría seleccionada
document.querySelectorAll('.emoji-category').forEach(function(cat) {
cat.classList.add('d-none');
});
const targetCategory = document.getElementById('emoji-' + category);
if (targetCategory) {
targetCategory.classList.remove('d-none');
}
}
// Eliminar manejadores anteriores para evitar duplicados
document.removeEventListener('click', handleEmojiClick);
document.removeEventListener('click', handleTabClick);
// Agregar nuevos manejadores
document.addEventListener('click', handleEmojiClick);
document.addEventListener('click', handleTabClick);
// Activar la primera pestaña por defecto si no hay ninguna activa
const activeTab = document.querySelector('.emoji-tab.btn-primary');
if (!activeTab) {
const firstTab = document.querySelector('.emoji-tab');
if (firstTab) {
firstTab.click();
}
}
};
}
// Inicializar los manejadores de eventos
if (typeof setupEmojiHandlers === 'function') {
setupEmojiHandlers();
}";
// Devolver el código JavaScript
echo $js;

View File

@@ -0,0 +1,49 @@
<?php
if (!function_exists('calculateNextSendTime')) {
/**
* Calcula la próxima fecha y hora de envío para un mensaje recurrente.
*
* @param array $recurringDays Un array de enteros representando los días de la semana (0=Domingo, 6=Sábado).
* @param string $recurringTime La hora del día en formato HH:MM.
* @param string $timezone La zona horaria a utilizar.
* @return string La próxima fecha y hora de envío en formato Y-m-d H:i:s.
* @throws Exception Si no se puede determinar la próxima fecha de envío.
*/
function calculateNextSendTime(array $recurringDays, string $recurringTime, string $timezone = 'America/Mexico_City'): string {
$tz = new DateTimeZone($timezone);
$now = new DateTime('now', $tz);
list($hour, $minute) = explode(':', $recurringTime);
sort($recurringDays); // Ensure days are sorted
$nextSendTime = null;
// 1. Check for today's occurrence first
$todayDayOfWeek = (int)$now->format('w');
$potentialTodaySendTime = (clone $now)->setTime((int)$hour, (int)$minute, 0);
if (in_array($todayDayOfWeek, $recurringDays) && $potentialTodaySendTime > $now) {
// If today is a recurring day and the time hasn't passed yet, this is the next send time
$nextSendTime = $potentialTodaySendTime;
} else {
// 2. Look for the next recurring day, starting from tomorrow
for ($i = 1; $i <= 7; $i++) { // Check next 7 days
$checkDate = (clone $now)->modify("+$i days");
$dayOfWeek = (int)$checkDate->format('w');
if (in_array($dayOfWeek, $recurringDays)) {
$nextSendTime = (clone $checkDate)->setTime((int)$hour, (int)$minute, 0);
break; // Found the earliest future recurring day
}
}
}
if ($nextSendTime === null) {
throw new Exception('No se pudo determinar la próxima fecha de envío para la configuración recurrente proporcionada.');
}
return $nextSendTime->format('Y-m-d H:i:s');
}
}
?>

View File

@@ -0,0 +1,58 @@
<?php
/**
* Factory para crear Senders según la plataforma
*
* Este factory simplifica la creación de senders para diferentes plataformas
* usando la nueva estructura de directorios.
*
* Ubicación: /common/helpers/sender_factory.php
* Fecha de creación: 2025-11-25
*/
class SenderFactory {
/**
* Crea un sender para la plataforma especificada
*
* @param string $platform 'telegram' o 'discord'
* @param PDO $pdo Conexión a base de datos (requerida para Telegram)
* @return TelegramSender|DiscordSender
* @throws Exception Si la plataforma no es soportada
*/
public static function create($platform, $pdo = null) {
switch(strtolower($platform)) {
case 'telegram':
if ($pdo === null) {
throw new Exception("PDO es requerido para TelegramSender");
}
require_once __DIR__ . '/../../telegram/TelegramSender.php';
return new TelegramSender(TELEGRAM_BOT_TOKEN, $pdo);
case 'discord':
require_once __DIR__ . '/../../discord/DiscordSender.php';
return new DiscordSender(DISCORD_BOT_TOKEN);
default:
throw new Exception("Plataforma no soportada: $platform. Use 'telegram' o 'discord'");
}
}
/**
* Verifica si una plataforma es soportada
*
* @param string $platform
* @return bool
*/
public static function isSupported($platform) {
return in_array(strtolower($platform), ['telegram', 'discord']);
}
/**
* Obtiene lista de plataformas soportadas
*
* @return array
*/
public static function getSupportedPlatforms() {
return ['telegram', 'discord'];
}
}

19
composer.json Executable file
View File

@@ -0,0 +1,19 @@
{
"require": {
"team-reflex/discord-php": "^7.0",
"firebase/php-jwt": "^6.0",
"monolog/monolog": "^3.9",
"slim/slim": "^4.0",
"react/promise-timer": "^1.8",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {
"psr-4": {
"App\\": "api/src/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
}
}

3132
composer.lock generated Executable file

File diff suppressed because it is too large Load Diff

13
config/discordbot.conf Executable file
View File

@@ -0,0 +1,13 @@
# Configuración actualizada de Supervisor para Discord Bot
# Usar: sudo cp config/discordbot_updated.conf /etc/supervisor/conf.d/discordbot.conf
[program:discordbot]
command=/usr/bin/php /var/www/html/bot/discord/bot/discord_bot.php
environment=APP_ENVIRONMENT="reod"
directory=/var/www/html/bot/
autostart=true
autorestart=true
stderr_logfile=/var/www/html/bot/logs/discordbot.err.log
stdout_logfile=/var/www/html/bot/logs/discordbot.out.log
user=www-data
redirect_stderr=false

13
config/discordbot_updated.conf Executable file
View File

@@ -0,0 +1,13 @@
# Configuración actualizada de Supervisor para Discord Bot
# Usar: sudo cp config/discordbot_updated.conf /etc/supervisor/conf.d/discordbot.conf
[program:discordbot]
command=/usr/bin/php /var/www/html/bot/discord/bot/discord_bot.php
environment=APP_ENVIRONMENT="reod"
directory=/var/www/html/bot/
autostart=true
autorestart=true
stderr_logfile=/var/www/html/bot/logs/discordbot.err.log
stdout_logfile=/var/www/html/bot/logs/discordbot.out.log
user=www-data
redirect_stderr=false

11
config/translation-worker.conf Executable file
View File

@@ -0,0 +1,11 @@
[program:translation-worker]
command=/usr/bin/php /var/www/html/bot/process_translation_queue.php
directory=/var/www/html/bot/
user=www-data
autostart=true
autorestart=true
environment=APP_ENVIRONMENT="pruebas"
stdout_logfile=/var/www/html/bot/logs/translation-worker.out.log
stderr_logfile=/var/www/html/bot/logs/translation-worker.err.log
redirect_stderr=true
numprocs=1

51
configure_webhook.php Executable file
View File

@@ -0,0 +1,51 @@
<?php
// Configuración
$botToken = '8469229183:AAEVIV5e7rjDXKNgFTX0dnCW6JWB88X4p2I';
$webhookToken = 'webhook_secure_token_12345';
$domain = 'pruebaspons.duckdns.org';
$webhookUrl = "https://{$domain}/bot/telegram_bot_webhook.php?auth_token=" . urlencode($webhookToken);
// 1. Eliminar webhook existente
$apiUrl = "https://api.telegram.org/bot{$botToken}/deleteWebhook";
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true]);
$response = curl_exec($ch);
curl_close($ch);
echo "Webhook eliminado. Configurando nuevo webhook...\n";
// 2. Configurar nuevo webhook
$apiUrl = "https://api.telegram.org/bot{$botToken}/setWebhook";
$postData = [
'url' => $webhookUrl,
'max_connections' => 40,
'allowed_updates' => json_encode(['message', 'callback_query'])
];
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_TIMEOUT => 10
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$result = json_decode($response, true);
curl_close($ch);
// Mostrar resultados
echo "Respuesta (HTTP $httpCode): ";
echo ($result['ok'] ?? false) ? "✅ Éxito" : "❌ Error: " . ($result['description'] ?? 'Desconocido');
echo "\nURL: " . ($result['result']['url'] ?? 'N/A') . "\n";
// 3. Verificar configuración actual
$apiUrl = "https://api.telegram.org/bot{$botToken}/getWebhookInfo";
$result = json_decode(file_get_contents($apiUrl), true);
if ($result['ok'] ?? false) {
echo "\nEstado actual del webhook:\n";
echo "URL: " . ($result['result']['url'] ?? 'No configurado') . "\n";
echo "Errores recientes: " . ($result['result']['last_error_message'] ?? 'Ninguno') . "\n";
}

621
create_message.php Executable file
View File

@@ -0,0 +1,621 @@
<?php
require_once __DIR__ . '/includes/session_check.php';
require_once __DIR__ . '/includes/db.php';
// Fetch all recipients and group by platform
$recipients_by_platform = ['discord' => ['channels' => [], 'users' => []], 'telegram' => ['channels' => [], 'users' => []]];
try {
$stmt = $pdo->query("SELECT id, name, type, platform FROM recipients ORDER BY platform, type, name ASC");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipients_by_platform[$row['platform']])) {
if ($row['type'] === 'channel') {
$recipients_by_platform[$row['platform']]['channels'][] = $row;
} else {
$recipients_by_platform[$row['platform']]['users'][] = $row;
}
}
}
} catch (PDOException $e) {
die("Error: No se pudieron cargar los destinatarios: " . $e->getMessage());
}
// Fetch recurrent message templates
$templates = [];
try {
$stmt = $pdo->query("SELECT id, name, message_content FROM recurrent_messages ORDER BY name ASC");
$templates = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die("Error: No se pudieron cargar las plantillas de mensajes recurrentes.");
}
// Initialize variables
$isEditing = false;
$scheduleId = null;
$pageTitle = 'Crear Notificación';
$submitButtonText = 'Guardar y Enviar';
$submitButtonIcon = 'bi-send-fill';
$submitAction = 'send';
$platform = 'discord'; // Default platform
$reuseContent = '';
$recipientId = '';
$recipientType = 'channel';
$scheduleType = 'now';
$recurringDays = [];
// Set default times in Mexico City timezone
$timezone = new DateTimeZone('America/Mexico_City');
$now = new DateTime('now', $timezone);
$recurringTime = $now->format('H:i');
$scheduleDateTime = $now->format('Y-m-d\TH:i');
// Check for reuse action from sent_messages.php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'reuse') {
$reuseContent = $_POST['messageContent'] ?? '';
$recipientId = $_POST['recipientId'] ?? '';
$recipientType = $_POST['recipientType'] ?? 'channel';
// Set schedule to default for a new message
$scheduleType = 'now';
// Set default schedule time to current time in Mexico City timezone
$timezone = new DateTimeZone('America/Mexico_City');
$now = new DateTime('now', $timezone);
$scheduleDateTime = $now->format('Y-m-d\TH:i');
$recurringDays = [];
$recurringTime = $now->format('H:i');
}
// Check for edit action
if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['schedule_id'])) {
$isEditing = true;
$scheduleId = $_GET['schedule_id'];
$pageTitle = 'Editar Notificación';
$submitButtonText = 'Actualizar Mensaje';
$submitButtonIcon = 'bi-pencil-square';
$submitAction = 'update';
$stmt = $pdo->prepare(
"SELECT
s.recipient_id,
s.send_time,
s.is_recurring,
s.recurring_days,
s.recurring_time,
m.content,
r.type as recipient_type,
r.platform
FROM schedules s
JOIN messages m ON s.message_id = m.id
JOIN recipients r ON s.recipient_id = r.id
WHERE s.id = ?"
);
$stmt->execute([$scheduleId]);
$message_data = $stmt->fetch(PDO::FETCH_ASSOC);
if ($message_data) {
$reuseContent = $message_data['content'];
$recipientId = $message_data['recipient_id'];
$recipientType = $message_data['recipient_type'];
$platform = $message_data['platform'];
if ($message_data['is_recurring']) {
$scheduleType = 'recurring';
$recurringDays = !empty($message_data['recurring_days']) ? explode(',', $message_data['recurring_days']) : [];
$recurringTime = substr($message_data['recurring_time'], 0, 5);
} else {
$scheduleType = 'later';
if ($message_data['send_time']) {
$scheduleDateTime = date('Y-m-d\TH:i', strtotime($message_data['send_time']));
}
}
} else {
die("Error: Mensaje programado no encontrado.");
}
}
require_once __DIR__ . '/templates/header.php';
?>
<!-- Summernote CSS -->
<link href="<?php echo asset('css/summernote-bs5.min.css'); ?>" rel="stylesheet">
<div class="container-fluid">
<h1 data-translate="true" class="mt-4" ><?php echo $pageTitle; ?></h1>
<form action="includes/message_handler.php" method="POST" id="createMessageForm">
<?php if ($isEditing): ?>
<input type="hidden" name="schedule_id" value="<?php echo htmlspecialchars($scheduleId); ?>">
<?php endif; ?>
<!-- Template Selection -->
<div class="mb-3">
<label for="templateSelector" class="form-label" data-translate="true">Cargar desde Plantilla</label>
<select class="form-select" id="templateSelector">
<option value="" data-translate="true">-- Selecciona una plantilla --</option>
<?php foreach ($templates as $template): ?>
<option value="<?php echo $template['id']; ?>" data-content="<?php echo htmlspecialchars($template['message_content']); ?>">
<?php echo htmlspecialchars($template['name']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- Message Content -->
<div class="mb-3">
<label data-translate="true" for="messageContent" class="form-label">Contenido del Mensaje</label>
<textarea id="messageContent" name="messageContent" class="form-control"><?php echo htmlspecialchars($reuseContent); ?></textarea>
<div id="characterCount" class="text-muted text-end mt-1">0 caracteres</div>
</div>
<!-- Translation Option -->
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableTranslation" name="enableTranslation" value="true" checked>
<label class="form-check-label" for="enableTranslation" data-translate="true">Habilitar traducción automática (Español/Portugués)</label>
</div>
<!-- Platform Selection -->
<div class="mb-3">
<label data-translate="true" class="form-label">Plataforma</label>
<div>
<input type="radio" id="platformDiscord" name="platform" value="discord" <?php echo ($platform === 'discord') ? 'checked' : ''; ?>>
<label for="platformDiscord" class="me-3" data-translate="true">Discord</label>
<input type="radio" id="platformTelegram" name="platform" value="telegram" <?php echo ($platform === 'telegram') ? 'checked' : ''; ?>>
<label for="platformTelegram" data-translate="true">Telegram</label>
</div>
</div>
<!-- Recipient Selection -->
<div class="mb-3">
<label data-translate="true" class="form-label">Tipo de Destinatario</label>
<div>
<input type="radio" id="recipientTypeChannel" name="recipientType" value="channel" <?php echo $recipientType === 'channel' ? 'checked' : ''; ?>>
<label for="recipientTypeChannel" class="me-3" data-translate="true">Canal</label>
<input type="radio" id="recipientTypeUser" name="recipientType" value="user" <?php echo $recipientType === 'user' ? 'checked' : ''; ?>>
<label for="recipientTypeUser" data-translate="true">Usuario</label>
</div>
</div>
<!-- Channel/User Selection -->
<div class="mb-3">
<label data-translate="true" for="recipientId" class="form-label">Seleccionar Destinatario</label>
<div id="discord-recipients">
<select class="form-select" id="recipientId_discord_channel" name="recipientId_discord_channel">
<option value="" data-translate="true">Selecciona un canal de Discord</option>
<?php foreach ($recipients_by_platform['discord']['channels'] as $channel): ?>
<option value="<?php echo $channel['id']; ?>"><?php echo htmlspecialchars($channel['name']); ?></option>
<?php endforeach; ?>
</select>
<select class="form-select d-none" id="recipientId_discord_user" name="recipientId_discord_user[]" multiple>
<option value="" data-translate="true">Selecciona un usuario de Discord</option>
<?php foreach ($recipients_by_platform['discord']['users'] as $user): ?>
<option value="<?php echo $user['id']; ?>"><?php echo htmlspecialchars($user['name']); ?></option>
<?php endforeach; ?>
</select>
</div>
<div id="telegram-recipients" class="d-none">
<select class="form-select" id="recipientId_telegram_channel" name="recipientId_telegram_channel">
<option value="" data-translate="true">Selecciona un canal de Telegram</option>
<?php foreach ($recipients_by_platform['telegram']['channels'] as $channel): ?>
<option value="<?php echo $channel['id']; ?>"><?php echo htmlspecialchars($channel['name']); ?></option>
<?php endforeach; ?>
</select>
<select class="form-select d-none" id="recipientId_telegram_user" name="recipientId_telegram_user[]" multiple>
<option value="" data-translate="true" data-translate="true">Selecciona un usuario de Telegram</option>
<?php foreach ($recipients_by_platform['telegram']['users'] as $user): ?>
<option value="<?php echo $user['id']; ?>"><?php echo htmlspecialchars($user['name']); ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<!-- Schedule Options -->
<div class="mb-3">
<label class="form-label" data-translate="true">Programación</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="scheduleType" id="scheduleNow" value="now" <?php echo $scheduleType === 'now' ? 'checked' : ''; ?>>
<label class="form-check-label" for="scheduleNow">
<span data-translate="true">Enviar ahora</span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="scheduleType" id="scheduleLater" value="later" <?php echo $scheduleType === 'later' ? 'checked' : ''; ?>>
<label class="form-check-label" for="scheduleLater">
<span data-translate="true">Programar para más tarde</span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="scheduleType" id="scheduleRecurring" value="recurring" <?php echo $scheduleType === 'recurring' ? 'checked' : ''; ?>>
<label class="form-check-label" for="scheduleRecurring">
<span data-translate="true">Programación recurrente</span>
</label>
</div>
</div>
<!-- Schedule Later Options -->
<div class="mb-3" id="scheduleLaterOptions" style="display: <?php echo $scheduleType === 'later' ? 'block' : 'none'; ?>;">
<label for="scheduleDateTime" class="form-label" data-translate="true">Fecha y Hora de Envío</label>
<input type="datetime-local" class="form-control" id="scheduleDateTime" name="scheduleDateTime"
min="<?php echo date('Y-m-d\TH:i'); ?>"
value="<?php echo $scheduleDateTime; ?>">
</div>
<!-- Recurring Schedule Options -->
<div class="mb-3" id="recurringOptions" style="display: <?php echo $scheduleType === 'recurring' ? 'block' : 'none'; ?>;">
<div class="mb-2">
<label class="form-label" data-translate="true">Días de la Semana</label>
<div class="d-flex flex-wrap gap-3">
<?php
$days = [
['value' => '1', 'label' => '<span data-translate="true">Lun</span>'],
['value' => '2', 'label' => '<span data-translate="true">Mar</span>'],
['value' => '3', 'label' => '<span data-translate="true">Mié</span>'],
['value' => '4', 'label' => '<span data-translate="true">Jue</span>'],
['value' => '5', 'label' => '<span data-translate="true">Vie</span>'],
['value' => '6', 'label' => '<span data-translate="true">Sáb</span>'],
['value' => '0', 'label' => '<span data-translate="true">Dom</span>']
];
foreach ($days as $day):
$isChecked = in_array($day['value'], $recurringDays) ? 'checked' : '';
?>
<div class="form-check">
<input class="form-check-input day-checkbox" type="checkbox"
name="recurringDays[]" value="<?php echo $day['value']; ?>"
id="day<?php echo $day['value']; ?>" <?php echo $isChecked; ?>>
<label class="form-check-label" for="day<?php echo $day['value']; ?>">
<?php echo $day['label']; ?>
</label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="mb-2">
<label for="recurringTime" class="form-label" data-translate="true">Hora de Envío (cada día seleccionado)</label>
<input type="time" class="form-control" id="recurringTime" name="recurringTime"
value="<?php echo $recurringTime; ?>">
</div>
</div>
<!-- Submit Button -->
<button type="submit" value="<?php echo $submitAction; ?>" class="btn btn-success">
<i class="bi <?php echo $submitButtonIcon; ?> me-1"></i> <?php echo $submitButtonText; ?>
</button>
</form>
</div>
<?php require_once __DIR__ . '/templates/footer.php'; ?>
<!-- Gallery Modal -->
<div class="modal fade" id="galleryModal" tabindex="-1" aria-labelledby="galleryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="galleryModalLabel" data-translate="true">Galería de Imágenes</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<?php
$gallery_path = __DIR__ . '/galeria';
$files = array_diff(scandir($gallery_path), array('.', '..'));
if (empty($files)) {
echo '<p class="text-center text-muted" data-translate="true">No hay imágenes en la galería.</p>';
} else {
foreach ($files as $file) {
if (is_file($gallery_path . '/' . $file)) {
echo '<div class="col-lg-3 col-md-4 col-sm-6 mb-4 text-center"><img src="galeria/' . htmlspecialchars($file) . '" class="img-fluid img-thumbnail gallery-item" style="cursor:pointer;" alt="' . htmlspecialchars($file) . '"><p class="small text-muted mt-1">' . htmlspecialchars($file) . '</p></div>';
}
}
}
?>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
<button type="button" class="btn btn-primary" id="insertImageFromGallery">Insertar Imagen</button>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function() {
// Custom Gallery Button
var GalleryButton = function (context) {
var ui = $.summernote.ui;
var button = ui.button({
contents: '<i class="bi bi-images"></i> Galería',
tooltip: 'Insertar imagen desde la galería',
click: function () {
$('#galleryModal').modal('show');
}
});
return button.render();
}
// Initialize Summernote
$('#messageContent').summernote({
placeholder: 'Escribe tu mensaje aquí...',
tabsize: 2,
height: 300,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['picture']],
['view', ['codeview']],
['mybutton', ['gallery']]
],
buttons: {
gallery: GalleryButton
},
popover: {
image: [
['image', ['resizeFull', 'resizeHalf', 'resizeQuarter', 'resizeNone']],
['float', ['floatLeft', 'floatRight', 'floatNone']],
['remove', ['removeMedia']]
],
link: [
['link', ['linkDialogShow', 'unlink']]
],
table: [
['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']],
['delete', ['deleteRow', 'deleteCol', 'deleteTable']],
],
air: [
['color', ['color']],
['font', ['bold', 'underline', 'clear']],
['para', ['ul', 'paragraph']],
['table', ['table']],
['insert', ['link', 'picture']]
]
},
callbacks: {
onImageUpload: function(files) {
var editor = $(this);
var data = new FormData();
data.append("file", files[0]);
$.ajax({
url: 'upload_editor_image.php',
method: 'POST',
data: data,
processData: false,
contentType: false,
success: function(response) {
if (response.url) {
editor.summernote('insertImage', response.url);
} else {
alert(response.error || 'Error al subir la imagen.');
}
},
error: function() {
alert('Error: No se pudo comunicar con el servidor para subir la imagen.');
}
});
},
onPaste: function(e) {
var bufferText = ((e.originalEvent || e).clipboardData || window.clipboardData).getData('Text');
e.preventDefault();
// Firefox fix
setTimeout(function () {
document.execCommand('insertText', false, bufferText);
}, 10);
},
onKeyup: function(e) {
updateCharacterCount();
},
onInit: function() {
updateCharacterCount();
}
}
});
// Handle template selection
$('#templateSelector').change(function() {
var selectedOption = $(this).find('option:selected');
var content = selectedOption.data('content');
if (content) {
$('#messageContent').summernote('code', content);
} else {
$('#messageContent').summernote('code', ''); // Clear if no content
}
updateCharacterCount(); // Update count after template load
});
// Function to update character count
function updateCharacterCount() {
// Get the text directly from the editable area of Summernote
var text = $('#messageContent').next('.note-editor').find('.note-editable').text();
var count = text.length;
console.log("Summernote text (from note-editable):", text);
console.log("Character count:", count);
$('#characterCount').text(count + ' caracteres');
}
// Handle image selection in modal
$(document).on('click', '.gallery-item', function() {
$(this).toggleClass('border-primary');
});
// Handle image insertion
$('#insertImageFromGallery').click(function() {
$('.gallery-item.border-primary').each(function(){
var imageUrl = $(this).attr('src');
$('#messageContent').summernote('insertImage', imageUrl);
});
$('#galleryModal').modal('hide');
$('.gallery-item').removeClass('border-primary');
});
// Toggle between channel and user selection
const recipientTypeChannel = document.getElementById('recipientTypeChannel');
const recipientTypeUser = document.getElementById('recipientTypeUser');
const platformDiscord = document.getElementById('platformDiscord');
const platformTelegram = document.getElementById('platformTelegram');
const discordRecipients = document.getElementById('discord-recipients');
const telegramRecipients = document.getElementById('telegram-recipients');
const discordChannelSelect = document.getElementById('recipientId_discord_channel');
const discordUserSelect = document.getElementById('recipientId_discord_user');
const telegramChannelSelect = document.getElementById('recipientId_telegram_channel');
const telegramUserSelect = document.getElementById('recipientId_telegram_user');
function updateRecipientVisibility() {
const isDiscord = platformDiscord.checked;
const isTelegram = platformTelegram.checked;
const isChannel = recipientTypeChannel.checked;
const isUser = recipientTypeUser.checked;
// Show/hide platform containers
discordRecipients.classList.toggle('d-none', !isDiscord);
telegramRecipients.classList.toggle('d-none', !isTelegram);
if (isDiscord) {
// Handle Discord recipients
discordChannelSelect.classList.toggle('d-none', !isChannel);
discordChannelSelect.disabled = !isChannel;
discordChannelSelect.required = isChannel;
discordUserSelect.classList.toggle('d-none', !isUser);
discordUserSelect.disabled = !isUser;
discordUserSelect.required = isUser;
// Disable Telegram selects
telegramChannelSelect.disabled = true;
telegramUserSelect.disabled = true;
} else if (isTelegram) {
// Handle Telegram recipients
telegramChannelSelect.classList.toggle('d-none', !isChannel);
telegramChannelSelect.disabled = !isChannel;
telegramChannelSelect.required = isChannel;
telegramUserSelect.classList.toggle('d-none', !isUser);
telegramUserSelect.disabled = !isUser;
telegramUserSelect.required = isUser;
// Disable Discord selects
discordChannelSelect.disabled = true;
discordUserSelect.disabled = true;
}
}
platformDiscord.addEventListener('change', updateRecipientVisibility);
platformTelegram.addEventListener('change', updateRecipientVisibility);
recipientTypeChannel.addEventListener('change', updateRecipientVisibility);
recipientTypeUser.addEventListener('change', updateRecipientVisibility);
// Initial call to set the correct state
updateRecipientVisibility();
// Toggle schedule options
const scheduleNow = document.getElementById('scheduleNow');
const scheduleLater = document.getElementById('scheduleLater');
const scheduleRecurring = document.getElementById('scheduleRecurring');
const scheduleLaterOptions = document.getElementById('scheduleLaterOptions');
const recurringOptions = document.getElementById('recurringOptions');
function updateScheduleOptions() {
const scheduleLaterOptions = document.getElementById('scheduleLaterOptions');
const scheduleDateTime = document.getElementById('scheduleDateTime');
const recurringOptions = document.getElementById('recurringOptions');
const recurringTime = document.getElementById('recurringTime');
const recurringDays = document.querySelectorAll('.day-checkbox');
if (scheduleNow.checked) {
scheduleLaterOptions.style.display = 'none';
scheduleDateTime.disabled = true;
recurringOptions.style.display = 'none';
recurringTime.disabled = true;
recurringDays.forEach(day => day.disabled = true);
} else if (scheduleLater.checked) {
scheduleLaterOptions.style.display = 'block';
scheduleDateTime.disabled = false;
recurringOptions.style.display = 'none';
recurringTime.disabled = true;
recurringDays.forEach(day => day.disabled = true);
} else if (scheduleRecurring.checked) {
scheduleLaterOptions.style.display = 'none';
scheduleDateTime.disabled = true;
recurringOptions.style.display = 'block';
recurringTime.disabled = false;
recurringDays.forEach(day => day.disabled = false);
}
}
if (scheduleNow && scheduleLater && scheduleRecurring) {
scheduleNow.addEventListener('change', updateScheduleOptions);
scheduleLater.addEventListener('change', updateScheduleOptions);
scheduleRecurring.addEventListener('change', updateScheduleOptions);
updateScheduleOptions();
}
// Form validation
const form = document.getElementById('createMessageForm');
if (form) {
form.addEventListener('submit', function(e) {
// Prevent default submission to perform validation and data preparation
e.preventDefault();
// 1. Validate Summernote content
if ($('#messageContent').summernote('isEmpty')) {
alert('El contenido del mensaje no puede estar vacío.');
return false;
}
// Get message content from Summernote
let messageContent = $('#messageContent').summernote('code');
// Check if translation is enabled
const enableTranslationCheckbox = document.getElementById('enableTranslation');
if (enableTranslationCheckbox && enableTranslationCheckbox.checked) {
// Add data-translate attribute to the message content
// This will be picked up by process_queue.php
messageContent = '<div data-translate="true">' + messageContent + '</div>';
}
// Add hidden field for messageContent
$('<input>').attr({ type: 'hidden', name: 'messageContent', value: messageContent }).appendTo(form);
let channelIds = [];
let userIds = [];
const isDiscord = platformDiscord.checked;
const isTelegram = platformTelegram.checked;
const isChannel = recipientTypeChannel.checked;
const isUser = recipientTypeUser.checked;
if (isDiscord) {
if (isChannel) {
channelIds.push(discordChannelSelect.value);
} else if (isUser) {
userIds = $(discordUserSelect).val() || [];
}
} else if (isTelegram) {
if (isChannel) {
channelIds.push(telegramChannelSelect.value);
} else if (isUser) {
userIds = $(telegramUserSelect).val() || [];
}
}
if (channelIds.length === 0 && userIds.length === 0) {
alert('Por favor selecciona al menos un destinatario.');
return false;
}
// Add hidden fields to the form
$('<input>').attr({ type: 'hidden', name: 'channelIds', value: JSON.stringify(channelIds) }).appendTo(form);
$('<input>').attr({ type: 'hidden', name: 'userIds', value: JSON.stringify(userIds) }).appendTo(form);
// If all validation passes, submit the form
form.submit();
});
}
});
</script>

8295
db/bot.sql Executable file

File diff suppressed because one or more lines are too long

28
delete_image.php Executable file
View File

@@ -0,0 +1,28 @@
<?php
require_once __DIR__ . '/includes/session_check.php';
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/activity_logger.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['filename'])) {
$userId = $_SESSION['user_id'] ?? 0;
$username = $_SESSION['username'] ?? 'Unknown';
$filename = basename($_POST['filename']); // Sanitize filename
$filepath = __DIR__ . '/galeria/' . $filename;
if (file_exists($filepath) && is_file($filepath)) {
if (unlink($filepath)) {
log_activity($userId, 'Image Deleted', 'User ' . $username . ' deleted image: ' . $filename);
header('Location: gallery.php?success=deleted');
} else {
log_activity($userId, 'Image Deletion Failed', 'User ' . $username . ' failed to delete image: ' . $filename);
header('Location: gallery.php?error=delete_failed');
}
} else {
log_activity($userId, 'Image Deletion Failed', 'User ' . $username . ' attempted to delete non-existent or invalid file: ' . $filename);
header('Location: gallery.php?error=invalid_file');
}
} else {
header('Location: gallery.php');
}
?>

266
discord/DiscordSender.php Executable file
View File

@@ -0,0 +1,266 @@
<?php
class DiscordSender
{
private const API_BASE_URL = 'https://discord.com/api/v10';
private const MESSAGE_CHAR_LIMIT = 1990;
private const LOG_FILE = __DIR__ . '/../logs/discord_api.log';
private string $token;
public function __construct(string $token)
{
custom_log('[DiscordSender] Initializing...');
$this->token = $token;
}
public function sendMessage(string $discordId, string $content, string $recipientType = 'channel') {
custom_log("[DiscordSender] sendMessage: Called for ID {$discordId} and recipient type {$recipientType}.");
try {
if (empty(trim($content))) {
$this->logMessage("Error: No se puede enviar un mensaje vacío");
return false;
}
$targetChannelId = $this->getTargetChannelId($discordId, $recipientType);
custom_log("[DiscordSender] sendMessage: Target channel ID is {$targetChannelId}.");
$parts = [];
preg_match_all('/<img[^>]+src=[\'"]([^\'"]+)[\'"][^>]*>/i', $content, $imageMatches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$lastPos = 0;
foreach ($imageMatches as $match) {
$imageTag = $match[0][0];
$imageUrl = $match[1][0];
$imagePos = $match[0][1];
$textBefore = trim(substr($content, $lastPos, $imagePos - $lastPos));
if (!empty($textBefore)) {
$textWithNewlines = str_ireplace(['<p>', '</p>', '<br>', '<br />'], ["", "\n", "\n", "\n"], $textBefore);
$text = trim(strip_tags($textWithNewlines));
if (!empty($text)) {
$parts[] = ['type' => 'text', 'content' => $text];
}
}
if (!empty($imageUrl)) {
$absoluteImageUrl = $imageUrl;
if (strpos($imageUrl, 'http') !== 0 && strpos($imageUrl, '//') !== 0) {
$base = rtrim(BOT_BASE_URL, '/');
$path = ltrim($imageUrl, '/');
$absoluteImageUrl = "{$base}/{$path}";
}
$parts[] = ['type' => 'image', 'url' => $absoluteImageUrl];
}
$lastPos = $imagePos + strlen($imageTag);
}
$textAfter = trim(substr($content, $lastPos));
if (!empty($textAfter)) {
$textWithNewlines = str_ireplace(['<p>', '</p>', '<br>', '<br />'], ["", "\n", "\n", "\n"], $textAfter);
$text = trim(strip_tags($textWithNewlines));
if (!empty($text)) {
$parts[] = ['type' => 'text', 'content' => $text];
}
}
if (empty($parts)) {
$textWithNewlines = str_ireplace(['<p>', '</p>', '<br>', '<br />'], ["", "\n", "\n", "\n"], $content);
$text = trim(strip_tags($textWithNewlines));
if (!empty($text)) {
$parts[] = ['type' => 'text', 'content' => $text];
}
}
if (empty($parts)) {
return false;
}
$messageIds = [];
$allPartsSentSuccessfully = true;
foreach ($parts as $part) {
if ($part['type'] === 'text') {
$chunks = $this->splitMessage($part['content']);
foreach ($chunks as $chunk) {
$trimmedChunk = trim($chunk);
if ($trimmedChunk === '') continue;
try {
$response = $this->sendDiscordMessage($targetChannelId, ['content' => $trimmedChunk]);
if (isset($response['id'])) {
$messageIds[] = $response['id'];
} else {
$allPartsSentSuccessfully = false; break;
}
} catch (Exception $e) {
$this->logMessage("Error al enviar texto: " . $e->getMessage());
$allPartsSentSuccessfully = false; break;
}
usleep(250000);
}
} elseif ($part['type'] === 'image') {
try {
$response = $this->sendDiscordMessage($targetChannelId, ['content' => $part['url']]);
if (isset($response['id'])) {
$messageIds[] = $response['id'];
} else {
$allPartsSentSuccessfully = false; break;
}
} catch (Exception $e) {
$this->logMessage("Error al enviar imagen como URL: " . $e->getMessage());
$allPartsSentSuccessfully = false; break;
}
}
if (!$allPartsSentSuccessfully) break;
usleep(500000);
}
return $allPartsSentSuccessfully ? $messageIds : false;
} catch (Exception $e) {
$this->logMessage("Error in sendMessage: " . $e->getMessage());
throw $e;
}
}
private function getTargetChannelId(string $discordId, string $recipientType): string {
if ($recipientType === 'user') {
return $this->createDMChannel($discordId);
}
return $discordId;
}
private function createDMChannel(string $userId): string {
$url = self::API_BASE_URL . '/users/@me/channels';
$data = json_encode(['recipient_id' => $userId]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => [
'Authorization: Bot ' . $this->token,
'Content-Type: application/json',
'Content-Length: ' . strlen($data)
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $data
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
throw new Exception('cURL error: ' . curl_error($ch));
}
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception("Failed to create DM channel. HTTP code: $httpCode, Response: $response");
}
$responseData = json_decode($response, true);
return $responseData['id'];
}
private function sendDiscordMessage(string $channelId, array $payload, array $files = []) {
$url = self::API_BASE_URL . "/channels/{$channelId}/messages";
if (isset($payload['content'])) {
$payload['content'] = trim($payload['content']);
if ($payload['content'] === '') unset($payload['content']);
}
if (empty($payload['content'] ?? '') && empty($payload['embeds'] ?? '') && empty($files)) {
throw new Exception("No se puede enviar un mensaje vacío");
}
$ch = curl_init($url);
$headers = ['Authorization: Bot ' . $this->token, 'User-Agent: DiscordBot (v1.0)'] ;
if (empty($files)) {
$headers[] = 'Content-Type: application/json';
$postData = json_encode($payload);
} else {
// Multipart logic for files would go here if needed
}
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$responseData = json_decode($response, true);
if ($httpCode >= 400) {
throw new Exception("Discord API error ({$httpCode}): " . ($responseData['message'] ?? 'Unknown error'));
}
return $responseData;
}
private function splitMessage(string $text, int $maxLength = self::MESSAGE_CHAR_LIMIT): array
{
$chunks = [];
$text = str_replace(["\r\n", "\r"], "\n", $text);
$lines = explode("\n", $text);
$currentChunk = '';
foreach ($lines as $line) {
if (mb_strlen($currentChunk, 'UTF-8') + mb_strlen($line, 'UTF-8') + 1 > $maxLength) {
$chunks[] = $currentChunk;
$currentChunk = $line;
} else {
$currentChunk .= (empty($currentChunk) ? '' : "\n") . $line;
}
}
if (!empty($currentChunk)) $chunks[] = $currentChunk;
return $chunks;
}
private function logMessage(string $message): void {
$logMessage = date('[Y-m-d H:i:s] ') . $message . "\n";
file_put_contents(self::LOG_FILE, $logMessage, FILE_APPEND | LOCK_EX);
}
public function sendRawMessage(string $channelId, string $content): ?array
{
custom_log("[DiscordSender] sendRawMessage: Called for channel ID {$channelId}.");
try {
return $this->sendDiscordMessage($channelId, ['content' => $content]);
} catch (Exception $e) {
$this->logMessage("Error in sendRawMessage: " . $e->getMessage());
return null;
}
}
/**
* Envía un Embed (construido como un array) a un canal de Discord.
*
* @param string $channelId El ID del canal de destino.
* @param array $embedData El array que representa el embed.
* @return array|null La respuesta de la API de Discord o null si hay un error.
*/
public function sendEmbedData(string $channelId, array $embedData): ?array
{
custom_log("[DiscordSender] sendEmbedData: Called for channel ID {$channelId}.");
try {
return $this->sendDiscordMessage($channelId, ['embeds' => [$embedData]]);
} catch (Exception $e) {
$this->logMessage("Error in sendEmbedData: " . $e->getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
require_once __DIR__ . '/../../includes/session_check.php';
require_once __DIR__ . '/../../includes/db.php';
require_once __DIR__ . '/../DiscordSender.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_POST['action'])) {
header('Location: ../sent_messages.php');
exit();
}
$action = $_POST['action'];
if ($action === 'delete_message') {
if (!isset($_POST['sent_message_id'], $_POST['platform_message_id'], $_POST['channel_id'])) {
header('Location: ../sent_messages.php?error=missing_data');
exit();
}
$sentMessageId = $_POST['sent_message_id'];
$discordMessageIdsJson = $_POST['platform_message_id'];
$channelId = $_POST['channel_id']; // The channel where the message was sent
$discordMessageIds = json_decode($discordMessageIdsJson, true);
// If decoding fails or the result is not an array, treat it as a single ID
if (json_last_error() !== JSON_ERROR_NONE || !is_array($discordMessageIds)) {
$discordMessageIds = [$discordMessageIdsJson];
}
$discordSender = new DiscordSender(DISCORD_BOT_TOKEN);
try {
// 1. Attempt to delete from Discord
$all_deleted = true;
foreach ($discordMessageIds as $discordMessageId) {
// Skip if the ID is empty or invalid
if (empty($discordMessageId) || !is_numeric($discordMessageId)) {
error_log("Skipping invalid message chunk ID: " . var_export($discordMessageId, true));
continue;
}
try {
error_log("Attempting to delete message chunk ID: {$discordMessageId} in channel {$channelId}");
$discordSender->deleteMessage($channelId, $discordMessageId);
usleep(500000); // Wait 500ms to avoid rate limiting
} catch (Exception $e) {
error_log("Failed to delete message chunk {$discordMessageId}: " . $e->getMessage());
$all_deleted = false; // Mark that at least one failed
}
}
// 2. If all chunks were deleted (or if there was only one), delete from our database
if ($all_deleted) {
$stmt = $pdo->prepare("DELETE FROM sent_messages WHERE id = ?");
$stmt->execute([$sentMessageId]);
header('Location: ../sent_messages.php?success=deleted&platform=Discord');
} else {
// If some failed, we don't delete the entry, so it can be retried.
// We could also add more sophisticated logic here, like storing partial success.
header('Location: ../sent_messages.php?error=delete_failed_partial&platform=Discord');
}
exit();
} catch (Exception $e) {
error_log("Discord message deletion failed: " . $e->getMessage());
header('Location: ../sent_messages.php?error=delete_failed&platform=Discord&message=' . urlencode($e->getMessage()));
exit();
}
}
// Fallback redirect
header('Location: ../sent_messages.php');
?>

View File

@@ -0,0 +1,209 @@
<?php
// Evitar caché del navegador
header("Cache-Control: no-cache, must-revalidate");
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Opciones de Traducción - Discord Bot</title>
<style>
:root {
--discord-blurple: #5865F2;
--discord-green: #57F287;
--discord-yellow: #FEE75C;
--discord-red: #ED4245;
--discord-dark: #2c2f33;
--discord-gray: #99aab5;
--discord-light-gray: #f2f3f5;
--discord-chat-bg: #36393f;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'gg sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background: var(--discord-dark);
color: white;
padding: 20px;
}
.container { max-width: 1200px; margin: 0 auto; }
.header { text-align: center; color: white; margin-bottom: 40px; }
.header h1 { font-size: 2.5rem; margin-bottom: 10px; }
.header p { font-size: 1.2rem; color: var(--discord-gray); }
.options-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 25px; margin-bottom: 30px; }
.option-card { background: var(--discord-chat-bg); border-radius: 8px; padding: 25px; box-shadow: 0 8px 20px rgba(0,0,0,0.3); transition: transform 0.3s ease; }
.option-card:hover { transform: translateY(-5px); }
.option-header { display: flex; align-items: center; gap: 15px; margin-bottom: 15px; }
.option-icon { font-size: 28px; }
.option-title { font-size: 1.5rem; font-weight: bold; color: white; }
.option-description { color: var(--discord-gray); font-size: 1rem; margin-bottom: 20px; }
.demo-area { background: var(--discord-dark); border-radius: 8px; padding: 20px; border: 1px solid #40444b; }
.discord-message { display: flex; gap: 15px; }
.avatar { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
.message-content { display: flex; flex-direction: column; }
.username { font-weight: 600; font-size: 1rem; margin-bottom: 5px; color: var(--discord-green); }
.message-text { color: #dcddde; line-height: 1.4; }
.discord-button { padding: 10px 16px; border: none; border-radius: 3px; font-weight: 600; cursor: pointer; font-size: 0.9rem; transition: background-color 0.2s; margin-top: 10px; background-color: #4f545c; color: white; }
.discord-button:hover { background-color: #5d636b; }
.pros-cons { margin-top: 20px; }
.pros, .cons { padding: 12px; border-radius: 8px; margin-bottom: 10px; font-size: 0.9rem; }
.pros { background: rgba(87, 242, 135, 0.1); border-left: 3px solid var(--discord-green); }
.pros strong { color: var(--discord-green); }
.cons { background: rgba(254, 231, 92, 0.1); border-left: 3px solid var(--discord-yellow); }
.cons strong { color: var(--discord-yellow); }
ul { color: var(--discord-gray); margin-left: 20px; margin-top: 5px; }
.recommendation { background: var(--discord-blurple); color: white; padding: 30px; border-radius: 8px; text-align: center; }
/* Modal for Ephemeral Message */
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; }
.modal.active { display: flex; }
.modal-content { background: var(--discord-chat-bg); padding: 20px; border-radius: 8px; max-width: 450px; width: 90%; box-shadow: 0 5px 15px rgba(0,0,0,0.5); }
.modal-header { color: var(--discord-green); font-weight: bold; margin-bottom: 15px; }
.modal-body { color: #dcddde; }
.modal-body .translation { margin-bottom: 10px; }
.modal-body .translation strong { color: white; }
.modal-footer { font-size: 0.8rem; color: var(--discord-gray); margin-top: 15px; text-align: center; }
/* Spoiler styles */
.spoiler { background-color: #202225; color: transparent; border-radius: 3px; cursor: pointer; padding: 0 2px; }
.spoiler.revealed { background-color: transparent; color: inherit; }
/* Thread styles */
.thread-link { color: var(--discord-blurple); font-weight: 600; cursor: pointer; font-size: 0.9rem; margin-top: 8px; }
.thread-view { display: none; margin-top: 15px; padding-top: 15px; border-top: 1px solid #40444b; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><img src="https://discord.com/assets/f8389ca1a741a115313a74fbbd352562.svg" alt="Discord Logo" width="40" style="vertical-align: middle;"> Opciones de Traducción para Discord</h1>
<p>Demostración visual de métodos no invasivos para mostrar traducciones.</p>
</div>
<div class="options-grid">
<!-- Opción 1: Botón con Respuesta Efímera -->
<div class="option-card">
<div class="option-header"><span class="option-icon">🤫</span> <h2 class="option-title">Botón Efímero</h2></div>
<p class="option-description">Un botón "Traducir" bajo el mensaje original. Al pulsarlo, se muestra una traducción que sólo el usuario que ha hecho clic puede ver.</p>
<div class="demo-area">
<div class="discord-message">
<img src="https://cdn.discordapp.com/embed/avatars/1.png" alt="avatar" class="avatar">
<div class="message-content">
<span class="username">UserFromFrance</span>
<div class="message-text">Bonjour tout le monde! C'est une démo.</div>
<button class="discord-button" onclick="showPopup('ephemeral-popup')">🌐 Traducir</button>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pros"><strong>✅ Ventajas:</strong><ul><li>No satura el chat general.</li><li>La traducción es bajo demanda.</li><li>Experiencia de usuario moderna y limpia.</li></ul></div>
<div class="cons"><strong>⚠️ Consideración:</strong><p style="margin-left: 20px; color: var(--discord-gray);">Requiere manejar interacciones (clicks de botón) en el backend.</p></div>
</div>
</div>
<!-- Opción 2: Hilos -->
<div class="option-card">
<div class="option-header"><span class="option-icon">🧵</span> <h2 class="option-title">Hilos (Threads)</h2></div>
<p class="option-description">Se crea un hilo a partir del mensaje original, y la traducción se publica dentro de ese hilo, manteniendo el canal principal limpio.</p>
<div class="demo-area">
<div class="discord-message">
<img src="https://cdn.discordapp.com/embed/avatars/2.png" alt="avatar" class="avatar">
<div class="message-content">
<span class="username" style="color: #f1c40f;">UserFromGermany</span>
<div class="message-text">Guten Tag, dies ist eine weitere Demo.</div>
<div class="thread-link" onclick="toggleThread('thread-view')">↪️ Ver Hilo (1 respuesta)</div>
</div>
</div>
<div class="thread-view" id="thread-view">
<div class="discord-message">
<img src="https://www.google.com/s2/favicons?domain=discord.com" alt="avatar" class="avatar">
<div class="message-content">
<span class="username" style="color: #5865F2;">Translation Bot</span>
<div class="message-text"><strong>Traducción (ES):</strong><br>Buenas tardes, esta es otra demo.</div>
</div>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pros"><strong>✅ Ventajas:</strong><ul><li>Mantiene el canal principal 100% limpio.</li><li>Permite discusiones sobre la traducción en el hilo.</li><li>Ideal para mensajes largos.</li></ul></div>
<div class="cons"><strong>⚠️ Consideración:</strong><p style="margin-left: 20px; color: var(--discord-gray);">Puede generar muchas notificaciones de hilos si hay mucha actividad.</p></div>
</div>
</div>
<!-- Opción 3: Spoilers -->
<div class="option-card">
<div class="option-header"><span class="option-icon">⬛</span> <h2 class="option-title">Spoilers</h2></div>
<p class="option-description">La traducción se añade al mensaje original o en una respuesta, pero oculta tras una etiqueta de spoiler que se revela al hacer clic.</p>
<div class="demo-area">
<div class="discord-message">
<img src="https://cdn.discordapp.com/embed/avatars/3.png" alt="avatar" class="avatar">
<div class="message-content">
<span class="username" style="color: #e91e63;">UserFromItaly</span>
<div class="message-text">
Ciao! Benvenuto alla demo finale.<br>
<strong>Traducción:</strong> <span class="spoiler" onclick="this.classList.toggle('revealed')">¡Hola! Bienvenido a la demo final.</span>
</div>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pros"><strong>✅ Ventajas:</strong><ul><li>Muy fácil de implementar.</li><li>No requiere interacciones complejas.</li><li>La traducción está en el mismo contexto.</li></ul></div>
<div class="cons"><strong>⚠️ Consideración:</strong><p style="margin-left: 20px; color: var(--discord-gray);">Añade "ruido" visual al chat, aunque esté oculto.</p></div>
</div>
</div>
</div>
<div class="recommendation">
<h3>💡 Nuestra Recomendación</h3>
<p>Para la mejor experiencia de usuario y mantener los canales limpios, la **Opción 1 (Botón Efímero)** es la más recomendada. Es moderna, eficiente y le da el control total al usuario sin interrumpir a los demás.</p>
</div>
</div>
<!-- Modal para la demo efímera -->
<div class="modal" id="ephemeral-popup" onclick="closePopup('ephemeral-popup')">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">Traducción del mensaje</div>
<div class="modal-body">
<div class="translation">
<strong>Inglés:</strong><br>
<span>Hello everyone! This is a demo.</span>
</div>
<div class="translation">
<strong>Español:</strong><br>
<span>¡Hola a todos! Esto es una demo.</span>
</div>
</div>
<div class="modal-footer">Solo tú puedes ver este mensaje.</div>
</div>
</div>
<script>
function showPopup(id) {
document.getElementById(id).classList.add('active');
}
function closePopup(id) {
document.getElementById(id).classList.remove('active');
}
function toggleThread(id) {
const threadView = document.getElementById(id);
if (threadView.style.display === 'block') {
threadView.style.display = 'none';
} else {
threadView.style.display = 'block';
}
}
// Cerrar modal con la tecla Escape
document.addEventListener('keydown', function (event) {
if (event.key === "Escape") {
const activeModal = document.querySelector('.modal.active');
if (activeModal) {
closePopup(activeModal.id);
}
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,765 @@
<?php
// discord_bot.php
// Pausa intencionada para evitar condiciones de carrera en el arranque
sleep(3);
// Cargar configuración y dependencias
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../includes/db.php';
require_once __DIR__ . '/../../includes/logger.php';
require_once __DIR__ . '/../../src/DiscordSender.php';
require_once __DIR__ . '/../../src/HtmlToDiscordMarkdownConverter.php';
require_once __DIR__ . '/../../src/Translate.php';
require_once __DIR__ . '/../../src/CommandLocker.php';
// Importar clases necesarias
use Discord\Discord;
use Discord\WebSockets\Intents;
use Discord\WebSockets\Event;
use Discord\Parts\Channel\Message;
use Discord\Parts\Interactions\Interaction;
use Discord\Parts\WebSockets\MessageReaction;
use Discord\Builders\MessageBuilder;
use Discord\Parts\Embed\Embed;
use Discord\Parts\Guild\Member;
use Discord\Builders\Components\ActionRow;
use Discord\Builders\Components\Button;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('DiscordBot');
$logger->pushHandler(new StreamHandler(__DIR__.'/../../logs/discord_bot.log', Logger::DEBUG));
$logger->info("Iniciando bot de Discord...");
if (!defined('DISCORD_BOT_TOKEN') || empty(DISCORD_BOT_TOKEN)) {
$logger->error("Error Fatal: La constante DISCORD_BOT_TOKEN no está definida o está vacía.");
die();
}
/**
* Guarda un destinatario (usuario o canal) en la base de datos si no existe.
*
* @param PDO $pdo La conexión a la base de datos.
* @param Logger $logger El logger para registrar eventos.
* @param string $platform La plataforma (ej. 'discord').
* @param string $type El tipo de destinatario ('user' o 'channel').
* @param string $platformId El ID de la plataforma.
* @param string|null $name El nombre del destinatario.
*/
function saveRecipientIfNotExists(PDO $pdo, Logger $logger, string $platform, string $type, string $platformId, ?string $name): void {
$logger->debug("[Registro BD] Verificando destinatario...", ['type' => $type, 'id' => $platformId, 'name' => $name]);
if (empty($platformId) || empty($name)) {
$logger->warning("[Registro BD] Se omitió guardar destinatario por ID o nombre vacío.", ['type' => $type, 'id' => $platformId, 'name' => $name]);
return;
}
try {
// Verificar si el destinatario ya existe
$stmt = $pdo->prepare("SELECT id FROM recipients WHERE platform = ? AND platform_id = ?");
$stmt->execute([$platform, $platformId]);
if ($stmt->rowCount() === 0) {
// Si no existe, insertarlo
$insertStmt = $pdo->prepare(
"INSERT INTO recipients (platform, type, platform_id, name) VALUES (?, ?, ?, ?)"
);
$insertStmt->execute([$platform, $type, $platformId, $name]);
$logger->info("[Registro BD] Nuevo destinatario guardado: Tipo={$type}, ID={$platformId}, Nombre={$name}");
}
} catch (Throwable $e) {
// Registrar cualquier error durante la operación de base de datos
$logger->error("[Registro BD] Error al guardar destinatario: " . $e->getMessage(), [
'platformId' => $platformId,
'name' => $name,
'trace' => $e->getTraceAsString()
]);
}
}
try {
$discord = new Discord([
'token' => DISCORD_BOT_TOKEN,
'intents' => Intents::GUILDS | Intents::GUILD_MESSAGES | Intents::DIRECT_MESSAGES | Intents::GUILD_MEMBERS | Intents::GUILD_MESSAGE_REACTIONS | Intents::MESSAGE_CONTENT,
'logger' => $logger
]);
$discord->on('ready', function (Discord $discord) {
$discord->getLogger()->info("==================================================");
$discord->getLogger()->info("Bot conectado y listo para escuchar!");
$discord->getLogger()->info("Usuario: {$discord->user->username}#{$discord->user->discriminator}");
$discord->getLogger()->info("==================================================");
});
// Evento para nuevos miembros en el servidor
// Nota: DiscordPHP puede enviar Discord\Parts\User\Member en lugar de Discord\Parts\Guild\Member
// Por eso NO usamos type hint aquí
$discord->on(Event::GUILD_MEMBER_ADD, function ($member, Discord $discord) use ($pdo, $logger) {
$logger->info("[NUEVO MIEMBRO] Usuario {$member->user->username} ({$member->id}) se ha unido al servidor.");
// Asegurarse de que el usuario existe en la BD
saveRecipientIfNotExists($pdo, $logger, 'discord', 'user', $member->id, $member->user->username);
try {
// 1. Obtener idiomas activos de la BD
$stmt = $pdo->query("SELECT language_code, language_name, flag_emoji FROM supported_languages WHERE is_active = 1 ORDER BY language_name ASC");
$activeLangs = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($activeLangs)) {
$logger->warning("[BIENVENIDA] No se envió mensaje de bienvenida porque no hay idiomas activos.");
return;
}
// 2. Construir el mensaje de bienvenida multi-idioma
$translator = new Translate();
$baseTitle = "👋 ¡Hola, {$member->user->username}! Bienvenido/a a el Discord de Cereal Kiiller.";
$baseDescription = "Por favor, selecciona tu idioma preferido:";
$fullDescription = "";
foreach($activeLangs as $lang) {
$langCode = $lang['language_code'];
$flag = $lang['flag_emoji'] ? $lang['flag_emoji'] . ' ' : '';
// Traducir si no es el idioma base (español)
$translatedDesc = ($langCode === 'es')
? $baseDescription
: $translator->translateText($baseDescription, 'es', $langCode);
if ($translatedDesc) {
$fullDescription .= $flag . $translatedDesc . "\n\n";
}
}
$embed = new Embed($discord);
$embed->setTitle($baseTitle); // El título se mantiene simple y en español
$embed->setDescription(trim($fullDescription));
$embed->setColor("#5865F2"); // Discord Blurple
if ($member->user && $member->user->avatar) {
$avatarUrl = "https://cdn.discordapp.com/avatars/{$member->user->id}/{$member->user->avatar}.png";
$embed->setThumbnail($avatarUrl);
}
$builder = MessageBuilder::new()->addEmbed($embed);
// 3. Construir los botones en filas de 5
$actionRows = [];
$currentRow = ActionRow::new();
$buttonCount = 0;
foreach ($activeLangs as $lang) {
$button = Button::new(Button::STYLE_SECONDARY, 'set_lang_' . $lang['language_code'])
->setLabel($lang['language_name']);
if (!empty($lang['flag_emoji'])) {
$button->setEmoji($lang['flag_emoji']);
}
$currentRow->addComponent($button);
$buttonCount++;
if ($buttonCount % 5 === 0) {
$actionRows[] = $currentRow;
$currentRow = ActionRow::new();
}
}
if ($buttonCount % 5 !== 0) {
$actionRows[] = $currentRow;
}
foreach ($actionRows as $row) {
$builder->addComponent($row);
}
// 4. Enviar el mensaje como Mensaje Directo (DM) al nuevo usuario.
$member->sendMessage($builder)->done(function () use ($logger, $member) {
$logger->info("[BIENVENIDA] Mensaje de selección de idioma enviado por DM a {$member->user->username}.");
}, function ($error) use ($logger, $member) {
$logger->error("[BIENVENIDA] No se pudo enviar DM de bienvenida a {$member->user->username}.", ['error' => $error]);
});
return;
} catch (Throwable $e) {
$logger->error("[BIENVENIDA] Error fatal al procesar nuevo miembro.", ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
}
});
// Evento para manejar interacciones (clics en botones)
$discord->on(Event::INTERACTION_CREATE, function (Interaction $interaction, Discord $discord) use ($pdo, $logger) {
// Guardar usuario y canal que interactúan
saveRecipientIfNotExists($pdo, $logger, 'discord', 'user', $interaction->user->id, $interaction->user->username);
$channelName = 'Canal Desconocido';
if ($interaction->channel) {
$channelName = $interaction->channel->name;
if ($interaction->channel->guild) {
$channelName = $interaction->channel->guild->name . ' - ' . $channelName;
} elseif ($interaction->channel->is_private) {
$channelName = 'DM con ' . $interaction->user->username;
}
}
saveRecipientIfNotExists($pdo, $logger, 'discord', 'channel', $interaction->channel_id, $channelName);
// DiscordPHP v7: No existe isButton(). Botones llegan como MESSAGE_COMPONENT (type = 3) y component_type = 2
$type = (int) ($interaction->type ?? 0); // 3 = MESSAGE_COMPONENT
$componentType = (int) ($interaction->data->component_type ?? 0); // 2 = BUTTON
if ($type !== 3 || $componentType !== 2) return;
$customId = $interaction->data->custom_id;
$userId = $interaction->user->id;
$logger->info("[INTERACCION] Usuario $userId hizo clic en el botón: $customId");
try {
// MANEJAR SELECCIÓN DE IDIOMA DE BIENVENIDA
if (strpos($customId, 'set_lang_') === 0) {
try {
$langCode = substr($customId, strlen('set_lang_'));
// Actualizar la base de datos
$stmt = $pdo->prepare("UPDATE recipients SET language_code = ? WHERE platform_id = ? AND platform = 'discord'");
$stmt->execute([$langCode, $userId]);
// Obtener el nombre del idioma para el mensaje de confirmación
$langNameStmt = $pdo->prepare("SELECT language_name FROM supported_languages WHERE language_code = ?");
$langNameStmt->execute([$langCode]);
$langName = $langNameStmt->fetchColumn() ?: strtoupper($langCode);
// Crear el mensaje de confirmación
$confirmEmbed = new Embed($discord);
$confirmEmbed->setTitle("✅ Idioma Configurado");
$confirmEmbed->setDescription("Tu idioma ha sido establecido a: **" . htmlspecialchars($langName) . "**");
$confirmEmbed->setColor("#57F287"); // Discord Green
// Actualizar el mensaje original para mostrar la confirmación y quitar los botones
$builder = MessageBuilder::new()->addEmbed($confirmEmbed);
// Acknowledge the interaction and then edit the message
$interaction->acknowledge()->done(function() use ($interaction, $builder, $logger, $langCode, $userId) {
$interaction->message->edit($builder)->done(function() use ($logger, $langCode, $userId) {
$logger->info("[BIENVENIDA] El usuario $userId ha establecido su idioma a '$langCode' y el mensaje fue editado.");
}, function ($error) use ($logger, $langCode, $userId, $interaction) {
$logger->error("[BIENVENIDA] Error al editar el mensaje original para {$userId}.", ['lang' => $langCode, 'error' => $error]);
// Fallback response: if editing fails, send a new ephemeral message
$interaction->respondWithMessage(MessageBuilder::new()->setContent("❌ Tu idioma ha sido guardado, pero no se pudo actualizar el mensaje de bienvenida. Error: " . $error->getMessage()), true);
});
}, function ($error) use ($logger, $langCode, $userId, $interaction) {
$logger->error("[BIENVENIDA] Error al reconocer interacción para {$userId}.", ['lang' => $langCode, 'error' => $error]);
$interaction->respondWithMessage(MessageBuilder::new()->setContent("❌ Error al procesar tu solicitud. Por favor, inténtalo de nuevo."), true);
});
$logger->info("[BIENVENIDA] El usuario $userId ha establecido su idioma a '$langCode'.");
} catch (Throwable $e) {
$logger->error("[BIENVENIDA] Error al procesar botón de idioma.", ['customId' => $customId, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
$interaction->respondWithMessage(MessageBuilder::new()->setContent("❌ Hubo un error al guardar tu selección de idioma."), true);
}
return; // Finalizar para no procesar otros ifs
}
if (strpos($customId, 'translate_auto:') === 0) {
// Extraer el ID del mensaje original del custom_id
$originalMessageId = substr($customId, strlen('translate_auto:'));
// Obtener el idioma preferido del usuario desde la tabla recipients
$stmt = $pdo->prepare("SELECT language_code FROM recipients WHERE platform_id = ? AND platform = 'discord'");
$stmt->execute([$userId]);
$userLang = $stmt->fetchColumn();
// Si no tiene idioma configurado, usar español por defecto
if (!$userLang) {
$userLang = 'es';
$logger->info("[TRANSLATION] Usuario {$userId} no tiene idioma configurado, usando 'es' por defecto");
}
$targetLang = $userLang;
// Obtener el mensaje original usando su ID
$interaction->channel->messages->fetch($originalMessageId)->done(function ($originalMessage) use ($interaction, $discord, $pdo, $logger, $userId, $targetLang) {
$originalContent = trim((string) ($originalMessage->content ?? ''));
if (empty($originalContent)) {
$interaction->respondWithMessage(
MessageBuilder::new()->setContent("❌ No se encontró contenido para traducir."),
true
);
return;
}
try {
$translator = new Translate();
$sourceLang = $translator->detectLanguage($originalContent) ?? 'es';
// Evitar traducir si ya está en el idioma destino
if ($sourceLang === $targetLang) {
$interaction->respondWithMessage(
MessageBuilder::new()->setContent("⚠️ El mensaje ya está en tu idioma ({$targetLang})."),
true
);
return;
}
$translatedText = $translator->translateText($originalContent, $sourceLang, $targetLang);
if (!empty($translatedText)) {
// Obtener información del idioma
$stmt = $pdo->prepare("SELECT flag_emoji, language_name FROM supported_languages WHERE language_code = ? AND is_active = 1");
$stmt->execute([$targetLang]);
$langInfo = $stmt->fetch(PDO::FETCH_ASSOC);
$flag = $langInfo['flag_emoji'] ?? '🏳️';
$langName = $langInfo['language_name'] ?? strtoupper($targetLang);
// Crear embed para la traducción
$embed = new Embed($discord);
$embed->setTitle("{$flag} Traducción a {$langName}");
$embed->setDescription($translatedText);
$embed->setColor("#5865F2");
$embed->setFooter("Traducido de {$sourceLang} • Solo tú puedes ver esto");
// Si el mensaje original tiene imágenes, agregar la primera como imagen del embed
if (count($originalMessage->attachments) > 0) {
$firstAttachment = $originalMessage->attachments->first();
if ($firstAttachment && isset($firstAttachment->url)) {
// Verificar si es una imagen
$contentType = $firstAttachment->content_type ?? '';
if (strpos($contentType, 'image/') === 0) {
$embed->setImage($firstAttachment->url);
}
}
}
// Enviar como mensaje EFÍMERO (solo visible para quien hizo clic)
$builder = MessageBuilder::new()->addEmbed($embed);
$interaction->respondWithMessage($builder, true);
$logger->info("[TRANSLATION] Usuario {$userId} tradujo mensaje de {$sourceLang} a {$targetLang}");
} else {
$interaction->respondWithMessage(
MessageBuilder::new()->setContent("⚠️ No se pudo traducir el mensaje."),
true
);
}
} catch (\Throwable $e) {
$logger->error("[TRANSLATION] Error al traducir", ['error' => $e->getMessage()]);
$interaction->respondWithMessage(
MessageBuilder::new()->setContent("❌ Error al traducir: " . $e->getMessage()),
true
);
}
}, function ($error) use ($interaction, $logger) {
$logger->error("[TRANSLATION] Error al obtener mensaje original", ['error' => $error->getMessage()]);
$interaction->respondWithMessage(
MessageBuilder::new()->setContent("❌ No se pudo obtener el mensaje original."),
true
);
});
return;
}
// Traducción de plantillas completas (comandos #)
if (strpos($customId, 'translate_template:') === 0) {
$payload = substr($customId, strlen('translate_template:'));
$parts = explode(':', $payload, 2);
if (count($parts) !== 2) {
$interaction->respondWithMessage(MessageBuilder::new()->setContent('Formato de botón inválido.'), true);
return;
}
[$commandKey, $targetLang] = $parts;
$interaction->respondWithMessage(MessageBuilder::new()->setContent('⌛ Traduciendo plantilla...'), true);
try {
// Obtener contenido HTML original de la plantilla
$stmt = $pdo->prepare("SELECT message_content FROM recurrent_messages WHERE telegram_command = ?");
$stmt->execute([$commandKey]);
$template = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$template) {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($interaction->channel_id, '<@' . $userId . '> No se encontró la plantilla.');
return;
}
$originalHtml = $template['message_content'] ?? '';
$converter = new HtmlToDiscordMarkdownConverter();
$partsArr = $converter->convertToArray($originalHtml);
// Unir todos los segmentos de texto (las imágenes no se traducen)
$fullText = '';
foreach ($partsArr as $p) {
if (($p['type'] ?? '') === 'text') {
$fullText .= ($fullText === '' ? '' : "\n\n") . trim((string)$p['content']);
}
}
if ($fullText === '') {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($interaction->channel_id, '<@' . $userId . '> No hay contenido de texto para traducir en la plantilla.');
return;
}
$translator = new Translate();
$sourceLang = $translator->detectLanguage($fullText) ?? 'es';
if ($sourceLang === $targetLang) {
// Fallback: fuerza ES como origen si coincide con el destino (común en nuestras plantillas)
$fallbackSrc = 'es';
if ($fallbackSrc !== $targetLang) {
$translated = $translator->translateText($fullText, $fallbackSrc, $targetLang);
$sourceLang = $fallbackSrc;
} else {
$translated = '';
}
} else {
$translated = $translator->translateText($fullText, $sourceLang, $targetLang);
}
if ($translated === '') {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($interaction->channel_id, '<@' . $userId . '> El contenido ya está en este idioma.');
return;
}
// Bandera
$flag = '';
try {
$stmt = $pdo->prepare("SELECT flag_emoji FROM supported_languages WHERE language_code = ? AND is_active = 1");
$stmt->execute([$targetLang]);
$flag = $stmt->fetchColumn() ?: '';
} catch (\Throwable $e) { /* noop */ }
$flag = $flag !== '' ? $flag : '🏳️';
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
// Pequeña espera para que el mensaje efímero aparezca primero
usleep(300000);
$sender->sendRawMessage($interaction->channel_id, '<@' . $userId . "> {$flag} Traducción plantilla ({$sourceLang} → {$targetLang}):\n" . $translated);
} catch (\Throwable $e) {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($interaction->channel_id, '<@' . $userId . "> Error al traducir plantilla: " . $e->getMessage());
}
return;
}
if ($customId === 'platicar_bot' || $customId === 'usar_ia') {
$newMode = ($customId === 'platicar_bot') ? 'bot' : 'ia';
$stmt = $pdo->prepare("UPDATE recipients SET chat_mode = ? WHERE platform_id = ? AND platform = 'discord'");
$stmt->execute([$newMode, $userId]);
$responseText = $newMode === 'bot'
? "🤖 Modo cambiado a 'Platicar con bot'. Ahora puedes usar los comandos normales como `/comandos`."
: "🧠 Modo cambiado a 'Usar IA'. Todo lo que escribas será procesado por la IA.\n\nEscribe `/agente` para volver a este menú.";
$interaction->respondWithMessage(MessageBuilder::new()->setContent($responseText), true);
$logger->info("[MODO AGENTE] Usuario $userId cambió al modo: $newMode");
}
} catch (Throwable $e) {
$logger->error("[INTERACCION] Error al procesar un botón.", ['customId' => $customId, 'error' => $e->getMessage()]);
// Mensaje genérico para otros botones que no sean de idioma
$interaction->respondWithMessage(MessageBuilder::new()->setContent("Hubo un error al procesar esta acción."), true);
}
});
// Evento para manejar mensajes
$discord->on(Event::MESSAGE_CREATE, function (Message $message, Discord $discord) use ($pdo, $logger) {
if ($message->author->bot) return;
// Guardar usuario
saveRecipientIfNotExists($pdo, $logger, 'discord', 'user', $message->author->id, $message->author->username);
// Guardar canal con nombre mejorado
$channelName = $message->channel->name;
if ($message->channel->is_private) {
$channelName = 'DM con ' . $message->author->username;
} elseif ($message->channel->guild) {
$channelName = $message->channel->guild->name . ' - ' . $channelName;
}
saveRecipientIfNotExists($pdo, $logger, 'discord', 'channel', $message->channel->id, $channelName);
$logger->info("[Mensaje Recibido] En canal '{$channelName}' de @{$message->author->username}: {$message->content}");
$isPrivateChat = $message->channel->is_private;
$userId = $message->author->id;
$content = $message->content;
try {
if ($isPrivateChat) {
// --- LÓGICA DE CHAT PRIVADO (AGENTE/IA/BOT) ---
$logger->info("[MODO AGENTE] Mensaje en chat privado de $userId.");
$stmt = $pdo->prepare("SELECT chat_mode FROM recipients WHERE platform_id = ? AND platform = 'discord'");
$stmt->execute([$userId]);
$userChatMode = $stmt->fetchColumn();
if ($userChatMode === false) {
// El registro ya se hizo con saveRecipientIfNotExists, solo necesitamos el modo
$logger->info("[NUEVO USUARIO DM] Usuario $userId no tenía modo. Asignando 'agent'.");
$updateStmt = $pdo->prepare("UPDATE recipients SET chat_mode = 'agent' WHERE platform_id = ? AND platform = 'discord'");
$updateStmt->execute([$userId]);
$userChatMode = 'agent';
}
if (trim($content) === '/agente') {
$stmt = $pdo->prepare("UPDATE recipients SET chat_mode = 'agent' WHERE platform_id = ? AND platform = 'discord'");
$stmt->execute([$userId]);
$userChatMode = 'agent';
$logger->info("[MODO AGENTE] Usuario $userId usó /agente. Reseteando a modo 'agent'.");
}
switch ($userChatMode) {
case 'agent':
$builder = MessageBuilder::new()->setContent("👋 Hola! ¿Cómo quieres interactuar?");
$actionRow = ActionRow::new()
->addComponent(Button::new(Button::STYLE_PRIMARY, 'platicar_bot')->setLabel('🤖 Platicar con bot'))
->addComponent(Button::new(Button::STYLE_SUCCESS, 'usar_ia')->setLabel('🧠 Usar IA'));
$builder->addComponent($actionRow);
$message->channel->sendMessage($builder);
return;
case 'ia':
$logger->info("[MODO IA] Mensaje de $userId para la IA: $content");
$n8nWebhookUrl = $_ENV['N8N_IA_WEBHOOK_URL_DISCORD'] ?? null;
if ($n8nWebhookUrl) {
$postData = [
'chat_id' => $message->channel_id,
'user_id' => $userId,
'message' => $content,
'name' => $message->author->username
];
$ch = curl_init($n8nWebhookUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_exec($ch);
curl_close($ch);
$logger->info("[MODO IA] Mensaje reenviado a n8n.");
} else {
$logger->error("[MODO IA] La variable N8N_IA_WEBHOOK_URL no está configurada.");
}
return;
case 'bot':
// Continuar con la lógica normal de bot abajo
break;
}
}
// --- LÓGICA DE BOT NORMAL (CHATS PÚBLICOS O MODO 'bot') ---
if (strpos($content, '/') === 0 || strpos($content, '#') === 0) {
handleDiscordCommand($message, $pdo, $logger);
} else if (strtolower($content) === '!ping') {
$message->reply('pong!');
} else {
handleDiscordTranslation($message, $pdo, $logger);
}
} catch (Throwable $e) {
$logger->error("!!!!!!!!!! ERROR FATAL CAPTURADO !!!!!!!!!!", [
'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString()
]);
}
});
$discord->run();
} catch (Throwable $e) {
$logger->critical("!!!!!!!!!! ERROR FATAL AL INICIAR !!!!!!!!!!", [
'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine()
]);
die();
}
function handleDiscordCommand(Message $message, PDO $pdo, Logger $logger)
{
$text = trim($message->content);
$userId = $message->author->id;
if (strpos($text, '#') === 0) {
$command = ltrim($text, '#');
$logger->info("[Comando] Usuario @{$message->author->username} solicitó: #{$command}");
try {
$stmt = $pdo->prepare("SELECT message_content FROM recurrent_messages WHERE telegram_command = ?");
$stmt->execute([$command]);
$template = $stmt->fetch(PDO::FETCH_ASSOC);
if ($template) {
$logger->info("[Comando] Plantilla encontrada para #{$command}.");
$originalHtml = $template['message_content'];
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sentMessageIds = $sender->sendMessage($message->channel_id, $originalHtml);
if ($sentMessageIds && !empty($sentMessageIds)) {
$firstMessageId = $sentMessageIds[0];
$logger->info("Mensajes enviados, primer ID: {$firstMessageId}");
// Añadir botones de traducción dinámicos
$message->channel->messages->fetch($firstMessageId)->done(function (Message $sentMessage) use ($pdo, $logger, $command) {
$langStmt = $pdo->query("SELECT language_code, language_name, flag_emoji FROM supported_languages WHERE is_active = 1");
$activeLangs = $langStmt->fetchAll(PDO::FETCH_ASSOC);
if (count($activeLangs) > 1) {
$components = [];
$actionRow = ActionRow::new();
$buttonCount = 0;
foreach ($activeLangs as $lang) {
// Para plantillas, incluir el comando en el custom_id para traducir el contenido completo original
$button = Button::new(Button::STYLE_SECONDARY, 'translate_template:' . $command . ':' . $lang['language_code'])
->setLabel($lang['language_name']);
if (!empty($lang['flag_emoji'])) {
$button->setEmoji($lang['flag_emoji']);
}
$actionRow->addComponent($button);
$buttonCount++;
// Discord permite máximo 5 botones por ActionRow
if ($buttonCount % 5 === 0) {
$components[] = $actionRow;
$actionRow = ActionRow::new();
}
}
if ($buttonCount % 5 !== 0) {
$components[] = $actionRow;
}
$builder = MessageBuilder::new()->setComponents($components);
$sentMessage->edit($builder);
$logger->info("Botones de traducción añadidos al mensaje {$sentMessage->id}");
}
});
}
} else {
$message->reply("El comando `#{$command}` no fue encontrado.");
}
} catch (Throwable $e) {
$logger->error("[Error Comando] Procesando #{$command}", ['error' => $e->getMessage()]);
$message->reply("Ocurrió un error inesperado al procesar tu comando.");
}
} elseif (strpos($text, '/setlang') === 0) {
$parts = explode(' ', $text, 2);
if (count($parts) < 2 || strlen(trim($parts[1])) !== 2) {
$message->reply("❌ Formato incorrecto. Usa: `/setlang es`");
return;
}
$newLangCode = strtolower(trim($parts[1]));
$stmt = $pdo->prepare("UPDATE recipients SET language_code = ? WHERE platform_id = ? AND platform = 'discord'");
$stmt->execute([$newLangCode, $userId]);
$message->reply("✅ Tu idioma ha sido establecido a '" . strtoupper($newLangCode) . "'.");
} elseif (strpos($text, '/bienvenida') === 0) {
$configStmt = $pdo->query("SELECT * FROM telegram_bot_messages WHERE id = 1");
$welcomeConfig = $configStmt->fetch(PDO::FETCH_ASSOC);
if ($welcomeConfig && $welcomeConfig['is_active']) {
$messageText = str_replace('{user_name}', $message->author->username, $welcomeConfig['message_text']);
$converter = new HtmlToDiscordMarkdownConverter();
$markdownText = $converter->convert($messageText);
$builder = MessageBuilder::new()->setContent($markdownText);
$actionRow = ActionRow::new()
->addComponent(Button::new(Button::STYLE_LINK, $welcomeConfig['group_invite_link'])->setLabel($welcomeConfig['button_text']));
$builder->addComponent($actionRow);
$message->channel->sendMessage($builder);
} else {
$message->reply(" La función de bienvenida no está activa.");
}
} elseif (strpos($text, '/comandos') === 0) {
$stmt = $pdo->query("SELECT telegram_command, name FROM recurrent_messages WHERE telegram_command IS NOT NULL AND telegram_command != '' ORDER BY name ASC");
$commands = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($commands)) {
$message->reply(" No hay comandos personalizados disponibles.");
} else {
$response = "**LISTA DE COMANDOS DISPONIBLES**\n\n";
foreach ($commands as $cmd) {
$command = trim($cmd['telegram_command']);
if (strpos($command, '#') !== 0) $command = '#' . $command;
$name = trim($cmd['name']);
$response .= "`" . $command . "` - " . $name . "\n";
}
$response .= "\n Escribe el comando para usarlo.";
$message->channel->sendMessage($response);
}
} elseif (strpos($text, '/agente') === 0) {
$prompt = trim(substr($text, strlen('/agente')));
$logger->info("[Comando] Usuario @{$message->author->username} solicitó: /agente con prompt: '{$prompt}'");
if (empty($prompt)) {
$message->reply("Por favor, escribe tu consulta después de /agente.");
return;
}
$n8nWebhookUrl = $_ENV['N8N_IA_WEBHOOK_URL_DISCORD'] ?? null;
if ($n8nWebhookUrl) {
$postData = [
'chat_id' => $message->channel_id,
'user_id' => $message->author->id,
'message' => $prompt,
'name' => $message->author->username
];
$ch = curl_init($n8nWebhookUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_exec($ch);
curl_close($ch);
$logger->info("[Comando /agente] Prompt reenviado a n8n.");
$message->reply("Tu solicitud ha sido enviada al agente. Recibirás una respuesta en breve.");
} else {
$logger->error("[Comando /agente] La variable N8N_IA_WEBHOOK_URL no está configurada.");
$message->reply("No se pudo procesar tu solicitud. El servicio de agente no está configurado.");
}
}
}
function handleDiscordTranslation(Message $message, PDO $pdo, Logger $logger)
{
try {
$translator = new Translate();
$text = $message->content;
// 1. Detectar idioma original
$detectedLang = $translator->detectLanguage(strip_tags($text)) ?? 'es';
// 2. Obtener todos los idiomas activos con información completa
$langStmt = $pdo->query("SELECT language_code, language_name, flag_emoji FROM supported_languages WHERE is_active = 1 ORDER BY language_name ASC");
$activeLangs = $langStmt->fetchAll(PDO::FETCH_ASSOC);
// 3. Filtrar los idiomas de destino (todos los activos menos el original)
$targetLangs = array_filter($activeLangs, function($lang) use ($detectedLang) {
return $lang['language_code'] !== $detectedLang;
});
// 4. Si no hay idiomas a los que traducir, no hacer nada
if (empty($targetLangs)) {
$logger->info("[TRANSLATION_BUTTONS] No se requieren botones de traducción para el mensaje de Discord #{$message->id} desde '$detectedLang'.");
return;
}
// 5. Crear un solo botón de traducción con el ID del mensaje original
$button = Button::new(Button::STYLE_PRIMARY, 'translate_auto:' . $message->id)
->setLabel('Traducir')
->setEmoji('🌐');
$actionRow = ActionRow::new()->addComponent($button);
// 6. Enviar mensaje del bot con botón como respuesta al mensaje original
$builder = MessageBuilder::new()
->setContent('🌐 Traducción disponible')
->setComponents([$actionRow]);
$message->reply($builder)->done(function () use ($logger, $message, $detectedLang) {
$logger->info("[TRANSLATION_BUTTONS] Botones de traducción enviados para mensaje #{$message->id} (idioma detectado: $detectedLang)");
}, function ($error) use ($logger, $message) {
$logger->error("[TRANSLATION_BUTTONS] Error al enviar botones para mensaje #{$message->id}", ['error' => $error->getMessage()]);
});
} catch (Throwable $e) {
$logger->error("[TRANSLATION_BUTTONS] Error al procesar mensaje para botones de traducción", ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
}
}

View File

@@ -0,0 +1,209 @@
<?php
// Cargar configuración
require_once __DIR__ . '/../../config/config.php';
class HtmlToDiscordMarkdownConverter
{
private const DISCORD_MESSAGE_LIMIT = 2000;
public function convert(string $html): string
{
$dom = new DOMDocument();
libxml_use_internal_errors(true); // Suppress warnings for malformed HTML
// Use LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD to prevent adding html/body tags
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$markdown = '';
foreach ($dom->childNodes as $node) {
$markdown .= $this->processNode($node);
}
// Clean up extra newlines
$markdown = preg_replace('/\n{3,}/', "\n\n", $markdown);
$markdown = trim($markdown);
return $markdown;
}
private function processNode(DOMNode $node): string
{
$output = '';
switch ($node->nodeType) {
case XML_TEXT_NODE:
$output .= $this->decodeHtmlEntities($node->nodeValue);
break;
case XML_ELEMENT_NODE:
switch (strtolower($node->nodeName)) {
case 'b':
case 'strong':
$output .= '**' . $this->processChildren($node) . '**';
break;
case 'i':
case 'em':
$output .= '*' . $this->processChildren($node) . '*';
break;
case 'u':
$output .= '__' . $this->processChildren($node) . '__';
break;
case 'a':
$href = $node->getAttribute('href');
// Ignorar nodos de texto con solo espacios en blanco para un análisis más preciso.
$realChildNodes = [];
foreach ($node->childNodes as $child) {
if ($child->nodeType === XML_TEXT_NODE && trim($child->nodeValue) === '') {
continue;
}
$realChildNodes[] = $child;
}
// Si el único hijo real es una imagen, procesarla directamente.
if (count($realChildNodes) === 1 && strtolower($realChildNodes[0]->nodeName) === 'img') {
$output .= $this->processChildren($node);
} else {
// Si no, trátalo como un enlace de texto normal.
$text = $this->processChildren($node);
$output .= "[{$text}]({$href})";
}
break;
case 'p':
$output .= $this->processChildren($node) . "\n\n";
break;
case 'br':
$output .= "\n";
break;
case 'ul':
case 'ol':
$listContent = $this->processChildren($node);
$listItems = explode("\n", trim($listContent));
$formattedList = [];
$counter = 1;
foreach($listItems as $item) {
if(empty(trim($item))) continue;
if (strtolower($node->nodeName) === 'ul') {
$formattedList[] = '- ' . trim($item);
} else {
$formattedList[] = ($counter++) . '. ' . trim($item);
}
}
$output .= implode("\n", $formattedList) . "\n\n";
break;
case 'li':
$output .= $this->processChildren($node);
break;
case 'img':
$src = $node->getAttribute('src');
if (!empty($src)) {
$absoluteImageUrl = $src;
// Convertir URL relativa a absoluta si es necesario
if (strpos($src, 'http') !== 0 && strpos($src, '//') !== 0) {
$base = rtrim(BOT_BASE_URL, '/');
$path = ltrim($src, '/');
$absoluteImageUrl = "{$base}/{$path}";
}
// Dejar solo la URL para que Discord la renderice
$output .= "\n" . $absoluteImageUrl . "\n";
}
break;
case 'div':
$output .= $this->processChildren($node);
break;
default:
// For unknown tags, just process their children
$output .= $this->processChildren($node);
break;
}
break;
}
return $output;
}
private function processChildren(DOMNode $node): string
{
$childrenOutput = '';
foreach ($node->childNodes as $child) {
$childrenOutput .= $this->processNode($child);
}
return $childrenOutput;
}
private function decodeHtmlEntities(string $encodedString): string
{
return html_entity_decode($encodedString, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
public function convertToArray(string $html): array
{
$parts = [];
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
foreach ($dom->childNodes as $node) {
$this->processNodeForArray($node, $parts);
}
return $parts;
}
private function processNodeForArray(DOMNode $node, array &$parts)
{
if ($node->nodeType === XML_TEXT_NODE) {
$this->addTextPart($parts, $this->decodeHtmlEntities($node->nodeValue));
return;
}
if ($node->nodeType !== XML_ELEMENT_NODE) {
return;
}
switch (strtolower($node->nodeName)) {
case 'img':
$src = $node->getAttribute('src');
if (!empty($src)) {
$absoluteImageUrl = $src;
if (strpos($src, 'http') !== 0 && strpos($src, '//') !== 0) {
$base = rtrim(BOT_BASE_URL, '/');
$path = ltrim($src, '/');
$absoluteImageUrl = "{$base}/{$path}";
}
$parts[] = ['type' => 'image', 'url' => $absoluteImageUrl];
}
break;
case 'p':
case 'div':
if ($node->hasChildNodes()) {
foreach ($node->childNodes as $child) {
$this->processNodeForArray($child, $parts);
}
}
$this->addTextPart($parts, "\n\n");
break;
default:
if ($node->hasChildNodes()) {
foreach ($node->childNodes as $child) {
$this->processNodeForArray($child, $parts);
}
}
break;
}
}
private function addTextPart(array &$parts, string $text)
{
if (empty($text)) return;
// Si la última parte fue texto, la unimos a ella.
if (!empty($parts) && end($parts)['type'] === 'text') {
$parts[key($parts)]['content'] .= $text;
} else {
$parts[] = ['type' => 'text', 'content' => $text];
}
}
}

535
discord_bot.php Executable file
View File

@@ -0,0 +1,535 @@
<?php
// discord_bot.php
// Cargar configuración y dependencias
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/logger.php';
require_once __DIR__ . '/discord/DiscordSender.php';
require_once __DIR__ . '/discord/converters/HtmlToDiscordMarkdownConverter.php';
require_once __DIR__ . '/includes/Translate.php';
// Importar clases necesarias
use Discord\Discord;
use Discord\WebSockets\Intents;
use Discord\WebSockets\Event;
use Discord\Parts\Channel\Message;
use Discord\Parts\Interactions\Interaction;
use Discord\Parts\WebSockets\MessageReaction;
use Discord\Builders\MessageBuilder;
use Discord\Parts\Embed\Embed;
use Discord\Parts\Guild\Member;
use Discord\Builders\Components\ActionRow;
use Discord\Builders\Components\Button;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('DiscordBot');
$logger->pushHandler(new StreamHandler(__DIR__.'/logs/discord_bot.log', Logger::DEBUG));
$logger->info("Iniciando bot de Discord...");
if (!defined('DISCORD_BOT_TOKEN') || empty(DISCORD_BOT_TOKEN)) {
$logger->error("Error Fatal: La constante DISCORD_BOT_TOKEN no está definida o está vacía.");
die();
}
try {
$discord = new Discord([
'token' => DISCORD_BOT_TOKEN,
'intents' => Intents::GUILDS | Intents::GUILD_MESSAGES | Intents::DIRECT_MESSAGES | Intents::GUILD_MEMBERS | Intents::GUILD_MESSAGE_REACTIONS,
'logger' => $logger
]);
$discord->on('ready', function (Discord $discord) {
$discord->getLogger()->info("==================================================");
$discord->getLogger()->info("Bot conectado y listo para escuchar!");
$discord->getLogger()->info("Usuario: {$discord->user->username}#{$discord->user->discriminator}");
$discord->getLogger()->info("==================================================");
});
// Evento para nuevos miembros en el servidor
$discord->on(Event::GUILD_MEMBER_ADD, function (Member $member, Discord $discord) use ($pdo, $logger) {
$logger->info("[NUEVO MIEMBRO] Usuario {$member->user->username} ({$member->id}) se ha unido al servidor.");
try {
$stmt = $pdo->prepare(
"INSERT INTO recipients (platform_id, name, type, platform, language_code)
VALUES (?, ?, 'user', 'discord', 'es')
ON DUPLICATE KEY UPDATE name = VALUES(name)"
);
$stmt->execute([$member->id, $member->user->username]);
$logger->info("[NUEVO MIEMBRO] Usuario {$member->user->username} registrado/actualizado en la base de datos.");
} catch (Throwable $e) {
$logger->error("[NUEVO MIEMBRO] Error al registrar al usuario.", ['error' => $e->getMessage()]);
}
});
// Evento para manejar interacciones (clics en botones)
$discord->on(Event::INTERACTION_CREATE, function (Interaction $interaction, Discord $discord) use ($pdo, $logger) {
// DiscordPHP v7: No existe isButton(). Botones llegan como MESSAGE_COMPONENT (type = 3) y component_type = 2
$type = (int) ($interaction->type ?? 0); // 3 = MESSAGE_COMPONENT
$componentType = (int) ($interaction->data->component_type ?? 0); // 2 = BUTTON
if ($type !== 3 || $componentType !== 2) return;
$customId = $interaction->data->custom_id;
$userId = $interaction->user->id;
$logger->info("[INTERACCION] Usuario $userId hizo clic en el botón: $customId");
try {
if (strpos($customId, 'translate_manual:') === 0) {
$targetLang = substr($customId, strlen('translate_manual:'));
$originalMessage = $interaction->message;
$channelId = $originalMessage->channel_id;
// Responder de inmediato para evitar timeout de interacción
$interaction->respondWithMessage(MessageBuilder::new()->setContent('⌛ Procesando traducción...'), true);
// Extraer contenido del mensaje: primero content plano, luego embeds como fallback
$originalContent = trim((string) ($originalMessage->content ?? ''));
if ($originalContent === '' && count($originalMessage->embeds) > 0) {
foreach ($originalMessage->embeds as $embed) {
$originalContent .= trim((string) ($embed->description ?? '')) . "\n";
}
$originalContent = trim($originalContent);
}
if (!empty($originalContent)) {
try {
$translator = new Translate();
$sourceLang = $translator->detectLanguage($originalContent) ?? 'es';
if ($sourceLang === $targetLang) {
// Fallback: muchas plantillas están en ES; intenta forzar ES como origen
$fallbackSrc = 'es';
if ($fallbackSrc !== $targetLang) {
$translatedText = $translator->translateText($originalContent, $fallbackSrc, $targetLang);
$sourceLang = $fallbackSrc;
} else {
$translatedText = null;
}
} else {
$translatedText = $translator->translateText($originalContent, $sourceLang, $targetLang);
}
// Obtener bandera desde supported_languages
$flag = '';
try {
$stmt = $pdo->prepare("SELECT flag_emoji FROM supported_languages WHERE language_code = ? AND is_active = 1");
$stmt->execute([$targetLang]);
$flag = $stmt->fetchColumn() ?: '';
} catch (\Throwable $e) { /* noop */ }
$flag = $flag !== '' ? $flag : '🏳️';
// Enviar resultado como mensaje normal al canal, mencionando al usuario
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$mention = '<@' . $userId . '>';
$content = "{$mention} {$flag} Traducción ({$sourceLang}{$targetLang}):\n> " . $translatedText;
// Pequeña espera para que el mensaje efímero aparezca primero
usleep(300000);
$sender->sendRawMessage($channelId, $content);
if (empty($translatedText)) {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($channelId, '<@' . $userId . '> El mensaje ya está en este idioma.');
}
} catch (\Throwable $e) {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($channelId, '<@' . $userId . "> Error al traducir: " . $e->getMessage());
}
} else {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($channelId, '<@' . $userId . '> No se encontró contenido para traducir.');
}
return; // Salir después de manejar la interacción
}
// Traducción de plantillas completas (comandos #)
if (strpos($customId, 'translate_template:') === 0) {
$payload = substr($customId, strlen('translate_template:'));
$parts = explode(':', $payload, 2);
if (count($parts) !== 2) {
$interaction->respondWithMessage(MessageBuilder::new()->setContent('Formato de botón inválido.'), true);
return;
}
[$commandKey, $targetLang] = $parts;
$interaction->respondWithMessage(MessageBuilder::new()->setContent('⌛ Traduciendo plantilla...'), true);
try {
// Obtener contenido HTML original de la plantilla
$stmt = $pdo->prepare("SELECT message_content FROM recurrent_messages WHERE telegram_command = ?");
$stmt->execute([$commandKey]);
$template = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$template) {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($interaction->channel_id, '<@' . $userId . '> No se encontró la plantilla.');
return;
}
$originalHtml = $template['message_content'] ?? '';
$converter = new HtmlToDiscordMarkdownConverter();
$partsArr = $converter->convertToArray($originalHtml);
// Unir todos los segmentos de texto (las imágenes no se traducen)
$fullText = '';
foreach ($partsArr as $p) {
if (($p['type'] ?? '') === 'text') {
$fullText .= ($fullText === '' ? '' : "\n\n") . trim((string)$p['content']);
}
}
if ($fullText === '') {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($interaction->channel_id, '<@' . $userId . '> No hay contenido de texto para traducir en la plantilla.');
return;
}
$translator = new Translate();
$sourceLang = $translator->detectLanguage($fullText) ?? 'es';
if ($sourceLang === $targetLang) {
// Fallback: fuerza ES como origen si coincide con el destino (común en nuestras plantillas)
$fallbackSrc = 'es';
if ($fallbackSrc !== $targetLang) {
$translated = $translator->translateText($fullText, $fallbackSrc, $targetLang);
$sourceLang = $fallbackSrc;
} else {
$translated = '';
}
} else {
$translated = $translator->translateText($fullText, $sourceLang, $targetLang);
}
if ($translated === '') {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($interaction->channel_id, '<@' . $userId . '> El contenido ya está en este idioma.');
return;
}
// Bandera
$flag = '';
try {
$stmt = $pdo->prepare("SELECT flag_emoji FROM supported_languages WHERE language_code = ? AND is_active = 1");
$stmt->execute([$targetLang]);
$flag = $stmt->fetchColumn() ?: '';
} catch (\Throwable $e) { /* noop */ }
$flag = $flag !== '' ? $flag : '🏳️';
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
// Pequeña espera para que el mensaje efímero aparezca primero
usleep(300000);
$sender->sendRawMessage($interaction->channel_id, '<@' . $userId . "> {$flag} Traducción plantilla ({$sourceLang}{$targetLang}):\n" . $translated);
} catch (\Throwable $e) {
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sender->sendRawMessage($interaction->channel_id, '<@' . $userId . "> Error al traducir plantilla: " . $e->getMessage());
}
return;
}
if ($customId === 'platicar_bot' || $customId === 'usar_ia') {
$newMode = ($customId === 'platicar_bot') ? 'bot' : 'ia';
$stmt = $pdo->prepare("UPDATE recipients SET chat_mode = ? WHERE platform_id = ? AND platform = 'discord'");
$stmt->execute([$newMode, $userId]);
$responseText = $newMode === 'bot'
? "🤖 Modo cambiado a 'Platicar con bot'. Ahora puedes usar los comandos normales como `/comandos`."
: "🧠 Modo cambiado a 'Usar IA'. Todo lo que escribas será procesado por la IA.\n\nEscribe `/agente` para volver a este menú.";
$interaction->respondWithMessage(MessageBuilder::new()->setContent($responseText), true);
$logger->info("[MODO AGENTE] Usuario $userId cambió al modo: $newMode");
}
} catch (Throwable $e) {
$logger->error("[INTERACCION] Error al procesar el botón.", ['error' => $e->getMessage()]);
$interaction->respondWithMessage(MessageBuilder::new()->setContent("Hubo un error al cambiar de modo."), true);
}
});
// Evento para manejar mensajes
$discord->on(Event::MESSAGE_CREATE, function (Message $message, Discord $discord) use ($pdo, $logger) {
if ($message->author->bot) return;
$logger->info("[Mensaje Recibido] En canal '{$message->channel->name}' de @{$message->author->username}: {$message->content}");
$isPrivateChat = $message->channel->is_private;
$userId = $message->author->id;
$content = $message->content;
try {
if ($isPrivateChat) {
// --- LÓGICA DE CHAT PRIVADO (AGENTE/IA/BOT) ---
$logger->info("[MODO AGENTE] Mensaje en chat privado de $userId.");
$stmt = $pdo->prepare("SELECT chat_mode FROM recipients WHERE platform_id = ? AND platform = 'discord'");
$stmt->execute([$userId]);
$userChatMode = $stmt->fetchColumn();
if ($userChatMode === false) {
// --- Nuevo Usuario en DM ---
$logger->info("[NUEVO USUARIO DM] Usuario $userId no encontrado. Registrando...");
$userName = $message->author->username;
$insertStmt = $pdo->prepare(
"INSERT INTO recipients (platform_id, name, type, platform, language_code, chat_mode)
VALUES (?, ?, 'user', 'discord', 'es', 'agent')
ON DUPLICATE KEY UPDATE name = VALUES(name)"
);
$insertStmt->execute([$userId, $userName]);
$logger->info("[NUEVO USUARIO DM] Usuario $userId ($userName) registrado con modo 'agent'.");
$userChatMode = 'agent'; // Forzar modo agente para la primera interacción
}
if (trim($content) === '/agente') {
$stmt = $pdo->prepare("UPDATE recipients SET chat_mode = 'agent' WHERE platform_id = ? AND platform = 'discord'");
$stmt->execute([$userId]);
$userChatMode = 'agent';
$logger->info("[MODO AGENTE] Usuario $userId usó /agente. Reseteando a modo 'agent'.");
}
switch ($userChatMode) {
case 'agent':
$builder = MessageBuilder::new()->setContent("👋 Hola! ¿Cómo quieres interactuar?");
$actionRow = ActionRow::new()
->addComponent(Button::new(Button::STYLE_PRIMARY, 'platicar_bot')->setLabel('🤖 Platicar con bot'))
->addComponent(Button::new(Button::STYLE_SUCCESS, 'usar_ia')->setLabel('🧠 Usar IA'));
$builder->addComponent($actionRow);
$message->channel->sendMessage($builder);
return;
case 'ia':
$logger->info("[MODO IA] Mensaje de $userId para la IA: $content");
$n8nWebhookUrl = $_ENV['N8N_IA_WEBHOOK_URL_DISCORD'] ?? null;
if ($n8nWebhookUrl) {
$postData = [
'chat_id' => $message->channel_id,
'user_id' => $userId,
'message' => $content,
'name' => $message->author->username
];
$ch = curl_init($n8nWebhookUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_exec($ch);
curl_close($ch);
$logger->info("[MODO IA] Mensaje reenviado a n8n.");
} else {
$logger->error("[MODO IA] La variable N8N_IA_WEBHOOK_URL no está configurada.");
}
return;
case 'bot':
// Continuar con la lógica normal de bot abajo
break;
}
}
// --- LÓGICA DE BOT NORMAL (CHATS PÚBLICOS O MODO 'bot') ---
if (strpos($content, '/') === 0 || strpos($content, '#') === 0) {
handleDiscordCommand($message, $pdo, $logger);
} else if (strtolower($content) === '!ping') {
$message->reply('pong!');
} else {
handleDiscordTranslation($message, $pdo, $logger);
}
} catch (Throwable $e) {
$logger->error("!!!!!!!!!! ERROR FATAL CAPTURADO !!!!!!!!!!", [
'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString()
]);
}
});
$discord->run();
} catch (Throwable $e) {
$logger->critical("!!!!!!!!!! ERROR FATAL AL INICIAR !!!!!!!!!!", [
'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine()
]);
die();
}
function handleDiscordCommand(Message $message, PDO $pdo, Logger $logger)
{
$text = trim($message->content);
$userId = $message->author->id;
if (strpos($text, '#') === 0) {
$command = ltrim($text, '#');
$logger->info("[Comando] Usuario @{$message->author->username} solicitó: #{$command}");
try {
$stmt = $pdo->prepare("SELECT message_content FROM recurrent_messages WHERE telegram_command = ?");
$stmt->execute([$command]);
$template = $stmt->fetch(PDO::FETCH_ASSOC);
if ($template) {
$logger->info("[Comando] Plantilla encontrada para #{$command}.");
$originalHtml = $template['message_content'];
$sender = new DiscordSender(DISCORD_BOT_TOKEN);
$sentMessageIds = $sender->sendMessage($message->channel_id, $originalHtml);
if ($sentMessageIds && !empty($sentMessageIds)) {
$firstMessageId = $sentMessageIds[0];
$logger->info("Mensajes enviados, primer ID: {$firstMessageId}");
// Añadir botones de traducción dinámicos
$message->channel->messages->fetch($firstMessageId)->done(function (Message $sentMessage) use ($pdo, $logger, $command) {
$langStmt = $pdo->query("SELECT language_code, language_name, flag_emoji FROM supported_languages WHERE is_active = 1");
$activeLangs = $langStmt->fetchAll(PDO::FETCH_ASSOC);
if (count($activeLangs) > 1) {
$components = [];
$actionRow = ActionRow::new();
$buttonCount = 0;
foreach ($activeLangs as $lang) {
// Para plantillas, incluir el comando en el custom_id para traducir el contenido completo original
$button = Button::new(Button::STYLE_SECONDARY, 'translate_template:' . $command . ':' . $lang['language_code'])
->setLabel($lang['language_name']);
if (!empty($lang['flag_emoji'])) {
$button->setEmoji($lang['flag_emoji']);
}
$actionRow->addComponent($button);
$buttonCount++;
// Discord permite máximo 5 botones por ActionRow
if ($buttonCount % 5 === 0) {
$components[] = $actionRow;
$actionRow = ActionRow::new();
}
}
if ($buttonCount % 5 !== 0) {
$components[] = $actionRow;
}
$builder = MessageBuilder::new()->setComponents($components);
$sentMessage->edit($builder);
$logger->info("Botones de traducción añadidos al mensaje {$sentMessage->id}");
}
});
}
} else {
$message->reply("El comando `#{$command}` no fue encontrado.");
}
} catch (Throwable $e) {
$logger->error("[Error Comando] Procesando #{$command}", ['error' => $e->getMessage()]);
$message->reply("Ocurrió un error inesperado al procesar tu comando.");
}
} elseif (strpos($text, '/setlang') === 0) {
$parts = explode(' ', $text, 2);
if (count($parts) < 2 || strlen(trim($parts[1])) !== 2) {
$message->reply("❌ Formato incorrecto. Usa: `/setlang es`");
return;
}
$newLangCode = strtolower(trim($parts[1]));
$stmt = $pdo->prepare("UPDATE recipients SET language_code = ? WHERE platform_id = ? AND platform = 'discord'");
$stmt->execute([$newLangCode, $userId]);
$message->reply("✅ Tu idioma ha sido establecido a '" . strtoupper($newLangCode) . "'.");
} elseif (strpos($text, '/bienvenida') === 0) {
$configStmt = $pdo->query("SELECT * FROM telegram_bot_messages WHERE id = 1");
$welcomeConfig = $configStmt->fetch(PDO::FETCH_ASSOC);
if ($welcomeConfig && $welcomeConfig['is_active']) {
$messageText = str_replace('{user_name}', $message->author->username, $welcomeConfig['message_text']);
$converter = new HtmlToDiscordMarkdownConverter();
$markdownText = $converter->convert($messageText);
$builder = MessageBuilder::new()->setContent($markdownText);
$actionRow = ActionRow::new()
->addComponent(Button::new(Button::STYLE_LINK, $welcomeConfig['group_invite_link'])->setLabel($welcomeConfig['button_text']));
$builder->addComponent($actionRow);
$message->channel->sendMessage($builder);
} else {
$message->reply(" La función de bienvenida no está activa.");
}
} elseif (strpos($text, '/comandos') === 0) {
$stmt = $pdo->query("SELECT telegram_command, name FROM recurrent_messages WHERE telegram_command IS NOT NULL AND telegram_command != '' ORDER BY name ASC");
$commands = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($commands)) {
$message->reply(" No hay comandos personalizados disponibles.");
} else {
$response = "**LISTA DE COMANDOS DISPONIBLES**\n\n";
foreach ($commands as $cmd) {
$command = trim($cmd['telegram_command']);
if (strpos($command, '#') !== 0) $command = '#' . $command;
$name = trim($cmd['name']);
$response .= "`" . $command . "` - " . $name . "\n";
}
$response .= "\n Escribe el comando para usarlo.";
$message->channel->sendMessage($response);
}
} elseif (strpos($text, '/agente') === 0) {
$prompt = trim(substr($text, strlen('/agente')));
$logger->info("[Comando] Usuario @{$message->author->username} solicitó: /agente con prompt: '{$prompt}'");
if (empty($prompt)) {
$message->reply("Por favor, escribe tu consulta después de /agente.");
return;
}
$n8nWebhookUrl = $_ENV['N8N_IA_WEBHOOK_URL_DISCORD'] ?? null;
if ($n8nWebhookUrl) {
$postData = [
'chat_id' => $message->channel_id,
'user_id' => $message->author->id,
'message' => $prompt,
'name' => $message->author->username
];
$ch = curl_init($n8nWebhookUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_exec($ch);
curl_close($ch);
$logger->info("[Comando /agente] Prompt reenviado a n8n.");
$message->reply("Tu solicitud ha sido enviada al agente. Recibirás una respuesta en breve.");
} else {
$logger->error("[Comando /agente] La variable N8N_IA_WEBHOOK_URL no está configurada.");
$message->reply("No se pudo procesar tu solicitud. El servicio de agente no está configurado.");
}
}
}
function handleDiscordTranslation(Message $message, PDO $pdo, Logger $logger)
{
try {
$translator = new Translate();
$text = $message->content;
$detectedLang = $translator->detectLanguage(strip_tags($text)) ?? 'es';
// Obtener idiomas activos y encolar traducciones
$langStmt = $pdo->query("SELECT language_code FROM supported_languages WHERE is_active = 1");
$activeLangs = $langStmt->fetchAll(PDO::FETCH_COLUMN);
if (in_array($detectedLang, $activeLangs)) {
foreach ($activeLangs as $targetLang) {
if ($detectedLang === $targetLang) {
continue; // No traducir al mismo idioma
}
$sql = "INSERT INTO translation_queue (platform, message_id, chat_id, user_id, text_to_translate, source_lang, target_lang) VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
'discord',
$message->id,
$message->channel_id,
$message->author->id,
$text,
$detectedLang,
$targetLang
]);
$logger->info("[QUEUE] Mensaje de Discord #{$message->id} encolado para traducción de '$detectedLang' a '$targetLang'.");
}
}
} catch (Throwable $e) {
$logger->error("[Error Encolando Traducción Discord]", ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
}
}

84
docker/Dockerfile Executable file
View File

@@ -0,0 +1,84 @@
FROM ubuntu:22.04
LABEL maintainer="bot-lastwar"
LABEL description="Bot LastWar - Discord/Telegram Bot with Web Interface"
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
apache2 \
php \
php-cli \
php-mysql \
php-mbstring \
php-xml \
php-curl \
php-zip \
php-json \
libapache2-mod-php \
composer \
nodejs \
npm \
supervisor \
curl \
git \
unzip \
nano \
libzip-dev \
bash \
bash-completion \
readline-common \
less \
&& rm -rf /var/lib/apt/lists/*
RUN a2enmod rewrite headers env
RUN npm install -g yarn
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN mkdir -p /var/www/html/bot/logs \
&& mkdir -p /var/www/html/bot/galeria \
&& mkdir -p /var/run/supervisor \
&& mkdir -p /etc/supervisor/conf.d
COPY docker/apache/*.conf /etc/apache2/sites-available/
COPY docker/supervisor/*.conf /etc/supervisor/conf.d/
RUN a2ensite reod-dragon.ddns.net.conf || true
RUN a2dissite 000-default.conf || true
WORKDIR /var/www/html/bot
EXPOSE 80
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY docker/zimaos.env.template /var/www/html/bot/docker/
COPY . . /var/www/html/bot/
# Eliminar archivos .env de desarrollo que vienen del código fuente
# En Docker usaremos el archivo .env generado desde variables de entorno
RUN rm -f /var/www/html/bot/.env.reod /var/www/html/bot/.env.pruebas /var/www/html/bot/.env.example 2>/dev/null || true
RUN rm -rf /var/www/html/bot/docker/Dockerfile \
/var/www/html/bot/docker-compose*.yml 2>/dev/null || true
RUN chmod -R 777 /var/www/html/bot/logs \
/var/www/html/bot/galeria \
/var/run/supervisor
RUN touch /var/log/apache2/error.log \
/var/log/apache2/access.log \
&& chmod 666 /var/log/apache2/*.log
# Set index.php as first DirectoryIndex
RUN cat > /etc/apache2/mods-enabled/dir.conf << 'EOF'
<IfModule mod_dir.c>
DirectoryIndex index.php index.html index.cgi index.pl index.xhtml index.htm
</IfModule>
EOF
CMD ["/entrypoint.sh"]

165
docker/README-ZimaOS.md Executable file
View File

@@ -0,0 +1,165 @@
# Docker Configuration for ZimaOS
## Archivos Incluidos
```
docker/
├── Dockerfile # Imagen base Ubuntu 22.04 (incluye todo el proyecto)
├── docker-compose.yml # Desarrollo local
├── docker-compose.local.yml # Pruebas locales con volumen
├── zimaos-docker-compose.yml # Producción ZimaOS
├── zimaos.env.template # Template de variables
├── entrypoint.sh # Script de inicio (genera .env)
├── supervisord.conf # Configuración Supervisor
├── supervisor/
│ ├── discordbot.conf # Bot de Discord
│ └── translation-worker.conf # Worker de traducciones
├── apache/
│ ├── ponsprueba.ddns.net.conf # Apache desarrollo
│ └── reod-dragon.ddns.net.conf # Apache producción
└── www/
└── index.html # Página de bienvenida
```
## Uso en ZimaOS
### 1. Preparar Archivos
Copia `zimaos-docker-compose.yml` a tu almacenamiento en ZimaOS.
### 2. Configurar Variables
Edita `zimaos-docker-compose.yml` y ajusta las variables de entorno:
```yaml
environment:
- DB_HOST=tu_host_db
- DB_PORT=tu_puerto
- DB_NAME=tu_db
- DB_USER=tu_usuario
- DB_PASS=tu_password
- DISCORD_BOT_TOKEN=tu_token
- TELEGRAM_BOT_TOKEN=tu_token
- APP_BASE_URL=http://tu-dominio:8086
# ... otras variables
```
### 3. Ejecutar
```bash
# Descargar imagen
docker compose -f zimaos-docker-compose.yml pull
# Iniciar contenedor
docker compose -f zimaos-docker-compose.yml up -d
# Ver logs
docker logs -f bot_lastwar_funcionando
```
### 4. Detener
```bash
docker compose -f zimaos-docker-compose.yml down
```
## Actualizar a nueva versión
```bash
docker compose -f zimaos-docker-compose.yml pull
docker compose -f zimaos-docker-compose.yml up -d
```
## Variables de Entorno
| Variable | Descripción | Ejemplo |
|----------|-------------|---------|
| `APP_ENVIRONMENT` | Entorno (pruebas\|reod) | pruebas |
| `USE_LOCALHOST` | Usar URLs localhost | false |
| `DB_*` | Configuración base de datos | Ver template |
| `DISCORD_*` | Credenciales Discord | Ver template |
| `TELEGRAM_*` | Credenciales Telegram | Ver template |
| `N8N_*` | URLs de N8N | Ver template |
| `JWT_SECRET` | Secreto para sesiones | `openssl rand -hex 32` |
| `INTERNAL_API_KEY` | API key interna | `openssl rand -hex 32` |
| `APP_BASE_URL` | URL base pública | http://tu-ip:8086 |
## Desarrollo Local
Para desarrollar localmente con volúmenes (los cambios se ven sin rebuild):
```bash
docker compose -f docker-compose.local.yml up -d
```
Para producción (imagen autocontenida):
```bash
docker compose -f docker-compose.yml build
docker compose -f docker-compose.yml up -d
```
## Puertos
- **8086** → Puerto HTTP del contenedor (80)
## Servicios
- **Apache2** - Servidor web
- **Discord Bot** - Bot de Discord
- **Translation Worker** - Procesador de traducciones
## Volúmenes Persistentes
Los volúmenes para logs y galería se crean automáticamente con Docker:
- `bot-logs` → /var/www/html/bot/logs
- `bot-galeria` → /var/www/html/bot/galeria
Para verlos:
```bash
docker volume ls | grep bot
```
## Solución de Problemas
### El contenedor no inicia
```bash
docker logs bot_lastwar_funcionando
```
### Verificar variables de entorno
```bash
docker exec bot_lastwar_funcionando env | grep -E "DB_|DISCORD_|TELEGRAM_|APP_"
```
### Verificar archivo .env generado
```bash
docker exec bot_lastwar_funcionando cat /var/www/html/bot/.env
```
### Base de datos no conecta
Verificar credenciales en variables de entorno.
### Reiniciar contenedor
```bash
docker restart bot_lastwar_funcionando
```
## Construcción de imagen (para actualizar)
```bash
# En la carpeta del proyecto
docker compose build
# Subir al registry
docker tag bot-bot-lastwar:latest 10.10.4.3:5000/bot-lastwar:latest
docker push 10.10.4.3:5000/bot-lastwar:latest
```

72
docker/README.md Executable file
View File

@@ -0,0 +1,72 @@
# Docker Configuration for Bot LastWar
## Estructura de Archivos
```
docker/
├── Dockerfile # Imagen base Ubuntu con Apache, PHP, Composer, Node, Supervisor
├── docker-compose.yml # Orquestación del contenedor
├── entrypoint.sh # Script de inicio
├── supervisord.conf # Configuración principal de Supervisor
├── supervisor/
│ ├── discordbot.conf # Proceso del bot de Discord
│ └── translation-worker.conf # Worker de traducciones
├── apache/
│ ├── ponsprueba.ddns.net.conf # Configuración Apache desarrollo
│ └── reod-dragon.ddns.net.conf # Configuración Apache producción
├── www/
│ └── index.html # Página de bienvenida
└── zimaos.env.template # Template de variables para ZimaOS
```
## Configuración para ZimaOS
1. Copia `docker/zimaos.env.template` a `.env` en ZimaOS
2. Ajusta las variables según tu red
3. La variable clave es `APP_ENVIRONMENT`:
- `pruebas` → usa `.env.pruebas`
- `reod` → usa `.env.reod`
## Construcción y Ejecución
```bash
# Construir la imagen
docker-compose build
# Ejecutar el contenedor
docker-compose up -d
# Ver logs
docker logs bot_lastwar_funcionando
# Detener
docker-compose down
```
## Puertos
- **8086 (host)** → **80 (contenedor)**
- Accede a: http://localhost:8086
## Servicios Incluidos
- **Apache2** - Servidor web en puerto 80
- **PHP** - Interpreter para la aplicación
- **Composer** - Gestor de dependencias PHP
- **Node.js** - Runtime JavaScript
- **Supervisor** - Gestión de procesos (bot + worker)
- **Discord Bot** - Bot de Discord (configurable via APP_ENVIRONMENT)
- **Translation Worker** - Procesador de cola de traducciones
## Variables de Entorno
| Variable | Descripción | Default |
|----------|-------------|---------|
| `APP_ENVIRONMENT` | Entorno (pruebas\|reod) | pruebas |
| `TZ` | Zona horaria | UTC |
## Notas
- Los logs se almacenan en `./logs/`
- La galería de imágenes está en `./galeria/`
- El contenedor copia automáticamente el .env correspondiente según `APP_ENVIRONMENT`

View File

@@ -0,0 +1,21 @@
<VirtualHost *:80>
ServerName reod-dragon.ddns.net
SetEnv APP_ENVIRONMENT "reod"
SetEnv DOCKER_CONTAINER "1"
# Content Security Policy para permitir jsdelivr CDN
Header set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://code.jquery.com 'unsafe-inline'; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; connect-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' https://cdn.jsdelivr.net data:;"
ErrorLog /var/www/html/bot/logs/reod_error.log
CustomLog /var/www/html/bot/logs/reod_access.log combined
DocumentRoot /var/www/html/bot/
<Directory /var/www/html/bot/>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
php_admin_value upload_max_filesize 20M
php_admin_value post_max_size 20M
</VirtualHost>

38
docker/deploy.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# ============================================
# Script para construir y subir imagen Docker
# Target: 10.10.4.3:5000/bot-lastwar:v2
# ============================================
set -e
# Cambiar al directorio del proyecto (carpeta padre de donde está este script)
cd "$(dirname "$0")/.."
echo "=========================================="
echo "Construyendo imagen Docker..."
echo "Directorio: $(pwd)"
echo "=========================================="
# Construir la imagen usando el Dockerfile en docker/
docker build -t bot-lastwar:latest -f docker/Dockerfile .
echo "=========================================="
echo "Etiquetando imagen para el registry..."
echo "=========================================="
# Etiquetar la imagen con la versión v2
docker tag bot-lastwar:latest 10.10.4.3:5000/bot-lastwar:v2
echo "=========================================="
echo "Subiendo imagen al registry..."
echo "=========================================="
# Subir al registry
docker push 10.10.4.3:5000/bot-lastwar:v2
echo "=========================================="
echo "Imagen subida exitosamente:"
echo "10.10.4.3:5000/bot-lastwar:v2"
echo "=========================================="

60
docker/diagnostico_zimaos.sh Executable file
View File

@@ -0,0 +1,60 @@
# Comandos para diagnosticar problemas en ZimaOS
# Copia y pega estos comandos en la terminal de ZimaOS
# 1. Ver logs del discordbot
docker exec bot_lastwar_funcionando cat /var/www/html/bot/logs/discordbot.out.log
# 2. Ver logs del discordbot (errores)
docker exec bot_lastwar_funcionando cat /var/www/html/bot/logs/discordbot.err.log
# 3. Ver logs del translation-worker
docker exec bot_lastwar_funcionando cat /var/www/html/bot/logs/translation-worker.out.log
# 4. Verificar si el archivo discord_bot.php existe
docker exec bot_lastwar_funcionando ls -la /var/www/html/bot/discord_bot.php
# 5. Verificar el .env generado
docker exec bot_lastwar_funcionando cat /var/www/html/bot/.env
# 6. Verificar si los archivos del proyecto están montados
docker exec bot_lastwar_funcionando ls -la /var/www/html/bot/ | head -20
# 7. Verificar permisos de la carpeta logs
docker exec bot_lastwar_funcionando ls -la /var/www/html/bot/logs/
# 8. Ver todos los procesos en ejecución
docker exec bot_lastwar_funcionando ps aux
# 9. Ver estado de supervisor
docker exec bot_lastwar_funcionando supervisorctl status
# ==== DIAGNÓSTICO DE APACHE ====
# 10. Ver si Apache está escuchando en puerto 80
docker exec bot_lastwar_funcionando netstat -tulpn | grep :80
# 11. Ver procesos de Apache
docker exec bot_lastwar_funcionando ps aux | grep apache
# 12. Ver log de error de Apache
docker exec bot_lastwar_funcionando cat /var/log/apache2/error.log
# 13. Ver log de error del sitio
docker exec bot_lastwar_funcionando cat /var/www/html/bot/logs/apache2.err.log
# 14. Probar Apache directamente dentro del contenedor
docker exec bot_lastwar_funcionando curl -v http://localhost:80/ 2>&1 | head -20
# 15. Ver configuración de Apache
docker exec bot_lastwar_funcionando apache2ctl -S 2>&1
# 16. Ver módulos de Apache cargados
docker exec bot_lastwar_funcionando apache2ctl -M 2>&1 | grep php
# 17. Verificar si hay algo escuchando en el puerto publicado
netstat -tulpn | grep 8086
# 18. Ver logs del contenedor
docker logs bot_lastwar_funcionando 2>&1 | tail -30
# 19. Reiniciar el contenedor (si necesitas hacer cambios)
docker restart bot_lastwar_funcionando

20
docker/docker-compose.local.yml Executable file
View File

@@ -0,0 +1,20 @@
version: '3.8'
services:
bot-lastwar:
container_name: bot_lastwar_funcionando
build:
context: .
dockerfile: docker/Dockerfile
ports:
- "8086:80"
environment:
- APP_ENVIRONMENT=pruebas
- USE_LOCALHOST=true
- TZ=UTC
volumes:
- .:/var/www/html/bot:rw
- ./docker/supervisor/supervisord.conf:/etc/supervisor/supervisord.conf:ro
working_dir: /var/www/html/bot
restart: unless-stopped
command: ["/entrypoint.sh"]

66
docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
set -e
echo "=========================================="
echo "Bot LastWar - Docker Startup Script"
echo "=========================================="
ENVIRONMENT=${APP_ENVIRONMENT:-reod}
echo "Environment: $ENVIRONMENT"
echo "Generando archivo .env desde variables de entorno..."
# Eliminar todos los archivos .env existentes para evitar conflictos
rm -f /var/www/html/bot/.env* 2>/dev/null || true
env | grep -E "^(DB_|JWT_|DISCORD_|TELEGRAM_|LIBRETRANSLATE_|N8N_|APP_|INTERNAL_API_KEY|TEST_ENV_LOAD)" > /tmp/env_vars.txt
# Determinar el nombre del archivo .env según el entorno
if [ "$ENVIRONMENT" = "reod" ]; then
ENV_FILE="/var/www/html/bot/.env"
else
ENV_FILE="/var/www/html/bot/.env.${ENVIRONMENT}"
fi
{
echo "# Auto-generado desde variables de entorno"
echo "# NO editar manualmente - los cambios se perderán al reiniciar"
echo ""
while IFS='=' read -r name value; do
[ -z "$name" ] && continue
# Renombrar APP_BASE_URL a APP_URL para compatibilidad con el código
if [ "$name" = "APP_BASE_URL" ]; then
echo "APP_URL=${value}"
else
echo "${name}=${value}"
fi
done < /tmp/env_vars.txt
} > "$ENV_FILE"
rm -f /tmp/env_vars.txt
echo "Archivo .env generado"
if [ -f /var/www/html/bot/composer.json ]; then
echo "Instalando dependencias de Composer..."
composer install --no-interaction --optimize-autoloader --no-dev 2>/dev/null || composer install --no-interaction
fi
mkdir -p /var/www/html/bot/logs 2>/dev/null || true
mkdir -p /var/www/html/bot/galeria 2>/dev/null || true
mkdir -p /var/run/supervisor 2>/dev/null || true
chmod -R 777 /var/www/html/bot/logs /var/log/apache2 2>/dev/null || true
touch /var/log/apache2/error.log 2>/dev/null || true
touch /var/log/apache2/access.log 2>/dev/null || true
chmod 666 /var/log/apache2/*.log 2>/dev/null || true
chown -R www-data:www-data /var/www/html/bot/logs /var/log/apache2 2>/dev/null || true
echo "Configurando sitio Apache..."
if [ "$ENVIRONMENT" = "reod" ]; then
a2ensite reod-dragon.ddns.net.conf 2>/dev/null || true
else
a2dissite reod-dragon.ddns.net.conf 2>/dev/null || true
fi
echo "Iniciando Supervisor..."
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,9 @@
[program:discordbot]
command=/usr/bin/php /var/www/html/bot/discord_bot.php
directory=/var/www/html/bot/
autostart=true
autorestart=true
stderr_logfile=/var/www/html/bot/logs/discordbot.err.log
stdout_logfile=/var/www/html/bot/logs/discordbot.out.log
user=root
redirect_stderr=false

View File

@@ -0,0 +1,17 @@
[supervisord]
nodaemon=true
logfile=/var/www/html/bot/logs/supervisor.log
loglevel=info
pidfile=/var/run/supervisor/supervisord.pid
childlogdir=/var/www/html/bot/logs
user=root
[program:apache2]
command=/bin/bash -c "source /etc/apache2/envvars && exec apache2 -D FOREGROUND"
autostart=true
autorestart=true
stdout_logfile=/var/www/html/bot/logs/apache2.out.log
stderr_logfile=/var/www/html/bot/logs/apache2.err.log
[include]
files=/etc/supervisor/conf.d/*.conf

View File

@@ -0,0 +1,19 @@
[program:translation-worker]
command=/usr/bin/php /var/www/html/bot/process_translation_queue.php
directory=/var/www/html/bot/
user=root
autostart=true
autorestart=true
stdout_logfile=/var/www/html/bot/logs/translation-worker.out.log
stderr_logfile=/var/www/html/bot/logs/translation-worker.err.log
redirect_stderr=true
[program:queue-processor]
command=/bin/bash -c "while true; do sleep 60; /usr/bin/php /var/www/html/bot/process_queue.php; done"
directory=/var/www/html/bot/
user=root
autostart=true
autorestart=true
stdout_logfile=/var/www/html/bot/logs/queue-processor.out.log
stderr_logfile=/var/www/html/bot/logs/queue-processor.err.log
redirect_stderr=true

View File

@@ -0,0 +1,73 @@
name: bot-lastwar
services:
bot-lastwar:
cpu_shares: 90
command:
- /entrypoint.sh
container_name: bot_lastwar_funcionando
deploy:
resources:
limits:
memory: 16663138304
reservations:
devices: []
environment:
- APP_BASE_URL=https://reod-dragon.ddns.net
- APP_ENV=production
- APP_ENVIRONMENT=reod
- APP_URL=https://reod-dragon.ddns.net
- DB_DIALECT=mysql
- DB_HOST=10.10.4.17
- DB_NAME=bot
- DB_PASS=MiPo6425@@
- DB_PORT=3390
- DB_USER=nickpons666
- DISCORD_BOT_TOKEN=MTM4NTc5MDM0NDU5NDk4NTA2MQ.GvobiS.TRQM9dX7vDjmuGVa3Ckp6YRtGEWxdW0gBDbvCI
- DISCORD_CLIENT_ID=1385790344594985061
- DISCORD_CLIENT_SECRET=hK9SNiYdenHQVxakt8Mx3RoMkZ5oOJvk
- DISCORD_GUILD_ID=1338327171013541999
- INTERNAL_API_KEY=b5dda33b8eb062e06e100c98a8947c0248b6e38973dfd689e81f725af238d23c
- JWT_ALGORITHM=HS256
- JWT_EXPIRATION=3600
- JWT_SECRET=19c5020fa8207d2c3b9e82f430784667e001f1eb733848922f7bcb9be98f93c2
- LIBRETRANSLATE_URL=http://10.10.4.17:5000
- N8N_IA_WEBHOOK_URL=https://n8n-dragon.ddns.net/webhook/ia
- N8N_IA_WEBHOOK_URL_DISCORD=https://n8n-dragon.ddns.net/webhook/42e803ae-8aee-4b1c-858a-6c6d3fbb6230
- N8N_PROCESS_QUEUE_WEBHOOK_URL=https://n8n-dragon.ddns.net/webhook/telegram-unified
- N8N_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4MWY4YjU3YS0wMTg2LTQ1NTctOWZlMC1jYWUxNjZlYzZlMTkiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzU1OTMwODM5fQ.2tLbddyhMTKplp9n-qVNiAgQCUj2YEvVASwLnNjgCt0
- N8N_URL=https://n8n-dragon.ddns.net
- TELEGRAM_BOT_TOKEN=8469229183:AAEVIV5e7rjDXKNgFTX0dnCW6JWB88X4p2I
- TELEGRAM_WEBHOOK_TOKEN=webhook_secure_token_12345
- TEST_ENV_LOAD=caos_cargado
- TZ=America/Mexico_City
- USE_LOCALHOST=false
image: 10.10.4.3:5000/bot-lastwar:latest
labels:
icon: https://icon.casaos.io/main/all/bot-lastwar.png
ports:
- target: 80
published: "8086"
protocol: tcp
restart: unless-stopped
user: 0:0
volumes:
- type: bind
source: /media/ZimaOS-HD/AppData/bot_lastwar/logs
target: /var/www/html/bot/logs
devices: []
cap_add: []
network_mode: bridge
privileged: false
x-casaos:
author: self
category: self
hostname: ""
icon: https://icon.casaos.io/main/all/bot-lastwar.png
index: /
is_uncontrolled: false
port_map: "8086"
scheme: http
store_app_id: bot-lastwar
title:
custom: ""
en_us: bot-lastwar

View File

@@ -0,0 +1,77 @@
# ZimaOS Docker Compose Configuration for Bot LastWar
# Contenedor: bot_lastwar_funcionando
# Puerto: 8086 -> 80
#
# INSTRUCCIONES PARA ZIMAOS:
# 1. Copia este archivo en ZimaOS
# 2. Ajusta todas las variables de entorno según tu entorno
# 3. La imagen ya contiene todos los archivos del proyecto
# 4. Ejecuta: docker compose -f zimaos-docker-compose.yml up -d
#
# La imagen se descarga automáticamente del registry: 10.10.4.3:5000/bot-lastwar:latest
services:
bot-lastwar:
image: 10.10.4.3:5000/bot-lastwar:latest
container_name: bot_lastwar_funcionando
ports:
- "8086:80"
environment:
# Configuración de la aplicación
- APP_ENV=production
- APP_ENVIRONMENT=reod
- APP_DEBUG=false
- USE_LOCALHOST=false
- TZ=America/Mexico_City
- APP_URL=https://reod-dragon.ddns.net
# Configuración de la base de datos
- DB_HOST=10.10.4.17
- DB_PORT=3390
- DB_NAME=bot
- DB_USER=nickpons666
- DB_PASS=MiPo6425@@
- DB_DIALECT=mysql
# Configuración de JWT
- JWT_SECRET=19c5020fa8207d2c3b9e82f430784667e001f1eb733848922f7bcb9be98f93c2
- JWT_ALGORITHM=HS256
- JWT_EXPIRATION=3600
# Configuración de Discord
- DISCORD_GUILD_ID=1338327171013541999
- DISCORD_CLIENT_ID=1385790344594985061
- DISCORD_CLIENT_SECRET=hK9SNiYdenHQVxakt8Mx3RoMkZ5oOJvk
- DISCORD_BOT_TOKEN=MTM4NTc5MDM0NDU5NDk4NTA2MQ.GvobiS.TRQM9dX7vDjmuGVa3Ckp6YRtGEWxdW0gBDbvCI
# Configuración de Telegram
- TELEGRAM_BOT_TOKEN=8469229183:AAEVIV5e7rjDXKNgFTX0dnCW6JWB88X4p2I
- TELEGRAM_WEBHOOK_TOKEN=webhook_secure_token_12345
- TEST_ENV_LOAD=caos_cargado
# LibreTranslate
- LIBRETRANSLATE_URL=http://10.10.4.17:5000
# N8N Integration
- N8N_URL=https://n8n-dragon.ddns.net
- N8N_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4MWY4YjU3YS0wMTg2LTQ1NTctOWZlMC1jYWUxNjZlYzZlMTkiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzU1OTMwODM5fQ.2tLbddyhMTKplp9n-qVNiAgQCUj2YEvVASwLnNjgCt0
# Configuración para migración a n8n
- APP_BASE_URL=https://reod-dragon.ddns.net
- INTERNAL_API_KEY="b5dda33b8eb062e06e100c98a8947c0248b6e38973dfd689e81f725af238d23c"
- N8N_PROCESS_QUEUE_WEBHOOK_URL="https://n8n-dragon.ddns.net/webhook/telegram-unified"
- N8N_IA_WEBHOOK_URL="https://n8n-dragon.ddns.net/webhook/ia"
- N8N_IA_WEBHOOK_URL_DISCORD="https://n8n-dragon.ddns.net/webhook/42e803ae-8aee-4b1c-858a-6c6d3fbb6230"
restart: unless-stopped
user: "0:0"
command: ["/entrypoint.sh"]
volumes:
# Volúmenes opcionales para persistencia (puedes quitarlos si no los necesitas)
- bot-logs:/var/www/html/bot/logs
- bot-galeria:/var/www/html/bot/galeria
volumes:
bot-logs:
bot-galeria:

50
docker/zimaos.env.template Executable file
View File

@@ -0,0 +1,50 @@
# ============================================
# Plantilla de variables de entorno para Docker
# Copiar este archivo a .env y configurar
# ============================================
# Configuración de la aplicación
APP_ENV=production
APP_ENVIRONMENT=reod
APP_DEBUG=false
USE_LOCALHOST=false
TZ=America/Mexico_City
APP_URL=https://reod-dragon.ddns.net
# Configuración de la base de datos
DB_HOST=10.10.4.17
DB_PORT=3390
DB_NAME=bot
DB_USER=nickpons666
DB_PASS=MiPo6425@@
DB_DIALECT=mysql
# Configuración de JWT
JWT_SECRET=19c5020fa8207d2c3b9e82f430784667e001f1eb733848922f7bcb9be98f93c2
JWT_ALGORITHM=HS256
JWT_EXPIRATION=3600
# Configuración de Discord
DISCORD_GUILD_ID=1338327171013541999
DISCORD_CLIENT_ID=1385790344594985061
DISCORD_CLIENT_SECRET=hK9SNiYdenHQVxakt8Mx3RoMkZ5oOJvk
DISCORD_BOT_TOKEN=MTM4NTc5MDM0NDU5NDk4NTA2MQ.GvobiS.TRQM9dX7vDjmuGVa3Ckp6YRtGEWxdW0gBDbvCI
# Configuración de Telegram
TELEGRAM_BOT_TOKEN=8469229183:AAEVIV5e7rjDXKNgFTX0dnCW6JWB88X4p2I
TELEGRAM_WEBHOOK_TOKEN=webhook_secure_token_12345
TEST_ENV_LOAD=caos_cargado
# LibreTranslate
LIBRETRANSLATE_URL=http://10.10.4.17:5000
# N8N Integration
N8N_URL=https://n8n-dragon.ddns.net
N8N_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4MWY4YjU3YS0wMTg2LTQ1NTctOWZlMC1jYWUxNjZlYzZlMTkiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzU1OTMwODM5fQ.2tLbddyhMTKplp9n-qVNiAgQCUj2YEvVASwLnNjgCt0
# Configuración para migración a n8n
APP_BASE_URL=https://reod-dragon.ddns.net
INTERNAL_API_KEY=b5dda33b8eb062e06e100c98a8947c0248b6e38973dfd689e81f725af238d23c
N8N_PROCESS_QUEUE_WEBHOOK_URL=https://n8n-dragon.ddns.net/webhook/telegram-unified
N8N_IA_WEBHOOK_URL=https://n8n-dragon.ddns.net/webhook/ia
N8N_IA_WEBHOOK_URL_DISCORD=https://n8n-dragon.ddns.net/webhook/42e803ae-8aee-4b1c-858a-6c6d3fbb6230

93
downgrade_docker.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
# ==============================================================================
# Script para hacer Downgrade de Docker en sistemas basados en Debian/Ubuntu (v2)
# ==============================================================================
#
# ADVERTENCIA: Este script desinstalará la versión actual de Docker
# y eliminará los datos de Docker existentes (imágenes, contenedores, volúmenes).
# Se recomienda hacer una copia de seguridad si tienes datos importantes.
#
# Uso:
# 1. Guarda este script como 'downgrade_docker.sh'.
# 2. Dale permisos de ejecución: chmod +x downgrade_docker.sh
# 3. Ejecútalo con sudo: sudo ./downgrade_docker.sh
#
# ==============================================================================
set -e # Termina el script si algún comando falla
# --- 1. Definir la versión de Docker a instalar ---
# Cambia esta variable si deseas una versión diferente.
# Busca versiones disponibles con: apt-cache madison docker-ce
# El formato debe ser como "5:27.1.1-1~ubuntu.22.04~jammy"
# Dejaremos esto en blanco por ahora para que puedas elegir de la lista.
TARGET_DOCKER_VERSION=""
echo "--- Iniciando el proceso de downgrade de Docker (v2) ---"
# --- 2. Desbloquear (unhold) paquetes de Docker ---
echo "Paso 1: Desbloqueando paquetes de Docker existentes..."
sudo apt-mark unhold docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || echo "No se encontraron paquetes de Docker bloqueados, continuando."
# --- 3. Detener los servicios de Docker ---
echo "Paso 2: Deteniendo los servicios de Docker..."
sudo systemctl stop docker.service
sudo systemctl stop docker.socket
sudo systemctl stop containerd
# --- 4. Desinstalar la versión actual de Docker ---
echo "Paso 3: Desinstalando los paquetes de Docker actuales..."
# Se agrega --allow-change-held-packages para asegurar la desinstalación
sudo apt-get remove --allow-change-held-packages -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras
sudo apt-get purge -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras
sudo apt-get autoremove -y --purge
# --- 5. Limpiar directorios residuales (¡CUIDADO!) ---
echo "Paso 4: Limpiando directorios de Docker (esto eliminará imágenes y contenedores)..."
# Comentado por seguridad. Descomenta si realmente quieres una limpieza completa.
# sudo rm -rf /var/lib/docker
# sudo rm -rf /var/lib/containerd
# --- 6. Asegurar que el repositorio de Docker esté configurado ---
echo "Paso 5: Configurando el repositorio de Docker..."
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Añadir el repositorio a las fuentes de Apt
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
# --- 7. Listar las versiones disponibles y solicitar al usuario que elija ---
echo "Paso 6: Versiones de Docker disponibles en el repositorio:"
apt-cache madison docker-ce | awk '{print $3}'
echo -e "\nPor favor, copia y pega la versión exacta que deseas instalar de la lista anterior (ej: 5:27.1.1-1~ubuntu.22.04~jammy):"
read -r TARGET_DOCKER_VERSION
if [ -z "$TARGET_DOCKER_VERSION" ]; then
echo "No se ha seleccionado ninguna versión. Abortando."
exit 1
fi
# --- 8. Instalar la versión de Docker seleccionada ---
echo "Paso 7: Instalando la versión $TARGET_DOCKER_VERSION..."
sudo apt-get install -y docker-ce=$TARGET_DOCKER_VERSION docker-ce-cli=$TARGET_DOCKER_VERSION containerd.io docker-buildx-plugin docker-compose-plugin
# --- 9. Verificar la instalación ---
echo "Paso 8: Verificando la versión de Docker instalada..."
docker --version
# --- 10. Bloquear la versión de Docker (opcional pero recomendado) ---
echo "Paso 9: Bloqueando la versión de Docker para evitar actualizaciones automáticas."
sudo apt-mark hold docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
echo -e "\n--- Proceso de downgrade de Docker completado. ---"
echo "Recuerda reiniciar los servicios que dependen de Docker, como CasaOS o los contenedores específicos."
echo "Puedes reiniciar el servicio de Docker con: sudo systemctl restart docker"

222
edit_message.php Executable file
View File

@@ -0,0 +1,222 @@
<?php
require_once __DIR__ . '/includes/session_check.php';
require_once __DIR__ . '/includes/db.php';
if (!isset($_GET['schedule_id'])) {
header('Location: scheduled_messages.php');
exit();
}
$scheduleId = $_GET['schedule_id'];
// Fetch the message details
$stmt = $pdo->prepare(
"SELECT s.*, m.content, dr.type as recipient_type
FROM schedules s
JOIN messages m ON s.message_id = m.id
JOIN discord_recipients dr ON s.recipient_id = dr.id
WHERE s.id = ?"
);
$stmt->execute([$scheduleId]);
$message = $stmt->fetch();
if (!$message) {
header('Location: scheduled_messages.php?error=not_found');
exit();
}
// Fetch all recipients for the dropdowns
$channels = [];
$users = [];
try {
$recipientStmt = $pdo->query("SELECT id, name, type FROM discord_recipients ORDER BY name ASC");
while ($row = $recipientStmt->fetch()) {
if ($row['type'] === 'channel') {
$channels[] = $row;
} else {
$users[] = $row;
}
}
} catch (PDOException $e) {
die("Error: No se pudieron cargar los destinatarios.");
}
require_once __DIR__ . '/templates/header.php';
?>
<!-- Summernote CSS -->
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs5.min.css" rel="stylesheet">
<div class="container-fluid">
<h1 class="mt-4">Editar Notificación</h1>
<form action="includes/message_handler_edit.php" method="POST" id="editMessageForm">
<input type="hidden" name="schedule_id" value="<?php echo $message['id']; ?>">
<input type="hidden" name="message_id" value="<?php echo $message['message_id']; ?>">
<!-- Message Content -->
<div class="mb-3">
<label for="messageContent" class="form-label">Contenido del Mensaje</label>
<textarea id="messageContent" name="messageContent" class="form-control"><?php echo htmlspecialchars($message['content']); ?></textarea>
</div>
<!-- Recipient Selection -->
<div class="mb-3">
<label class="form-label">Tipo de Destinatario</label>
<div>
<input type="radio" id="recipientTypeChannel" name="recipientType" value="channel" <?php echo ($message['recipient_type'] === 'channel') ? 'checked' : ''; ?>>
<label for="recipientTypeChannel">Canal</label>
<input type="radio" id="recipientTypeUser" name="recipientType" value="user" class="ms-3" <?php echo ($message['recipient_type'] === 'user') ? 'checked' : ''; ?>>
<label for="recipientTypeUser">Usuario</label>
</div>
</div>
<div class="mb-3 <?php echo ($message['recipient_type'] === 'user') ? 'd-none' : ''; ?>" id="channelSelectWrapper">
<label for="channelId" class="form-label">Seleccionar Canal</label>
<select id="channelId" name="recipientId_channel" class="form-select">
<?php foreach ($channels as $channel): ?>
<option value="<?php echo $channel['id']; ?>" <?php echo ($message['recipient_id'] == $channel['id']) ? 'selected' : ''; ?>><?php echo htmlspecialchars($channel['name']); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3 <?php echo ($message['recipient_type'] === 'channel') ? 'd-none' : ''; ?>" id="userSelectWrapper">
<label for="userId" class="form-label">Seleccionar Usuario</label>
<select id="userId" name="recipientId_user" class="form-select">
<?php foreach ($users as $user): ?>
<option value="<?php echo $user['id']; ?>" <?php echo ($message['recipient_id'] == $user['id']) ? 'selected' : ''; ?>><?php echo htmlspecialchars($user['name']); ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- Scheduling Options -->
<fieldset class="mb-3">
<legend class="h6">Opciones de Envío</legend>
<!-- Current implementation only allows re-scheduling. 'Send Now' could be added. -->
<div>
<input type="radio" id="scheduleLater" name="scheduleType" value="later" checked>
<label for="scheduleLater">Programar para más tarde</label>
</div>
</fieldset>
<div class="mb-3" id="scheduleDateTimeWrapper">
<label for="scheduleDateTime" class="form-label">Fecha y Hora de Envío</label>
<input type="datetime-local" id="scheduleDateTime" name="scheduleDateTime" class="form-control" value="<?php echo date('Y-m-d\TH:i', strtotime($message['send_time'])); ?>">
</div>
<!-- Submit Button -->
<button type="submit" class="btn btn-primary">Actualizar Mensaje</button>
</form>
</div>
<?php require_once __DIR__ . '/templates/footer.php'; ?>
<!-- Gallery Modal -->
<div class="modal fade" id="galleryModal" tabindex="-1" aria-labelledby="galleryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="galleryModalLabel">Galería de Imágenes</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<?php
$gallery_path = __DIR__ . '/galeria';
$files = array_diff(scandir($gallery_path), array('.', '..'));
if (empty($files)) {
echo '<p class="text-center text-muted">No hay imágenes en la galería.</p>';
} else {
foreach ($files as $file) {
if (is_file($gallery_path . '/' . $file)) {
echo '<div class="col-lg-3 col-md-4 col-sm-6 mb-4 text-center"><img src="galeria/' . htmlspecialchars($file) . '" class="img-fluid img-thumbnail gallery-item" style="cursor:pointer;" alt="' . htmlspecialchars($file) . '"><p class="small text-muted mt-1">' . htmlspecialchars($file) . '</p></div>';
}
}
}
?>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
<button type="button" class="btn btn-primary" id="insertImageFromGallery">Insertar Imagen</button>
</div>
</div>
</div>
</div>
<!-- Summernote JS -->
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs5.min.js"></script>
<script>
$(document).ready(function() {
// Custom Gallery Button
var GalleryButton = function (context) {
var ui = $.summernote.ui;
var button = ui.button({
contents: '<i class="bi bi-images"></i> Galería',
tooltip: 'Insertar imagen desde la galería',
click: function () {
$('#galleryModal').modal('show');
}
});
return button.render();
}
// Initialize Summernote
$('#messageContent').summernote({
placeholder: 'Escribe tu mensaje aquí...',
tabsize: 2,
height: 300,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'picture', 'video']],
['view', ['fullscreen', 'codeview', 'help']],
['mybutton', ['gallery']]
],
buttons: {
gallery: GalleryButton
}
});
// Handle image selection in modal
$(document).on('click', '.gallery-item', function() {
$(this).toggleClass('border-primary');
});
// Handle image insertion
$('#insertImageFromGallery').click(function() {
$('.gallery-item.border-primary').each(function(){
var imageUrl = $(this).attr('src');
$('#messageContent').summernote('insertImage', imageUrl);
});
$('#galleryModal').modal('hide');
$('.gallery-item').removeClass('border-primary');
});
// Function to toggle recipient selects
function toggleRecipientFields() {
if ($('input[name="recipientType"]:checked').val() === 'channel') {
$('#channelSelectWrapper').removeClass('d-none');
$('#userSelectWrapper').addClass('d-none');
$('#channelId').prop('name', 'recipientId');
$('#userId').prop('name', 'recipientId_user'); // Unset name to avoid submission
} else {
$('#channelSelectWrapper').addClass('d-none');
$('#userSelectWrapper').removeClass('d-none');
$('#channelId').prop('name', 'recipientId_channel'); // Unset name
$('#userId').prop('name', 'recipientId');
}
}
// Initial toggle on page load
toggleRecipientFields();
// Add change event listener
$('input[name="recipientType"]').change(toggleRecipientFields);
});
</script>

203
edit_recurrent_message.php Executable file
View File

@@ -0,0 +1,203 @@
<?php
require_once __DIR__ . '/includes/session_check.php';
require_once __DIR__ . '/includes/db.php';
$base_url = rtrim(BOT_BASE_URL, '/');
$message_id = $_GET['id'] ?? 0;
if (!$message_id) {
header('Location: recurrentes.php');
exit;
}
// Fetch the recurrent message data
try {
$stmt = $pdo->prepare("SELECT * FROM recurrent_messages WHERE id = ?");
$stmt->execute([$message_id]);
$message = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$message) {
die("Mensaje recurrente no encontrado.");
}
} catch (PDOException $e) {
die("Error al cargar el mensaje: " . $e->getMessage());
}
// Recipient selection is handled on the 'send from template' page, not here.
$current_days = explode(',', $message['recurring_days']);
require_once __DIR__ . '/templates/header.php';
?>
<!-- Summernote CSS -->
<link href="<?php echo asset('css/summernote-bs5.min.css'); ?>" rel="stylesheet">
<div class="container-fluid">
<h1 class="mt-4">Editar Mensaje Recurrente</h1>
<div class="card card-body p-4">
<form action="includes/recurrent_message_handler.php" method="POST" id="editRecurrentMessageForm">
<input type="hidden" name="action" value="update">
<input type="hidden" name="id" value="<?php echo $message['id']; ?>">
<!-- Message Name -->
<div class="mb-3">
<label for="messageName" class="form-label">Nombre del Mensaje</label>
<input type="text" class="form-control" id="messageName" name="messageName" value="<?php echo htmlspecialchars($message['name']); ?>" required>
</div>
<!-- Telegram Command -->
<div class="mb-3">
<label for="telegramCommand" class="form-label">Comando de Telegram</label>
<input type="text" class="form-control" id="telegramCommand" name="telegram_command" value="<?php echo htmlspecialchars($message['telegram_command'] ?? ''); ?>" placeholder="Ej: Dia6 (sin #)">
<small class="form-text text-muted">Opcional. El comando que los usuarios usarán en Telegram (sin el #).</small>
</div>
<!-- Message Content -->
<div class="mb-3">
<label for="messageContent" class="form-label">Contenido del Mensaje</label>
<textarea id="messageContent" name="messageContent" class="form-control"><?php echo htmlspecialchars($message['message_content']); ?></textarea>
</div>
<!-- Submit Button -->
<button type="submit" class="btn btn-primary">
<i class="bi bi-save-fill me-1"></i> Actualizar Mensaje
</button>
<a href="recurrentes.php" class="btn btn-secondary">Cancelar</a>
</form>
</div>
</div>
<!-- Gallery Modal (same as create_message.php) -->
<div class="modal fade" id="galleryModal" tabindex="-1" aria-labelledby="galleryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="galleryModalLabel">Galería de Imágenes</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<?php
$gallery_path = __DIR__ . '/galeria';
$files = array_diff(scandir($gallery_path), array('.', '..'));
if (empty($files)) {
echo '<p class="text-center text-muted">No hay imágenes en la galería.</p>';
} else {
foreach ($files as $file) {
if (is_file($gallery_path . '/' . $file)) {
echo '<div class="col-lg-3 col-md-4 col-sm-6 mb-4 text-center"><img src="galeria/' . htmlspecialchars($file) . '" class="img-fluid img-thumbnail gallery-item" style="cursor:pointer;" alt="' . htmlspecialchars($file) . '"><p class="small text-muted mt-1">' . htmlspecialchars($file) . '</p></div>';
}
}
}
?>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
<button type="button" class="btn btn-primary" id="insertImageFromGallery">Insertar Imagen</button>
</div>
</div>
</div>
</div>
<!-- Preview Modal -->
<div class="modal fade" id="previewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="previewModalLabel">Vista Previa del Mensaje</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="previewModalBody">
<!-- Content will be injected here -->
</div>
</div>
</div>
</div>
<?php require_once __DIR__ . '/templates/footer.php'; ?>
<script>
$(document).ready(function() {
// Custom Gallery Button
var GalleryButton = function (context) {
var ui = $.summernote.ui;
var button = ui.button({
contents: '<i class="bi bi-images"></i> Galería',
tooltip: 'Insertar imagen desde la galería',
click: function () {
$('#galleryModal').modal('show');
}
});
return button.render();
}
// Initialize Summernote
$('#messageContent').summernote({
placeholder: 'Escribe tu mensaje aquí...',
tabsize: 2,
height: 300,
toolbar: [
['style', ['style']], ['font', ['bold', 'underline', 'clear']], ['color', ['color']],
['para', ['ul', 'ol', 'paragraph']], ['table', ['table']],
['insert', ['picture']], ['view', ['codeview']], ['mybutton', ['gallery']]
],
buttons: { gallery: GalleryButton },
callbacks: {
onImageUpload: function(files) {
var editor = $(this);
var data = new FormData();
data.append("file", files[0]);
$.ajax({
url: 'upload_editor_image.php', method: 'POST', data: data,
processData: false, contentType: false,
success: function(response) {
if (response.url) {
editor.summernote('insertImage', response.url);
} else {
alert(response.error || 'Error al subir la imagen.');
}
},
error: function() {
alert('Error: No se pudo comunicar con el servidor para subir la imagen.');
}
});
}
}
});
// Handle image selection and insertion from gallery
$(document).on('click', '.gallery-item', function() { $(this).toggleClass('border-primary'); });
$('#insertImageFromGallery').click(function() {
$('.gallery-item.border-primary').each(function(){
$('#messageContent').summernote('insertImage', $(this).attr('src'));
});
$('#galleryModal').modal('hide');
$('.gallery-item').removeClass('border-primary');
});
// No recipient logic is needed on this page.
// Form validation
$('#editRecurrentMessageForm').submit(function(e) {
if ($('#messageContent').summernote('isEmpty')) {
e.preventDefault();
alert('El contenido del mensaje no puede estar vacío.');
return false;
}
return true;
});
});
// Preview message function
function previewMessage(content) {
const previewModalBody = document.getElementById('previewModalBody');
// Replace image URLs with absolute paths for preview
const baseUrl = "<?php echo $base_url; ?>";
let processedContent = content.replace(/src="galeria\//g, 'src="' + baseUrl + 'galeria/');
previewModalBody.innerHTML = processedContent;
var myModal = new bootstrap.Modal(document.getElementById('previewModal'));
myModal.show();
}
</script>

168
enviar_plantilla.php Executable file
View File

@@ -0,0 +1,168 @@
<?php
require_once __DIR__ . '/includes/session_check.php';
require_once __DIR__ . '/includes/db.php';
// Fetch recipients
$discord_channels = [];
$discord_users = [];
$telegram_chats = [];
try {
$stmt = $pdo->query("SELECT id, name, type, platform FROM recipients ORDER BY platform, type, name ASC");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if ($row['platform'] === 'discord') {
if ($row['type'] === 'channel') $discord_channels[] = $row;
else $discord_users[] = $row;
} elseif ($row['platform'] === 'telegram') {
$telegram_chats[] = $row;
}
}
} catch (PDOException $e) {
die("Error: No se pudieron cargar los destinatarios.");
}
// Fetch templates
$templates = [];
try {
$stmt = $pdo->query("SELECT id, name FROM recurrent_messages ORDER BY name ASC");
$templates = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die("Error: No se pudieron cargar las plantillas.");
}
require_once __DIR__ . '/templates/header.php';
?>
<div class="container-fluid">
<h1 class="mt-4">Enviar Mensaje desde Plantilla</h1>
<form action="includes/message_handler.php" method="POST" id="sendMessageForm">
<input type="hidden" name="submit" value="send_from_template">
<div class="row">
<div class="col-md-6">
<!-- Template Selection -->
<div class="mb-3">
<label for="recurrent_message_id" class="form-label">1. Seleccionar Plantilla</label>
<select class="form-select" id="recurrent_message_id" name="recurrent_message_id" required>
<option value="" selected disabled>Elige una plantilla...</option>
<?php foreach ($templates as $template): ?>
<option value="<?php echo $template['id']; ?>"><?php echo htmlspecialchars($template['name']); ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- Platform Selection -->
<div class="mb-3">
<label class="form-label">2. Seleccionar Plataforma</label>
<div>
<input type="radio" class="btn-check" name="platform" id="platform_discord" value="discord" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="platform_discord">Discord</label>
<input type="radio" class="btn-check" name="platform" id="platform_telegram" value="telegram" autocomplete="off">
<label class="btn btn-outline-primary" for="platform_telegram">Telegram</label>
</div>
</div>
<!-- Recipient Selection -->
<div class="mb-3">
<label for="recipient_id" class="form-label">3. Seleccionar Destinatarios</label>
<!-- Discord Channels -->
<select class="form-select recipient-select" id="recipient_id_discord_channel" name="recipient_id[]" multiple>
<optgroup label="Canales de Discord">
<?php foreach ($discord_channels as $c): ?><option value="<?php echo $c['id']; ?>"><?php echo htmlspecialchars($c['name']); ?></option><?php endforeach; ?>
</optgroup>
</select>
<!-- Discord Users -->
<select class="form-select recipient-select mt-2" id="recipient_id_discord_user" name="recipient_id[]" multiple>
<optgroup label="Usuarios de Discord">
<?php foreach ($discord_users as $u): ?><option value="<?php echo $u['id']; ?>"><?php echo htmlspecialchars($u['name']); ?></option><?php endforeach; ?>
</optgroup>
</select>
<!-- Telegram Chats -->
<select class="form-select recipient-select d-none mt-2" id="recipient_id_telegram" name="recipient_id[]" multiple>
<optgroup label="Chats de Telegram">
<?php foreach ($telegram_chats as $t): ?><option value="<?php echo $t['id']; ?>"><?php echo htmlspecialchars($t['name']); ?></option><?php endforeach; ?>
</optgroup>
</select>
<small class="form-text text-muted">Mantén Ctrl (o Cmd) para seleccionar varios. Solo se enviará a los de la plataforma elegida.</small>
</div>
</div>
<div class="col-md-6">
<!-- Schedule Options -->
<div class="mb-3">
<label class="form-label">4. Programar Envío</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="scheduleType" id="scheduleNow" value="now" checked>
<label class="form-check-label" for="scheduleNow">Enviar ahora</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="scheduleType" id="scheduleLater" value="later">
<label class="form-check-label" for="scheduleLater">Programar para más tarde</label>
</div>
</div>
<div class="mb-3" id="scheduleLaterOptions" style="display: none;">
<label for="scheduleDateTime" class="form-label">Fecha y Hora</label>
<input type="datetime-local" class="form-control" id="scheduleDateTime" name="scheduleDateTime" min="<?php echo date('Y-m-d\TH:i'); ?>">
</div>
</div>
</div>
<button type="submit" class="btn btn-success"><i class="bi bi-send-fill me-1"></i> Enviar Mensaje</button>
</form>
</div>
<?php require_once __DIR__ . '/templates/footer.php'; ?>
<script>
$(document).ready(function() {
const platformDiscord = $('#platform_discord');
const platformTelegram = $('#platform_telegram');
const discordChannelSelect = $('#recipient_id_discord_channel');
const discordUserSelect = $('#recipient_id_discord_user');
const telegramSelect = $('#recipient_id_telegram');
function togglePlatformView() {
if (platformDiscord.is(':checked')) {
discordChannelSelect.removeClass('d-none');
discordUserSelect.removeClass('d-none');
telegramSelect.addClass('d-none');
} else {
discordChannelSelect.addClass('d-none');
discordUserSelect.addClass('d-none');
telegramSelect.removeClass('d-none');
}
}
platformDiscord.on('change', togglePlatformView);
platformTelegram.on('change', togglePlatformView);
togglePlatformView(); // Initial call
// Toggle schedule options
$('input[name="scheduleType"]').on('change', function() {
$('#scheduleLaterOptions').toggle(this.value === 'later');
});
// Form validation
$('#sendMessageForm').submit(function(e) {
let selectedRecipients = 0;
if (platformDiscord.is(':checked')) {
selectedRecipients += discordChannelSelect.val().length;
selectedRecipients += discordUserSelect.val().length;
} else {
selectedRecipients += telegramSelect.val().length;
}
if (selectedRecipients === 0) {
e.preventDefault();
alert('Debes seleccionar al menos un destinatario para la plataforma elegida.');
return false;
}
return true;
});
});
</script>

BIN
galeria/Asedio.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
galeria/CAOS.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
galeria/Carbon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
galeria/Dia1.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
galeria/Dia2.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
galeria/Dia3.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
galeria/Dia4.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
galeria/Dia5.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
galeria/Dia6.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

BIN
galeria/Escudo.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
galeria/Escudos.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
galeria/Radar.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
galeria/S1-Skill-Points.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

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