Модуль вывода сообщений из канала MAX на сайт с CMS Joomla 3

Архитектура модуля. Создаём на компьютере папку с названием mod_max_news. Внутри нее должны находиться 3 файла:

  1. mod_max_news.xml — манифест установки для Joomla.
  2. mod_max_news.php — точка входа и логика запроса к API.
  3. tmpl/default.php — шаблон для HTML-вывода ленты новостей.
  1. Создаем структуру папок на компьютере и сохраняем эти три файла (не забудьте положить default.php внутрь папки tmpl).
  2. Сформируем ZIP-архив из содержимого папки mod_max_news.
  3. В панели управления Joomla 3 зайдем в Расширения → Менеджер расширений → Установка. Загрузим созданный архив.
  4. Перейдем в Расширения → Менеджер модулей, находим модуль «MAX Messenger News Feed», укажем его позицию на сайте, выберем страницы отображения и впишем в настройки данные ID нашего канала и токен бота, которого нужно создать и сделать администратором канала. Переведем статус модуля в Опубликовано и разместим модуль на странице.

1. Файл манифеста: mod_max_news.xml Этот файл регистрирует модуль в системе и выводит настройки (Токен бота и ID канала) прямо в административную панель Joomla.

<?xml version="1.0" encoding="utf-8"?>
<extension type="module" version="3.0" client="site" method="upgrade">
<name>MAX Messenger News Feed</name>
<author>Your Name</author>
<version>1.1.0</version>
<description>Модуль для вывода ленты новостей из мессенджера MAX через API с поддержкой кэширования и логирования.</description>

<files>
<filename module="mod_max_news">mod_max_news.php</filename>
<folder>tmpl</folder>
</files>

<config>
<fields name="params">
<fieldset name="basic">
<field name="bot_token" type="text" default="" label="Токен Bot API" description="Введите токен вашего MAX бота" required="true" />
<field name="channel_id" type="text" default="" label="ID Канала" description="ID или никнейм публичного канала (например, @my_channel)" required="true" />
<field name="post_count" type="number" default="5" label="Количество постов" description="Сколько новостей отображать на странице" />
</fieldset>

<fieldset name="logging" label="Логирование">
<field name="log_level" type="list" default="errors" label="Уровень логирования" description="Выберите тип записываемых событий в системный журнал">
<option value="none">Отключить логирование</option>
<option value="critical">Только критические ошибки (CRITICAL)</option>
<option value="errors">Ошибки и критические сбои (ERROR + CRITICAL)</option>
<option value="all">Записывать всё (Включая WARNING)</option>
</field>
</fieldset>
</fields>
</config>
</extension>

2. Главный файл логики: mod_max_news.php. Файл получает параметры из админки, делает безопасный cURL запрос к API мессенджера MAX и передает полученный массив постов в файл верстки.

<?php
defined('_JEXEC') or die;
jimport('joomla.log.log');
$botToken = $params->get('bot_token');
$channelId = $params->get('channel_id');
$limit = (int) $params->get('post_count', 5);
$logLevel = $params->get('log_level', 'errors');
$cacheMaxAge = 30 * 24 * 60 * 60; // 30 дней
$gcProbability = 5;

$loggingEnabled = ($logLevel !== 'none');if ($loggingEnabled) { $jLogLevel = ($logLevel === 'critical') ? (JLog::EMERGENCY | JLog::ALERT | JLog::CRITICAL) : (($logLevel === 'errors') ? (JLog::EMERGENCY | JLog::ALERT | JLog::CRITICAL | JLog::ERROR) : JLog::ALL); JLog::addLogger(array('text_file' => 'mod_max_news.error.log'), $jLogLevel, array('mod_max_news'));}if (empty($botToken) || empty($channelId)) { echo "Пожалуйста, настройте параметры модуля MAX Messenger."; return;}$db = JFactory::getDbo();$tableName = $db->quoteName('#__mod_max_news');

// 1. ПРОВЕРКА И СОЗДАНИЕ ТАБЛИЦЫ БД С УВЕЛИЧЕННЫМИ ИНДЕКСАМИ ДЛЯ MESSAGE_ID

