Initial commit - Last War messaging system

This commit is contained in:
2026-02-19 01:33:28 -06:00
commit 38a8447a64
2162 changed files with 216183 additions and 0 deletions

414
discord/DiscordSender.php Executable file
View File

@@ -0,0 +1,414 @@
<?php
namespace Discord;
class DiscordSender
{
private string $token;
private string $guildId;
private string $baseUrl = 'https://discord.com/api/v10';
public function __construct()
{
$this->token = $_ENV['DISCORD_BOT_TOKEN'] ?? getenv('DISCORD_BOT_TOKEN');
$this->guildId = $_ENV['DISCORD_GUILD_ID'] ?? getenv('DISCORD_GUILD_ID');
}
public function sendMessage(string $channelId, string $content, ?array $embed = null, ?array $buttons = null): array
{
$channelId = $this->resolveUserToDmChannel($channelId);
$data = ['content' => $content];
if ($embed) {
$data['embeds'] = [$embed];
}
if ($buttons) {
// Construir componentes correctamente
$components = [];
foreach ($buttons as $row) {
if (isset($row['components']) && is_array($row['components'])) {
$componentRow = [
'type' => 1,
'components' => []
];
foreach ($row['components'] as $btn) {
$componentRow['components'][] = [
'type' => 2,
'style' => isset($btn['style']) ? intval($btn['style']) : 1,
'label' => $btn['label'],
'custom_id' => $btn['custom_id']
];
}
$components[] = $componentRow;
}
}
if (!empty($components)) {
$data['components'] = $components;
}
}
return $this->request("POST", "/channels/{$channelId}/messages", $data);
}
private function cleanComponents(array $components): array
{
$clean = [];
foreach ($components as $row) {
$cleanRow = [];
if (isset($row['components'])) {
$cleanComponents = [];
foreach ($row['components'] as $btn) {
$cleanBtn = [
'type' => 2,
'style' => $btn['style'] ?? 1,
'label' => $btn['label'],
'custom_id' => $btn['custom_id']
];
$cleanComponents[] = $cleanBtn;
}
$cleanRow = [
'type' => 1,
'components' => $cleanComponents
];
} else {
$cleanRow = $row;
}
$clean[] = $cleanRow;
}
return $clean;
}
public function sendMessageWithImages(string $channelId, string $content, array $images, ?array $buttons = null): array
{
$channelId = $this->resolveUserToDmChannel($channelId);
$result = null;
if (!empty($images)) {
// Verificar si las imágenes son locales o URLs
$localImages = [];
$remoteImages = [];
foreach ($images as $imageUrl) {
if (strpos($imageUrl, 'http') === 0) {
// Es una URL remota
$remoteImages[] = $imageUrl;
} elseif (file_exists($imageUrl)) {
// Es un archivo local
$localImages[] = $imageUrl;
}
}
// Enviar imágenes locales como adjuntos
if (!empty($localImages)) {
$result = $this->sendMessageWithAttachments($channelId, $content, $localImages);
} else {
$result = $this->sendMessage($channelId, $content, null, $buttons);
}
// Enviar imágenes remotas como embeds
foreach ($remoteImages as $imageUrl) {
$embed = [
'image' => ['url' => $imageUrl]
];
$result = $this->sendMessage($channelId, '', $embed, $buttons);
$buttons = null; // Solo enviar botones en el primer mensaje
}
} else {
$result = $this->sendMessage($channelId, $content, null, $buttons);
}
return $result;
}
/**
* Enviar contenido con texto e imágenes en el orden correcto
* Divide el contenido en segmentos y los envía manteniendo el orden
*/
public function sendContentWithOrderedImages(string $channelId, array $segments): void
{
$channelId = $this->resolveUserToDmChannel($channelId);
foreach ($segments as $segment) {
if ($segment['type'] === 'text') {
// Enviar texto
if (!empty(trim($segment['content']))) {
$this->sendMessage($channelId, $segment['content']);
}
} elseif ($segment['type'] === 'image') {
// Enviar imagen
$imagePath = $segment['src'];
if (strpos($imagePath, 'http') === 0) {
// URL remota - enviar como embed
$embed = ['image' => ['url' => $imagePath]];
$this->sendMessage($channelId, '', $embed);
} elseif (file_exists($imagePath)) {
// Archivo local - enviar como adjunto
$this->sendMessageWithAttachments($channelId, '', [$imagePath]);
}
}
}
}
public function sendMessageWithAttachments(string $channelId, string $content, array $files): array
{
$channelId = $this->resolveUserToDmChannel($channelId);
$url = $this->baseUrl . "/channels/{$channelId}/messages";
// Preparar los datos multipart
$postData = [
'content' => $content,
'payload_json' => json_encode(['content' => $content])
];
// Agregar archivos
$fileIndex = 0;
foreach ($files as $filePath) {
if (file_exists($filePath)) {
$postData["file{$fileIndex}"] = new \CURLFile($filePath, mime_content_type($filePath), basename($filePath));
$fileIndex++;
}
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bot ' . $this->token,
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result = json_decode($response, true);
if ($httpCode >= 400) {
throw new \Exception("Discord API Error: " . ($result['message'] ?? 'Unknown error'));
}
return $result;
}
public function editMessage(string $channelId, string $messageId, string $content, ?array $embed = null): array
{
$data = ['content' => $content];
if ($embed) {
$data['embeds'] = [$embed];
}
return $this->request("PATCH", "/channels/{$channelId}/messages/{$messageId}", $data);
}
public function deleteMessage(string $channelId, string $messageId): bool
{
$this->request("DELETE", "/channels/{$channelId}/messages/{$messageId}");
return true;
}
public function getChannel(string $channelId): array
{
return $this->request("GET", "/channels/{$channelId}");
}
public function getGuildChannels(): array
{
return $this->request("GET", "/guilds/{$this->guildId}/channels");
}
private function buildActionRow(array $buttons): array
{
$components = [];
foreach ($buttons as $button) {
$component = [
'type' => 2,
'style' => $button['style'] ?? 1,
'label' => $button['label'],
'custom_id' => $button['custom_id']
];
if (isset($button['url'])) {
$component['url'] = $button['url'];
}
$components[] = $component;
}
return [
[
'type' => 1,
'components' => $components
]
];
}
private function request(string $method, string $endpoint, ?array $data = null): array
{
$url = $this->baseUrl . $endpoint;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bot ' . $this->token,
'Content-Type: application/json'
]);
if ($data) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result = json_decode($response, true);
if ($httpCode >= 400) {
throw new \Exception("Discord API Error: " . ($result['message'] ?? 'Unknown error'));
}
return $result;
}
private function resolveUserToDmChannel(string $userId): string
{
try {
$response = $this->request("POST", "/users/{$userId}/channels", [
'recipient_id' => $userId
]);
if (isset($response['id'])) {
return $response['id'];
}
} catch (\Exception $e) {
error_log("Error creating DM channel: " . $e->getMessage());
}
return $userId;
}
public function splitMessage(string $content, int $maxLength = 2000): array
{
if (strlen($content) <= $maxLength) {
return [$content];
}
$parts = [];
$lines = explode("\n", $content);
$currentPart = '';
foreach ($lines as $line) {
if (strlen($currentPart . "\n" . $line) > $maxLength) {
if (!empty($currentPart)) {
$parts[] = $currentPart;
$currentPart = '';
}
if (strlen($line) > $maxLength) {
$chunks = str_split($line, $maxLength);
$parts = array_merge($parts, array_slice($chunks, 0, -1));
$currentPart = end($chunks);
} else {
$currentPart = $line;
}
} else {
$currentPart .= (empty($currentPart) ? '' : "\n") . $line;
}
}
if (!empty($currentPart)) {
$parts[] = $currentPart;
}
return $parts;
}
/**
* Parsear HTML y dividirlo en segmentos manteniendo el orden
* Retorna array de ['type' => 'text|image', 'content' => '...', 'src' => '...']
*/
public function parseContent(string $html): array
{
$segments = [];
$currentText = '';
// Usar regex para encontrar todas las etiquetas <img>
$pattern = '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i';
$parts = preg_split($pattern, $html, -1, PREG_SPLIT_DELIM_CAPTURE);
// El array parts alterna entre: [texto, src_imagen, texto, src_imagen, texto...]
for ($i = 0; $i < count($parts); $i++) {
if ($i % 2 === 0) {
// Es texto
$text = $this->htmlToPlainText($parts[$i]);
if (!empty(trim($text))) {
$segments[] = [
'type' => 'text',
'content' => $text
];
}
} else {
// Es una imagen (el src capturado)
$segments[] = [
'type' => 'image',
'src' => $parts[$i],
'content' => ''
];
}
}
return $segments;
}
/**
* Convertir HTML a texto plano manteniendo saltos de línea
*/
private function htmlToPlainText(string $html): string
{
// Reemplazar <br>, <p>, etc. con saltos de línea
$text = preg_replace('/<br\s*\/?>/i', "\n", $html);
$text = preg_replace('/<\/p>/i', "\n", $text);
$text = preg_replace('/<p[^>]*>/i', '', $text);
$text = preg_replace('/<div[^>]*>/i', '', $text);
$text = preg_replace('/<\/div>/i', "\n", $text);
// Eliminar otras etiquetas HTML
$text = strip_tags($text);
// Decodificar entidades HTML
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Limpiar espacios múltiples y saltos de línea
$text = preg_replace('/\n{3,}/', "\n\n", $text);
$text = preg_replace('/[ \t]+/', ' ', $text);
return trim($text);
}
public function extractImages(string $html): array
{
preg_match_all('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $html, $matches);
return $matches[1] ?? [];
}
public function removeImages(string $html): string
{
return preg_replace('/<img[^>]+>/i', '', $html);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Discord\Actions;
use Discord\DiscordSender;
use Discord\Converters\HtmlToDiscordMarkdownConverter;
class DiscordActions
{
private DiscordSender $sender;
private HtmlToDiscordMarkdownConverter $converter;
public function __construct()
{
$this->sender = new DiscordSender();
$this->converter = new HtmlToDiscordMarkdownConverter();
}
public function sendTemplate(string $channelId, string $htmlContent, ?string $command = null): array
{
$content = $this->converter->convert($htmlContent);
$images = $this->converter->extractImages($htmlContent);
$contentWithoutImages = $this->converter->removeImages($htmlContent);
$content = $this->converter->convert($contentWithoutImages);
if (!empty($images)) {
return $this->sender->sendMessageWithImages($channelId, $content, $images);
}
return $this->sender->sendMessage($channelId, $content);
}
public function sendScheduledMessage(string $channelId, string $htmlContent, ?array $buttons = null): array
{
$content = $this->converter->convert($htmlContent);
$images = $this->converter->extractImages($htmlContent);
$contentWithoutImages = $this->converter->removeImages($htmlContent);
$content = $this->converter->convert($contentWithoutImages);
if (!empty($images)) {
return $this->sender->sendMessageWithImages($channelId, $content, $images);
}
return $this->sender->sendMessage($channelId, $content, null, $buttons);
}
public function sendWithTranslation(string $channelId, string $htmlContent, array $translations): array
{
$content = $this->converter->convert($htmlContent);
$embed = [
'title' => '📝 Mensaje Original',
'description' => $content,
'color' => 3447003,
'footer' => [
'text' => 'Traducciones disponibles en los botones'
]
];
$buttons = [];
foreach ($translations as $lang => $translatedText) {
$buttons[] = [
'label' => strtoupper($lang),
'custom_id' => "translate_{$lang}",
'style' => 1
];
}
return $this->sender->sendMessage($channelId, '', $embed, $buttons);
}
public function translateMessage(string $channelId, string $messageId, string $originalText, string $targetLang, string $translatedText): array
{
$embed = [
'title' => "🌐 Traducción ({strtoupper($targetLang)})",
'description' => $translatedText,
'color' => 3066993,
'fields' => [
[
'name' => 'Original',
'value' => mb_substr($originalText, 0, 1024),
'inline' => false
]
]
];
return $this->sender->sendMessage($channelId, '', $embed);
}
public function handleButtonInteraction(string $channelId, string $messageId, string $customId): array
{
if (str_starts_with($customId, 'translate_')) {
$lang = str_replace('translate_', '', $customId);
return ['action' => 'translate', 'lang' => $lang];
}
return ['action' => 'unknown'];
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Discord\Converters;
class HtmlToDiscordMarkdownConverter
{
public function convert(string $html): string
{
$content = $html;
$content = $this->convertImages($content);
$content = $this->convertBold($content);
$content = $this->convertItalic($content);
$content = $this->convertUnderline($content);
$content = $this->convertStrikethrough($content);
$content = $this->convertCode($content);
$content = $this->convertLinks($content);
$content = $this->convertLists($content);
$content = $this->convertHeaders($content);
$content = $this->convertLineBreaks($content);
$content = $this->cleanUp($content);
return trim($content);
}
private function convertImages(string $content): string
{
$content = preg_replace('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', '($1)', $content);
return $content;
}
private function convertBold(string $content): string
{
$content = preg_replace('/<strong[^>]*>(.*?)<\/strong>/is', '**$1**', $content);
$content = preg_replace('/<b[^>]*>(.*?)<\/b>/is', '**$1**', $content);
return $content;
}
private function convertItalic(string $content): string
{
$content = preg_replace('/<em[^>]*>(.*?)<\/em>/is', '*$1*', $content);
$content = preg_replace('/<i[^>]*>(.*?)<\/i>/is', '*$1*', $content);
return $content;
}
private function convertUnderline(string $content): string
{
$content = preg_replace('/<u[^>]*>(.*?)<\/u>/is', '__$1__', $content);
return $content;
}
private function convertStrikethrough(string $content): string
{
$content = preg_replace('/<s[^>]*>(.*?)<\/s>/is', '~~$1~~', $content);
$content = preg_replace('/<strike[^>]*>(.*?)<\/strike>/is', '~~$1~~', $content);
$content = preg_replace('/<del[^>]*>(.*?)<\/del>/is', '~~$1~~', $content);
return $content;
}
private function convertCode(string $content): string
{
$content = preg_replace('/<code[^>]*>(.*?)<\/code>/is', '`$1`', $content);
$content = preg_replace('/<pre[^>]*>(.*?)<\/pre>/is', "```\n$1\n```", $content);
return $content;
}
private function convertLinks(string $content): string
{
$content = preg_replace('/<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is', '[$2]($1)', $content);
return $content;
}
private function convertLists(string $content): string
{
$content = preg_replace('/<li[^>]*>(.*?)<\/li>/is', "\n$1", $content);
$content = preg_replace('/<ul[^>]*>/is', '', $content);
$content = preg_replace('/<\/ul>/is', '', $content);
return $content;
}
private function convertHeaders(string $content): string
{
$content = preg_replace('/<h1[^>]*>(.*?)<\/h1>/is', "\n\n## $1\n", $content);
$content = preg_replace('/<h2[^>]*>(.*?)<\/h2>/is', "\n\n### $1\n", $content);
$content = preg_replace('/<h3[^>]*>(.*?)<\/h3>/is', "\n\n#### $1\n", $content);
$content = preg_replace('/<h4[^>]*>(.*?)<\/h4>/is', "\n\n##### $1\n", $content);
$content = preg_replace('/<h5[^>]*>(.*?)<\/h5>/is', "\n\n###### $1\n", $content);
$content = preg_replace('/<h6[^>]*>(.*?)<\/h6>/is', "\n\n###### $1\n", $content);
return $content;
}
private function convertLineBreaks(string $content): string
{
$content = preg_replace('/<br\s*\/?>/i', "\n", $content);
return $content;
}
private function cleanUp(string $content): string
{
$content = preg_replace('/<p[^>]*>(.*?)<\/p>/is', "\n$1\n", $content);
$content = preg_replace('/<div[^>]*>(.*?)<\/div>/is', "\n$1\n", $content);
$content = preg_replace('/<span[^>]*>(.*?)<\/span>/is', '$1', $content);
$content = strip_tags($content);
$content = preg_replace('/&nbsp;/', ' ', $content);
$content = preg_replace('/&amp;/', '&', $content);
$content = preg_replace('/&lt;/', '<', $content);
$content = preg_replace('/&gt;/', '>', $content);
$content = preg_replace('/&quot;/', '"', $content);
$content = preg_replace('/\n{3,}/', "\n\n", $content);
return $content;
}
public function extractImages(string $html): array
{
preg_match_all('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $html, $matches);
return $matches[1] ?? [];
}
public function removeImages(string $html): string
{
return preg_replace('/<img[^>]+>/i', '', $html);
}
}