$query = "CREATE TABLE IF NOT EXISTS " . $tableName . " ( `id` INT(11) NOT NULL AUTO_INCREMENT, `message_id` VARCHAR(100) NOT NULL, `chat_id` VARCHAR(50) NOT NULL, `text` TEXT, `media_type` VARCHAR(20) DEFAULT NULL, `media_url` TEXT DEFAULT NULL, `timestamp` INT(11) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `msg_chat` (`message_id`, `chat_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";$db->setQuery($query);try { $db->execute();} catch (Exception $e) { if ($loggingEnabled) JLog::add('Ошибка создания таблицы: ' . $e->getMessage(), JLog::CRITICAL, 'mod_max_news');}

// 2. ЗАПРОС НОВЫХ СООБЩЕНИЙ ИЗ MAX BOT API

$headers = array( 'Authorization: ' . trim($botToken), 'Accept: application/json', 'Content-Type: application/json'); $apiUrl = "https://platform-api.max.ru/messages?chat_id=" . $channelId;$ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $apiUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);curl_close($ch); if ($httpCode === 200) { $newsData = json_decode($response, true); $rawMessages = isset($newsData['messages']) ? $newsData['messages'] : array(); if (is_array($rawMessages) && !empty($rawMessages)) { foreach ($rawMessages as $msg) { $msgChatId = isset($msg['recipient']['chat_id']) ? trim((string)$msg['recipient']['chat_id']) : ''; $targetChatId = trim((string)$channelId); if ($msgChatId === $targetChatId) { $messageId = isset($msg['body']['mid']) ? $msg['body']['mid'] : md5(serialize($msg)); $text = isset($msg['body']['text']) ? $msg['body']['text'] : ''; $msTimestamp = isset($msg['timestamp']) ? $msg['timestamp'] : time() * 1000; $date = (int)($msTimestamp / 1000); $mediaType = 'TEXT'; $mediaUrl = ''; // ИСПРАВЛЕНИЕ: Строго извлекаем нулевой индекс [0] из массива attachments, чтобы не дублировать ссылки if (isset($msg['body']['attachments'][0]) && is_array($msg['body']['attachments'][0])) { $firstAttachment = $msg['body']['attachments'][0]; $mediaType = isset($firstAttachment['type']) ? strtoupper($firstAttachment['type']) : 'IMAGE'; $mediaUrl = isset($firstAttachment['payload']['url']) ? $firstAttachment['payload']['url'] : ''; } $insertQuery = $db->getQuery(true) ->insert($tableName, true) ->columns(array( $db->quoteName('message_id'), $db->quoteName('chat_id'), $db->quoteName('text'), $db->quoteName('media_type'), $db->quoteName('media_url'), $db->quoteName('timestamp') )) ->values( $db->quote($messageId) . ', ' . $db->quote($msgChatId) . ', ' . $db->quote($text) . ', ' . $db->quote($mediaType) . ', ' . $db->quote($mediaUrl) . ', ' . (int)$date ); try { $db->setQuery($insertQuery); $db->execute(); } catch (Exception $e) { if ($loggingEnabled) { JLog::add('Ошибка SQL при записи поста: ' . $e->getMessage(), JLog::ERROR, 'mod_max_news'); } } } }}} else { if ($loggingEnabled && $httpCode !== 0) { JLog::add('Сбой запроса к API МАКС. HTTP-код: ' . $httpCode . ' Ответ: ' . substr($response, 0, 150), JLog::WARNING, 'mod_max_news'); }}

// 3. ПОЛУЧЕНИЕ ДАННЫХ ИЗ БАЗЫ ДЛЯ ВЫВОДА В ШАБЛОН

$selectQuery = $db->getQuery(true) ->select('*') ->from($tableName) ->where($db->quoteName('chat_id') . ' = ' . $db->quote((string)trim($channelId))) ->order($db->quoteName('timestamp') . ' DESC') ->setLimit($limit);$db->setQuery($selectQuery);$rows = $db->loadObjectList();$posts = array();$cacheDirName = 'images/max_cache';$cachePath = JPATH_SITE . '/' . $cacheDirName;if (!JFolder::exists($cachePath)) { JFolder::create($cachePath); JFile::write($cachePath . '/index.html', '<html><body bgcolor="#ffffff"></body></html>');}

// 4. СКАЧИВАНИЕ И КЭШИРОВАНИЕ МЕДИАФАЙЛОВ НА ХОСТИНГ

if (!empty($rows)) { foreach ($rows as $row) { $postItem = array( 'text' => $row->text, 'date' => $row->timestamp, 'content' => null ); if (!empty($row->media_url)) { $remoteUrl = $row->media_url; $cleanUrl = strtok($remoteUrl, '?'); $fileExtension = pathinfo($cleanUrl, PATHINFO_EXTENSION); if (empty($fileExtension)) { $fileExtension = ($row->media_type === 'VIDEO') ? 'mp4' : 'jpg'; } $localFileName = md5($remoteUrl) . '.' . $fileExtension; $localFilePath = $cachePath . '/' . $localFileName; if (!JFile::exists($localFilePath)) { $chMedia = curl_init($remoteUrl); curl_setopt($chMedia, CURLOPT_RETURNTRANSFER, true); curl_setopt($chMedia, CURLOPT_TIMEOUT, 20); curl_setopt($chMedia, CURLOPT_BINARYTRANSFER, true); curl_setopt($chMedia, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($chMedia, CURLOPT_HTTPHEADER, $headers); $mediaData = curl_exec($chMedia); curl_close($chMedia); if (!empty($mediaData)) { JFile::write($localFilePath, $mediaData); $cachedUrl = JURI::base(true) . '/' . $cacheDirName . '/' . $localFileName; } else { $cachedUrl = $remoteUrl; } } else { $cachedUrl = JURI::base(true) . '/' . $cacheDirName . '/' . $localFileName; } $postItem['content'] = array( 'type' => $row->media_type, 'cached_url' => $cachedUrl ); } $posts[] = $postItem; } unset($postItem);}

// 5. УДАЛЕНИЕ СТАРЫХ ФАЙЛОВ И ЗАПИСЕЙ КЭША

if (rand(1, 100) <= $gcProbability && JFolder::exists($cachePath)) { $thresholdTime = time() - $cacheMaxAge; $deleteQuery = $db->getQuery(true) ->delete($tableName) ->where($db->quoteName('timestamp') . ' < ' . $thresholdTime); $db->setQuery($deleteQuery); $db->execute(); $files = JFolder::files($cachePath, '.', false, true); if (!empty($files)) { foreach ($files as $file) { if (basename($file) === 'index.html') continue; if ((time() - filemtime($file)) > $cacheMaxAge) { JFile::delete($file); } } }}require JModuleHelper::getLayoutPath('mod_max_news', 'default');

3. Шаблон вывода: tmpl/default.php

<?php
defined('_JEXEC') or die;
?>
<div class="max-news-feed">
<?php if (!empty($posts)): ?>
<?php foreach ($posts as $post): ?>
<div class="max-news-item" style="margin-bottom: 25px; padding: 15px; border: 1px solid #e1e1e1; background: #fff; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">

<!-- ОБРАБОТКА МЕДИАФАЙЛОВ ИЗ КЭША -->
<?php if (isset($post['content']) && is_array($post['content'])): ?>
<?php
$contentType = isset($post['content']['type']) ? $post['content']['type'] : 'TEXT';
// Используем закэшированный URL, если он был успешно создан
$mediaUrl = isset($post['content']['cached_url']) ? $post['content']['cached_url'] : '';
?>
<?php if (!empty($mediaUrl)): ?>
<div class="max-news-media" style="margin-bottom: 15px; text-align: center;">

<!-- Если это картинка -->
<?php if ($contentType === 'IMAGE'): ?>
<img src="<?php echo htmlspecialchars($mediaUrl); ?>"
alt="Иллюстрация"
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin: 0 auto;" />

<!-- Если это видео -->
<?php elseif ($contentType === 'VIDEO'): ?>
<video controls style="width: 100%; max-height: 400px; background: #000; border-radius: 4px;">
<source src="<?php echo htmlspecialchars($mediaUrl); ?>" type="video/mp4">
Ваш браузер не поддерживает встроенные видео.
</video>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<!-- ТЕКСТ ПОСТА -->
<?php if (!empty($post['text'])): ?>
<div class="max-news-text" style="font-size: 14px; line-height: 1.6; color: #333; margin-bottom: 10px; word-wrap: break-word;">
<?php echo nl2br(htmlspecialchars($post['text'])); ?>
</div>
<?php endif; ?>

<!-- ДАТА ПУБЛИКАЦИИ -->
<?php if (isset($post['date'])): ?>
<div class="max-news-date" style="text-align: right;">
<small class="text-muted" style="font-size: 11px; color: #999;">
<?php echo date('d.m.Y H:i', $post['date']); ?>
</small>
</div>
<?php endif; ?>

</div>
<?php endforeach; ?>
<?php else: ?>
<p>В данный момент новостей нет.</p>
<?php endif; ?>
</div>