diff --git a/.gitignore b/.gitignore index f4bd435ad..63489743a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ node_modules/ # Ignore compiled binaries main +3x-ui # Ignore OS specific files .DS_Store diff --git a/docs/content/docs/en/config/panel.mdx b/docs/content/docs/en/config/panel.mdx index 542a4e604..16f68b49c 100644 --- a/docs/content/docs/en/config/panel.mdx +++ b/docs/content/docs/en/config/panel.mdx @@ -67,6 +67,7 @@ These have their own settings groups and pages: + diff --git a/docs/content/docs/en/operations/discord-bot.mdx b/docs/content/docs/en/operations/discord-bot.mdx new file mode 100644 index 000000000..da6ef9927 --- /dev/null +++ b/docs/content/docs/en/operations/discord-bot.mdx @@ -0,0 +1,121 @@ +--- +title: Discord Bot +description: Connect a Discord bot to 3x-ui to receive real-time Embed notifications in a channel for panel events (service crashes, node status, CPU/RAM load, and login attempts). +icon: Bot +--- + +3x-ui provides comprehensive Discord integration: real-time event notifications via the event bus (`EventBus`), periodic scheduled health reports with database backups, and interactive commands via the Discord Gateway. + + + Discord notifications and scheduled reports use outbound HTTPS REST API v10 calls. Interactive bot commands connect via a secure background WebSocket connection to the Discord Gateway. + + +## Set it up + + + + +### Create a Discord Application & Bot + +1. Open the [Discord Developer Portal](https://discord.com/developers/applications) and sign in. +2. Click **New Application** at the top right, enter a name (e.g., `3x-ui Notifier`), and confirm. +3. In the left sidebar, navigate to the **Bot** tab. +4. Click **Reset Token** (or **Add Bot** if not already created) and copy the **Bot Token**. Keep this token secure. +5. Under **Privileged Gateway Intents**, toggle on **Message Content Intent** (required for the bot to read prefix commands like `!status`). + + + +### Invite the Bot to your Discord Server + +1. In the Discord Developer Portal, navigate to **OAuth2** $\rightarrow$ **URL Generator**. +2. Under **Scopes**, check `bot`. +3. Under **Bot Permissions**, select: + - **Send Messages** + - **Embed Links** + - **Attach Files** (required for database backups) + - **Read Message History** +4. Copy the generated URL at the bottom and open it in your browser to invite the bot to your server. + + + +### Copy the Channel ID + +1. In your Discord client, enable Developer Mode: **User Settings** $\rightarrow$ **Advanced** $\rightarrow$ **Developer Mode** (toggle on). +2. Right-click the channel where you want alerts and bot interaction to occur and select **Copy Channel ID**. +3. Ensure the bot has access to view and send messages in this specific channel. + + + +### Configure the Panel + +1. In the 3x-ui panel, open **Panel Settings** $\rightarrow$ **Discord Bot** (or navigate to `/settings#discord`). +2. Under **General**: + - Toggle **Enable Discord Notifications** on. + - Enter your **Discord Bot Token** and **Channel ID**. + - Enter your own Discord user ID in **Admin User IDs** (right-click your name → **Copy User ID**; separate several IDs with commas). + - Select your preferred **Discord Bot Language**. +3. Under **Notifications**: + - Set the **Notification Time** schedule (e.g., `@daily`, `@weekly`, or custom crontab). + - Optionally toggle **Database Backups** to automatically attach `x-ui.db` with periodic reports. + - Select which events trigger notifications and adjust CPU/RAM thresholds. +4. Click **Send Test Notification** to verify delivery. A test embed will immediately appear in your Discord channel. +5. Click **Save** to apply changes. + + + + +## Bot Commands + +When enabled, the bot listens to commands in the configured Discord channel (supporting both `!` and `/` prefixes). Only users listed in **Admin User IDs** can run them; messages from anyone else are ignored, and an empty list turns commands off. `!backup` and scheduled backups post the database into the channel, so pick a channel only admins can read: + +| Command | Description | +| ------- | ----------- | +| `!status` | Display system load, RAM, CPU usage, TCP/UDP connections, and active clients. | +| `!report` | Generate and send a complete status report embed immediately. | +| `!backup` | Download current database backup file (`x-ui.db`) and `config.json`. | +| `!usage ` | Query bandwidth usage (upload/download), quota limit, and expiration date for a client. | +| `!inbounds` | List all active inbounds with port, protocol, traffic, and client counts. | +| `!restart` | Safely restart the Xray core without restarting the web panel. | +| `!help` | Display list of available bot commands. | + +## Event Alerts + +Alerts are sent as Discord Embeds with color coding and relevant diagnostics: + +| Event | Indicator | Description | +| ----- | --------- | ----------- | +| `xray.crash` | 🔴 Red | Xray-core crashed; includes reason and timestamp | +| `outbound.down` | 🔴 Red | Outbound connectivity test failed | +| `outbound.up` | 🟢 Green | Outbound connectivity restored | +| `node.down` | 🔴 Red | Remote sub-node offline or unreachable | +| `node.up` | 🟢 Green | Remote sub-node reconnected and healthy | +| `cpu.high` | 🟠 Orange | Host CPU usage exceeded configured threshold (`discordCpu`) | +| `memory.high` | 🟠 Orange | Host memory usage exceeded configured threshold (`discordMemory`) | +| `login.attempt` | 🟢 / 🔴 | Web panel login attempt with username, IP, and status | + + + Login alerts report the attempted username and client IP address. Passwords are never logged or transmitted. + + +## Settings Reference + +| Setting | Default | Description | +| ------- | ------- | ----------- | +| `discordBotEnable` | `false` | Master toggle for Discord bot and notifications. | +| `discordBotToken` | _(secret)_ | Discord Bot token from Developer Portal. | +| `discordChannelId` | _(none)_ | Target Discord channel snowflake ID (17–20 digits). | +| `discordAdminIds` | _(none)_ | Comma-separated Discord user IDs allowed to run bot commands. Empty turns commands off. | +| `discordLang` | `en-US` | Language for Discord bot messages and reports. | +| `discordRunTime` | `@daily` | Cron expression or interval for periodic status reports. | +| `discordBotBackup` | `false` | Whether to attach database backup (`x-ui.db`) to reports. | +| `discordEnabledEvents` | `login.attempt,cpu.high` | Comma-separated list of enabled event types. | +| `discordCpu` | `80` | CPU utilization percentage threshold for alerts (0–100). | +| `discordMemory` | `80` | RAM utilization percentage threshold for alerts (0–100). | + +## Troubleshooting + +- **Test fails with "invalid bot token (401)"**: Verify that you copied the full Bot Token from the **Bot** tab in Developer Portal, not the Client Secret or Application ID. +- **Test fails with "missing permissions (403)"**: Ensure the bot role has **Send Messages**, **Embed Links**, and **Attach Files** permissions in the target channel or category. +- **Commands do not respond**: Check that your Discord user ID is listed in **Admin User IDs**. Then ensure **Message Content Intent** is enabled under the **Bot** tab in Discord Developer Portal and restart the panel: Discord closes the connection for good when the intent is missing, so the bot does not retry on its own. +- **Test fails with "channel not found (404)"**: Verify the numeric Channel ID. Ensure the bot is present in the server that owns the channel. +- **Proxying outbound requests**: If your host requires a proxy to connect to Discord, configure **Panel Outbound** in Panel Settings. Discord requests automatically route through the configured panel outbound proxy. diff --git a/docs/content/docs/en/operations/meta.json b/docs/content/docs/en/operations/meta.json index 914a25fa1..a69d9d2ca 100644 --- a/docs/content/docs/en/operations/meta.json +++ b/docs/content/docs/en/operations/meta.json @@ -7,6 +7,7 @@ "outbounds-routing", "backup-restore", "telegram-bot", + "discord-bot", "security" ] } diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index 8f1224e37..0344a8c08 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/docs/content/docs/en/reference/api/clients.mdx @@ -227,7 +227,8 @@ _openapi: title: Reset the recorded IP list for a client. url: '#reset-the-recorded-ip-list-for-a-client' - depth: 2 - title: List registered HWID devices for a client with a short fingerprint. Full hashes are not exposed. + title: List registered HWID devices for a client with a short fingerprint. Full + hashes are not exposed. url: '#list-registered-hwid-devices-for-a-client-with-a-short-fingerprint-full-hashes-are-not-exposed' - depth: 2 title: Clear all registered HWID devices for a client so new devices can @@ -481,7 +482,8 @@ _openapi: id: list-source-ips-that-have-connected-with-the-given-clients-credentials-returns-an-array-of-ip-timestamp-strings - content: Reset the recorded IP list for a client. id: reset-the-recorded-ip-list-for-a-client - - content: List registered HWID devices for a client with a short fingerprint. Full hashes are not exposed. + - content: List registered HWID devices for a client with a short fingerprint. + Full hashes are not exposed. id: list-registered-hwid-devices-for-a-client-with-a-short-fingerprint-full-hashes-are-not-exposed - content: Clear all registered HWID devices for a client so new devices can register again. diff --git a/docs/content/docs/en/reference/api/settings.mdx b/docs/content/docs/en/reference/api/settings.mdx index 46a721191..442de9f1a 100644 --- a/docs/content/docs/en/reference/api/settings.mdx +++ b/docs/content/docs/en/reference/api/settings.mdx @@ -48,6 +48,10 @@ _openapi: title: Test Telegram bot connection by sending a test message to the configured chat. url: '#test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat' + - depth: 2 + title: Test Discord bot connection by sending a test embed to the configured + channel. + url: '#test-discord-bot-connection-by-sending-a-test-embed-to-the-configured-channel' - depth: 2 title: Return the built-in default Xray JSON config template that ships with this panel version. @@ -86,6 +90,9 @@ _openapi: - content: Test Telegram bot connection by sending a test message to the configured chat. id: test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat + - content: Test Discord bot connection by sending a test embed to the configured + channel. + id: test-discord-bot-connection-by-sending-a-test-embed-to-the-configured-channel - content: Return the built-in default Xray JSON config template that ships with this panel version. id: return-the-built-in-default-xray-json-config-template-that-ships-with-this-panel-version @@ -101,7 +108,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/fa/operations/meta.json b/docs/content/docs/fa/operations/meta.json index 26b193a11..000badb34 100644 --- a/docs/content/docs/fa/operations/meta.json +++ b/docs/content/docs/fa/operations/meta.json @@ -7,6 +7,7 @@ "outbounds-routing", "backup-restore", "telegram-bot", + "discord-bot", "security" ] } diff --git a/docs/content/docs/ru/config/panel.mdx b/docs/content/docs/ru/config/panel.mdx index b9bb6419f..a8b7c29df 100644 --- a/docs/content/docs/ru/config/panel.mdx +++ b/docs/content/docs/ru/config/panel.mdx @@ -67,6 +67,7 @@ icon: SlidersHorizontal + diff --git a/docs/content/docs/ru/operations/discord-bot.mdx b/docs/content/docs/ru/operations/discord-bot.mdx new file mode 100644 index 000000000..ec9d5efe0 --- /dev/null +++ b/docs/content/docs/ru/operations/discord-bot.mdx @@ -0,0 +1,121 @@ +--- +title: Discord-бот +description: Подключите Discord-бота к 3x-ui для получения оповещений о событиях панели (сбои сервисов, доступность узлов, нагрузка CPU/RAM и попытки входа) прямо в канал Discord. +icon: Bot +--- + +3x-ui предоставляет полную интеграцию с Discord: мгновенные уведомления о событиях через шину событий панели (`EventBus`), периодические отчёты о состоянии сервера с резервным копированием базы данных, а также интерактивные команды через Discord Gateway. + + + Уведомления и периодические отчёты отправляются через исходящие HTTPS-запросы к REST API Discord v10. Интерактивные команды бота работают через постоянное защищённое WebSocket-соединение с Discord Gateway. + + +## Настройка + + + + +### Создайте приложение и бота в Discord + +1. Откройте [Discord Developer Portal](https://discord.com/developers/applications) и авторизуйтесь. +2. Нажмите **New Application** в правом верхнем углу, укажите имя (например, `3x-ui Notifier`) и подтвердите создание. +3. В боковом меню перейдите во вкладку **Bot**. +4. Нажмите **Reset Token** (или **Add Bot**, если бот ещё не создан) и скопируйте **Bot Token**. Сохраните токен в надёжном месте. +5. В блоке **Privileged Gateway Intents** включите переключатель **Message Content Intent** (необходимо, чтобы бот мог читать команды вида `!status`). + + + +### Пригласите бота на свой сервер Discord + +1. В Developer Portal перейдите в раздел **OAuth2** $\rightarrow$ **URL Generator**. +2. В блоке **Scopes** отметьте галочкой `bot`. +3. В блоке **Bot Permissions** выберите: + - **Send Messages** (Отправка сообщений) + - **Embed Links** (Встраивание ссылок / Embeds) + - **Attach Files** (Прикрепление файлов — необходимо для резервных копий БД) + - **Read Message History** (Чтение истории сообщений) +4. Скопируйте полученную ссылку внизу страницы, откройте её в браузере и добавьте бота на нужный сервер. + + + +### Скопируйте ID канала (Channel ID) + +1. В клиенте Discord включите режим разработчика: **Настройки пользователя** $\rightarrow$ **Расширенные** $\rightarrow$ **Режим разработчика** (Developer Mode). +2. Нажмите правой кнопкой мыши по каналу, куда должны приходить уведомления и команды, и выберите **Копировать ID канала**. +3. Убедитесь, что у бота есть права на просмотр и отправку сообщений в этот канал. + + + +### Настройте панель 3x-ui + +1. В веб-интерфейсе 3x-ui перейдите в **Настройки панели** $\rightarrow$ **Discord Bot** (или перейдите по адресу `/settings#discord`). +2. Во вкладке **Основные настройки**: + - Включите **Включить уведомления Discord**. + - Укажите **Токен Discord-бота** и **ID канала**. + - Укажите свой ID пользователя Discord в поле **ID администраторов** (правый клик по своему имени → **Копировать ID пользователя**; несколько ID разделяйте запятыми). + - Выберите **Язык Discord-бота**. +3. Во вкладке **Уведомления**: + - Настройте **Частоту уведомлений** (например, `@daily`, `@weekly` или произвольное выражение crontab). + - При необходимости включите **Резервное копирование базы данных**, чтобы отчёт сопровождался файлом `x-ui.db`. + - Выберите отслеживаемые события и настройте пороги нагрузки CPU/RAM. +4. Нажмите **Отправить тестовое сообщение**, чтобы проверить доставку. В канале Discord появится тестовое Embed-сообщение. +5. Нажмите **Сохранить** для применения настроек. + + + + +## Команды бота + +Когда бот включён, он принимает текстовые команды в настроенном канале (поддерживаются префиксы `!` и `/`). Выполнять их могут только пользователи из списка **ID администраторов**; сообщения остальных игнорируются, а при пустом списке команды отключены. `!backup` и плановые резервные копии публикуют базу данных в канал, поэтому выбирайте канал, доступный только администраторам: + +| Команда | Описание | +| ------- | -------- | +| `!status` | Вывести нагрузку системы, память, процессор, число соединений и активных клиентов. | +| `!report` | Немедленно сгенерировать и отправить подробный отчёт о состоянии сервера. | +| `!backup` | Отправить файл резервной копии базы данных (`x-ui.db`) и `config.json`. | +| `!usage ` | Запросить статистику трафика (Upload/Download), лимит и срок действия клиента. | +| `!inbounds` | Показать список всех активных подключений (порты, протоколы, клиенты, трафик). | +| `!restart` | Перезапустить ядро Xray без перезапуска веб-панели. | +| `!help` | Показать справку по доступным командам. | + +## Оповещения о событиях + +Уведомления приходят в виде Embed-карточек с цветовым обозначением важности: + +| Событие | Индикатор | Описание | +| ------- | --------- | -------- | +| `xray.crash` | 🔴 Красный | Сбой процесса Xray-core с указанием причины и времени | +| `outbound.down` | 🔴 Красный | Неудачная проверка доступности исходящего соединения (outbound) | +| `outbound.up` | 🟢 Зеленый | Восстановление доступности исходящего соединения | +| `node.down` | 🔴 Красный | Удалённый под-узел (node) отключился или недоступен | +| `node.up` | 🟢 Зеленый | Удалённый под-узел снова в сети и готов к работе | +| `cpu.high` | 🟠 Оранжевый | Нагрузка процессора превысила заданный порог (`discordCpu`) | +| `memory.high` | 🟠 Оранжевый | Использование оперативной памяти превысило порог (`discordMemory`) | +| `login.attempt` | 🟢 / 🔴 | Попытка авторизации в панели (с указанием IP и логина) | + + + Оповещения о входе содержат только введённое имя пользователя и IP-адрес. Пароли никогда не логируются и не передаются. + + +## Параметры конфигурации + +| Параметр | По умолчанию | Описание | +| -------- | ------------ | -------- | +| `discordBotEnable` | `false` | Главный переключатель бота и уведомлений Discord. | +| `discordBotToken` | _(секрет)_ | Токен бота из Discord Developer Portal. | +| `discordChannelId` | _(пусто)_ | Идентификатор канала Discord (17–20 цифр). | +| `discordAdminIds` | _(пусто)_ | ID пользователей Discord через запятую, которым разрешено выполнять команды бота. Пустой список отключает команды. | +| `discordLang` | `en-US` | Язык сообщений и отчётов бота. | +| `discordRunTime` | `@daily` | Расписание генерации периодических отчётов (crontab). | +| `discordBotBackup` | `false` | Отправлять ли файл резервной копии базы данных (`x-ui.db`) вместе с отчётом. | +| `discordEnabledEvents` | `login.attempt,cpu.high` | Список отслеживаемых событий через запятую. | +| `discordCpu` | `80` | Порог нагрузки процессора для алерта (в процентах, 0–100). | +| `discordMemory` | `80` | Порог использования RAM для алерта (в процентах, 0–100). | + +## Устранение неполадок + +- **Ошибка "invalid bot token (401)"**: Проверьте, что вы скопировали именно Bot Token из раздела **Bot**, а не Client Secret или Application ID. +- **Ошибка "missing permissions (403)"**: Проверьте, выданы ли роли бота права **Send Messages**, **Embed Links** и **Attach Files** в целевом канале или категории каналов. +- **Бот не реагирует на команды**: Проверьте, что ваш ID пользователя Discord указан в **ID администраторов**. Затем убедитесь, что в Discord Developer Portal в разделе **Bot** включен **Message Content Intent**, и перезапустите панель: без этого разрешения Discord окончательно закрывает соединение, и бот не переподключается сам. +- **Ошибка "channel not found (404)"**: Проверьте правильность числового Channel ID и убедитесь, что бот состоит на сервере, которому принадлежит канал. +- **Проксирование запросов**: Если сервер не имеет прямого доступа к серверам Discord, настройте исходящий прокси в **Настройках панели** (**Исходящий трафик панели** / `panelOutbound`). Запросы бота будут автоматически направляться через этот прокси. diff --git a/docs/content/docs/ru/operations/meta.json b/docs/content/docs/ru/operations/meta.json index f6bc11ef8..9b3823fa8 100644 --- a/docs/content/docs/ru/operations/meta.json +++ b/docs/content/docs/ru/operations/meta.json @@ -7,6 +7,7 @@ "outbounds-routing", "backup-restore", "telegram-bot", + "discord-bot", "security" ] } diff --git a/docs/content/docs/zh/operations/meta.json b/docs/content/docs/zh/operations/meta.json index 89e239f4d..56a2eb6f5 100644 --- a/docs/content/docs/zh/operations/meta.json +++ b/docs/content/docs/zh/operations/meta.json @@ -7,6 +7,7 @@ "outbounds-routing", "backup-restore", "telegram-bot", + "discord-bot", "security" ] } diff --git a/docs/public/openapi.json b/docs/public/openapi.json index dc2ab93cd..32a8ff513 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -31,6 +31,40 @@ "datepicker": { "type": "string" }, + "discordAdminIds": { + "type": "string" + }, + "discordBotBackup": { + "type": "boolean" + }, + "discordBotEnable": { + "type": "boolean" + }, + "discordBotToken": { + "type": "string" + }, + "discordChannelId": { + "type": "string" + }, + "discordCpu": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordEnabledEvents": { + "type": "string" + }, + "discordLang": { + "type": "string" + }, + "discordMemory": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordRunTime": { + "type": "string" + }, "expireDiff": { "minimum": 0, "type": "integer" @@ -465,6 +499,16 @@ }, "required": [ "datepicker", + "discordAdminIds", + "discordBotBackup", + "discordBotEnable", + "discordBotToken", + "discordChannelId", + "discordCpu", + "discordEnabledEvents", + "discordLang", + "discordMemory", + "discordRunTime", "expireDiff", "externalTrafficInformEnable", "externalTrafficInformURI", @@ -606,6 +650,40 @@ "datepicker": { "type": "string" }, + "discordAdminIds": { + "type": "string" + }, + "discordBotBackup": { + "type": "boolean" + }, + "discordBotEnable": { + "type": "boolean" + }, + "discordBotToken": { + "type": "string" + }, + "discordChannelId": { + "type": "string" + }, + "discordCpu": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordEnabledEvents": { + "type": "string" + }, + "discordLang": { + "type": "string" + }, + "discordMemory": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordRunTime": { + "type": "string" + }, "expireDiff": { "minimum": 0, "type": "integer" @@ -622,6 +700,9 @@ "hasApiToken": { "type": "boolean" }, + "hasDiscordBotToken": { + "type": "boolean" + }, "hasLdapPassword": { "type": "boolean" }, @@ -1061,11 +1142,22 @@ }, "required": [ "datepicker", + "discordAdminIds", + "discordBotBackup", + "discordBotEnable", + "discordBotToken", + "discordChannelId", + "discordCpu", + "discordEnabledEvents", + "discordLang", + "discordMemory", + "discordRunTime", "expireDiff", "externalTrafficInformEnable", "externalTrafficInformURI", "happLinkEnable", "hasApiToken", + "hasDiscordBotToken", "hasLdapPassword", "hasNordSecret", "hasSmtpPassword", @@ -12382,6 +12474,40 @@ } } }, + "/panel/api/setting/testDiscord": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Test Discord bot connection by sending a test embed to the configured channel.", + "operationId": "post_panel_api_setting_testDiscord", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "msg": "Test notification sent successfully" + } + } + } + } + } + } + }, "/panel/api/setting/getDefaultJsonConfig": { "get": { "tags": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index dc2ab93cd..32a8ff513 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -31,6 +31,40 @@ "datepicker": { "type": "string" }, + "discordAdminIds": { + "type": "string" + }, + "discordBotBackup": { + "type": "boolean" + }, + "discordBotEnable": { + "type": "boolean" + }, + "discordBotToken": { + "type": "string" + }, + "discordChannelId": { + "type": "string" + }, + "discordCpu": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordEnabledEvents": { + "type": "string" + }, + "discordLang": { + "type": "string" + }, + "discordMemory": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordRunTime": { + "type": "string" + }, "expireDiff": { "minimum": 0, "type": "integer" @@ -465,6 +499,16 @@ }, "required": [ "datepicker", + "discordAdminIds", + "discordBotBackup", + "discordBotEnable", + "discordBotToken", + "discordChannelId", + "discordCpu", + "discordEnabledEvents", + "discordLang", + "discordMemory", + "discordRunTime", "expireDiff", "externalTrafficInformEnable", "externalTrafficInformURI", @@ -606,6 +650,40 @@ "datepicker": { "type": "string" }, + "discordAdminIds": { + "type": "string" + }, + "discordBotBackup": { + "type": "boolean" + }, + "discordBotEnable": { + "type": "boolean" + }, + "discordBotToken": { + "type": "string" + }, + "discordChannelId": { + "type": "string" + }, + "discordCpu": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordEnabledEvents": { + "type": "string" + }, + "discordLang": { + "type": "string" + }, + "discordMemory": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordRunTime": { + "type": "string" + }, "expireDiff": { "minimum": 0, "type": "integer" @@ -622,6 +700,9 @@ "hasApiToken": { "type": "boolean" }, + "hasDiscordBotToken": { + "type": "boolean" + }, "hasLdapPassword": { "type": "boolean" }, @@ -1061,11 +1142,22 @@ }, "required": [ "datepicker", + "discordAdminIds", + "discordBotBackup", + "discordBotEnable", + "discordBotToken", + "discordChannelId", + "discordCpu", + "discordEnabledEvents", + "discordLang", + "discordMemory", + "discordRunTime", "expireDiff", "externalTrafficInformEnable", "externalTrafficInformURI", "happLinkEnable", "hasApiToken", + "hasDiscordBotToken", "hasLdapPassword", "hasNordSecret", "hasSmtpPassword", @@ -12382,6 +12474,40 @@ } } }, + "/panel/api/setting/testDiscord": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Test Discord bot connection by sending a test embed to the configured channel.", + "operationId": "post_panel_api_setting_testDiscord", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "msg": "Test notification sent successfully" + } + } + } + } + } + } + }, "/panel/api/setting/getDefaultJsonConfig": { "get": { "tags": [ diff --git a/frontend/src/components/command-palette/CommandPalette.tsx b/frontend/src/components/command-palette/CommandPalette.tsx index 6378e2940..eeb49fefa 100644 --- a/frontend/src/components/command-palette/CommandPalette.tsx +++ b/frontend/src/components/command-palette/CommandPalette.tsx @@ -15,6 +15,7 @@ import { CopyOutlined, DashboardOutlined, DatabaseOutlined, + DiscordOutlined, ExportOutlined, FileTextOutlined, GlobalOutlined, @@ -464,6 +465,13 @@ export default function CommandPalette() { keywords: ['email', 'smtp', 'mail', 'crash alerts'], icon: , }, + { + path: '/settings#discord', + title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`, + subtitle: t('pages.settings.discordSettings'), + keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'], + icon: , + }, { path: '/settings#subscription', title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`, diff --git a/frontend/src/components/ui/notifications/DiscordNotifications.stories.tsx b/frontend/src/components/ui/notifications/DiscordNotifications.stories.tsx new file mode 100644 index 000000000..a0a015009 --- /dev/null +++ b/frontend/src/components/ui/notifications/DiscordNotifications.stories.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { AllSetting } from '@/models/setting'; +import { DiscordNotifications } from './DiscordNotifications'; + +const meta = { + title: 'UI/Notifications/DiscordNotifications', + component: DiscordNotifications, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Grid of event-group cards (outbound, Xray, node, system, security) that pick which panel events the Discord bot reports, with per-group select-all and CPU/RAM threshold inputs. Used on the settings page Discord tab to edit `discordEnabledEvents`.', + }, + }, + }, + argTypes: { + allSetting: { + description: + 'Panel settings snapshot; reads `discordEnabledEvents` plus the `discordCpu`/`discordMemory` thresholds.', + }, + updateSetting: { + description: + 'Called with a partial settings patch when an event toggle or threshold changes.', + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function Demo({ initial }: { initial: AllSetting }) { + const [settings, setSettings] = useState(initial); + return ( + setSettings((prev) => new AllSetting({ ...prev, ...patch }))} + /> + ); +} + +const placeholderArgs = { + allSetting: new AllSetting(), + updateSetting: () => undefined, +}; + +export const NothingSelected: Story = { + args: placeholderArgs, + render: () => , +}; + +export const TypicalMonitoring: Story = { + args: placeholderArgs, + render: () => ( + + ), +}; + +export const EverythingEnabled: Story = { + args: placeholderArgs, + render: () => ( + + ), +}; diff --git a/frontend/src/components/ui/notifications/DiscordNotifications.tsx b/frontend/src/components/ui/notifications/DiscordNotifications.tsx new file mode 100644 index 000000000..3aca818cf --- /dev/null +++ b/frontend/src/components/ui/notifications/DiscordNotifications.tsx @@ -0,0 +1,123 @@ +import { InputNumber } from 'antd'; +import { + CloudServerOutlined, + ThunderboltOutlined, + DesktopOutlined, + DashboardOutlined, + SafetyOutlined, +} from '@ant-design/icons'; +import type { AllSetting } from '@/models/setting'; +import { NotificationLayout } from './NotificationLayout'; +import { NotificationGroup } from './NotificationGroup'; +import type { NotificationGroupConfig } from './types'; + +const GROUPS: NotificationGroupConfig[] = [ + { + icon: , + title: 'eventGroupOutbound', + events: [ + { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' }, + { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' }, + ], + }, + { + icon: , + title: 'eventGroupXray', + events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }], + }, + { + icon: , + title: 'eventGroupNode', + events: [ + { key: 'node.down', label: 'eventNodeDown', settingKey: '' }, + { key: 'node.up', label: 'eventNodeUp', settingKey: '' }, + ], + }, + { + icon: , + title: 'eventGroupSystem', + events: [ + { + key: 'cpu.high', + label: 'eventCPUHigh', + settingKey: 'discordCpu', + extra: ({ value, onChange, ariaLabel }) => ( + + ), + }, + { + key: 'memory.high', + label: 'eventMemoryHigh', + settingKey: 'discordMemory', + extra: ({ value, onChange, ariaLabel }) => ( + + ), + }, + ], + }, + { + icon: , + title: 'eventGroupSecurity', + events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }], + }, +]; + +interface Props { + allSetting: AllSetting; + updateSetting: (patch: Partial) => void; +} + +export function DiscordNotifications({ allSetting, updateSetting }: Props) { + const events = allSetting.discordEnabledEvents || ''; + const selected = events + ? events + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : []; + + function toggle(key: string) { + const next = selected.includes(key) ? selected.filter((e) => e !== key) : [...selected, key]; + updateSetting({ discordEnabledEvents: next.join(',') }); + } + + function toggleAll(keys: string[]) { + const allSelected = keys.every((v) => selected.includes(v)); + const next = allSelected + ? selected.filter((v) => !keys.includes(v)) + : [...new Set([...selected, ...keys])]; + updateSetting({ discordEnabledEvents: next.join(',') }); + } + + return ( + + {GROUPS.map((group, i) => ( + + ))} + + ); +} diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 10777c76e..36c2474c1 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -2,6 +2,16 @@ export const EXAMPLES: Record = { "AllSetting": { "datepicker": "", + "discordAdminIds": "", + "discordBotBackup": false, + "discordBotEnable": false, + "discordBotToken": "", + "discordChannelId": "", + "discordCpu": 0, + "discordEnabledEvents": "", + "discordLang": "", + "discordMemory": 0, + "discordRunTime": "", "expireDiff": 0, "externalTrafficInformEnable": false, "externalTrafficInformURI": "", @@ -138,11 +148,22 @@ export const EXAMPLES: Record = { }, "AllSettingView": { "datepicker": "", + "discordAdminIds": "", + "discordBotBackup": false, + "discordBotEnable": false, + "discordBotToken": "", + "discordChannelId": "", + "discordCpu": 0, + "discordEnabledEvents": "", + "discordLang": "", + "discordMemory": 0, + "discordRunTime": "", "expireDiff": 0, "externalTrafficInformEnable": false, "externalTrafficInformURI": "", "happLinkEnable": false, "hasApiToken": false, + "hasDiscordBotToken": false, "hasLdapPassword": false, "hasNordSecret": false, "hasSmtpPassword": false, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 8dce0656a..a5dc8fa20 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -5,6 +5,40 @@ export const SCHEMAS: Record = { "datepicker": { "type": "string" }, + "discordAdminIds": { + "type": "string" + }, + "discordBotBackup": { + "type": "boolean" + }, + "discordBotEnable": { + "type": "boolean" + }, + "discordBotToken": { + "type": "string" + }, + "discordChannelId": { + "type": "string" + }, + "discordCpu": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordEnabledEvents": { + "type": "string" + }, + "discordLang": { + "type": "string" + }, + "discordMemory": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordRunTime": { + "type": "string" + }, "expireDiff": { "minimum": 0, "type": "integer" @@ -439,6 +473,16 @@ export const SCHEMAS: Record = { }, "required": [ "datepicker", + "discordAdminIds", + "discordBotBackup", + "discordBotEnable", + "discordBotToken", + "discordChannelId", + "discordCpu", + "discordEnabledEvents", + "discordLang", + "discordMemory", + "discordRunTime", "expireDiff", "externalTrafficInformEnable", "externalTrafficInformURI", @@ -580,6 +624,40 @@ export const SCHEMAS: Record = { "datepicker": { "type": "string" }, + "discordAdminIds": { + "type": "string" + }, + "discordBotBackup": { + "type": "boolean" + }, + "discordBotEnable": { + "type": "boolean" + }, + "discordBotToken": { + "type": "string" + }, + "discordChannelId": { + "type": "string" + }, + "discordCpu": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordEnabledEvents": { + "type": "string" + }, + "discordLang": { + "type": "string" + }, + "discordMemory": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "discordRunTime": { + "type": "string" + }, "expireDiff": { "minimum": 0, "type": "integer" @@ -596,6 +674,9 @@ export const SCHEMAS: Record = { "hasApiToken": { "type": "boolean" }, + "hasDiscordBotToken": { + "type": "boolean" + }, "hasLdapPassword": { "type": "boolean" }, @@ -1035,11 +1116,22 @@ export const SCHEMAS: Record = { }, "required": [ "datepicker", + "discordAdminIds", + "discordBotBackup", + "discordBotEnable", + "discordBotToken", + "discordChannelId", + "discordCpu", + "discordEnabledEvents", + "discordLang", + "discordMemory", + "discordRunTime", "expireDiff", "externalTrafficInformEnable", "externalTrafficInformURI", "happLinkEnable", "hasApiToken", + "hasDiscordBotToken", "hasLdapPassword", "hasNordSecret", "hasSmtpPassword", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 5a88e97c6..26c6e6b40 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -9,6 +9,16 @@ export type transportBits = number; export interface AllSetting { datepicker: string; + discordAdminIds: string; + discordBotBackup: boolean; + discordBotEnable: boolean; + discordBotToken: string; + discordChannelId: string; + discordCpu: number; + discordEnabledEvents: string; + discordLang: string; + discordMemory: number; + discordRunTime: string; expireDiff: number; externalTrafficInformEnable: boolean; externalTrafficInformURI: string; @@ -146,11 +156,22 @@ export interface AllSetting { export interface AllSettingView { datepicker: string; + discordAdminIds: string; + discordBotBackup: boolean; + discordBotEnable: boolean; + discordBotToken: string; + discordChannelId: string; + discordCpu: number; + discordEnabledEvents: string; + discordLang: string; + discordMemory: number; + discordRunTime: string; expireDiff: number; externalTrafficInformEnable: boolean; externalTrafficInformURI: string; happLinkEnable: boolean; hasApiToken: boolean; + hasDiscordBotToken: boolean; hasLdapPassword: boolean; hasNordSecret: boolean; hasSmtpPassword: boolean; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index b7ddae1c5..5f1414b50 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -23,6 +23,16 @@ export type transportBits = z.infer; export const AllSettingSchema = z.object({ datepicker: z.string(), + discordAdminIds: z.string(), + discordBotBackup: z.boolean(), + discordBotEnable: z.boolean(), + discordBotToken: z.string(), + discordChannelId: z.string(), + discordCpu: z.number().int().min(0).max(100), + discordEnabledEvents: z.string(), + discordLang: z.string(), + discordMemory: z.number().int().min(0).max(100), + discordRunTime: z.string(), expireDiff: z.number().int().min(0), externalTrafficInformEnable: z.boolean(), externalTrafficInformURI: z.string(), @@ -161,11 +171,22 @@ export type AllSetting = z.infer; export const AllSettingViewSchema = z.object({ datepicker: z.string(), + discordAdminIds: z.string(), + discordBotBackup: z.boolean(), + discordBotEnable: z.boolean(), + discordBotToken: z.string(), + discordChannelId: z.string(), + discordCpu: z.number().int().min(0).max(100), + discordEnabledEvents: z.string(), + discordLang: z.string(), + discordMemory: z.number().int().min(0).max(100), + discordRunTime: z.string(), expireDiff: z.number().int().min(0), externalTrafficInformEnable: z.boolean(), externalTrafficInformURI: z.string(), happLinkEnable: z.boolean(), hasApiToken: z.boolean(), + hasDiscordBotToken: z.boolean(), hasLdapPassword: z.boolean(), hasNordSecret: z.boolean(), hasSmtpPassword: z.boolean(), diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index f3c668c14..a86dfe11c 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -13,6 +13,7 @@ import { CodeOutlined, DashboardOutlined, DatabaseOutlined, + DiscordOutlined, ExportOutlined, GithubOutlined, GlobalOutlined, @@ -257,6 +258,11 @@ export default function AppSidebar() { label: t('pages.settings.TGBotSettings'), }, { key: '/settings#email', icon: , label: t('pages.settings.emailSettings') }, + { + key: '/settings#discord', + icon: , + label: t('pages.settings.discordSettings'), + }, { key: '/settings#subscription', icon: , diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index 6e70a0161..c5dcfae25 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -147,6 +147,18 @@ export class AllSetting { clearTgBotToken = false; clearLdapPassword = false; clearSmtpPassword = false; + discordBotEnable = false; + discordBotToken = ''; + discordChannelId = ''; + discordAdminIds = ''; + discordRunTime = '@daily'; + discordBotBackup = false; + discordCpu = 80; + discordMemory = 80; + discordLang = 'en-US'; + discordEnabledEvents = 'login.attempt,cpu.high'; + hasDiscordBotToken = false; + clearDiscordBotToken = false; constructor(data?: unknown) { if (data != null) { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index e1d44ad2f..f45af4a89 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1917,6 +1917,12 @@ export const sections: readonly Section[] = [ summary: 'Test Telegram bot connection by sending a test message to the configured chat.', response: '{\n "success": true,\n "msg": "Test message sent to Telegram"\n}', }, + { + method: 'POST', + path: '/panel/api/setting/testDiscord', + summary: 'Test Discord bot connection by sending a test embed to the configured channel.', + response: '{\n "success": true,\n "msg": "Test notification sent successfully"\n}', + }, { method: 'GET', path: '/panel/api/setting/getDefaultJsonConfig', diff --git a/frontend/src/pages/settings/DiscordTab.tsx b/frontend/src/pages/settings/DiscordTab.tsx new file mode 100644 index 000000000..e12f5e0ba --- /dev/null +++ b/frontend/src/pages/settings/DiscordTab.tsx @@ -0,0 +1,201 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Button, Input, Select, Space, Switch, Tabs } from 'antd'; +import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons'; +import { HttpUtil, LanguageManager } from '@/utils'; +import type { AllSetting } from '@/models/setting'; +import { SettingListItem } from '@/components/ui'; +import { DiscordNotifications } from '@/components/ui/notifications/DiscordNotifications'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { catTabLabel } from './catTabLabel'; +import { NotifyTimeField } from './NotifyTimeField'; +import SecretInput from './SecretInput'; + +interface DiscordTabProps { + allSetting: AllSetting; + updateSetting: (patch: Partial) => void; +} + +interface DiscordTestResult { + success: boolean; + msg: string; +} + +export default function DiscordTab({ allSetting, updateSetting }: DiscordTabProps) { + const { t } = useTranslation(); + const { isMobile } = useMediaQuery(); + const [testLoading, setTestLoading] = useState(false); + const [testResult, setTestResult] = useState(null); + + async function handleTestDiscord() { + setTestLoading(true); + setTestResult(null); + try { + const res = (await HttpUtil.post('/panel/api/setting/testDiscord')) as DiscordTestResult; + setTestResult(res); + } catch (e: unknown) { + setTestResult({ + success: false, + msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'), + }); + } finally { + setTestLoading(false); + } + } + + const langOptions = useMemo( + () => + LanguageManager.supportedLanguages.map( + (l: { value: string; name: string; icon: string }) => ({ + value: l.value, + label: ( + <> + + {l.icon} + +   {l.name} + + ), + }), + ), + [], + ); + + return ( + , t('pages.settings.panelSettings'), isMobile), + children: ( + <> + + updateSetting({ discordBotEnable: v })} + /> + + + + updateSetting({ discordBotToken: v })} + onClearArmedChange={(armed) => updateSetting({ clearDiscordBotToken: armed })} + /> + + + + updateSetting({ discordChannelId: e.target.value })} + style={{ width: '100%' }} + /> + + + + updateSetting({ discordAdminIds: e.target.value })} + style={{ width: '100%' }} + /> + + + + update({ custom: e.target.value })} + aria-label={t('pages.settings.notifyTime.custom')} + /> + )} + + ); +} diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index d8b1bba3d..2cbd20aa6 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -27,6 +27,7 @@ import GeneralTab from './GeneralTab'; import SecurityTab from './SecurityTab'; import TelegramTab from './TelegramTab'; import EmailTab from './EmailTab'; +import DiscordTab from './DiscordTab'; import SubscriptionGeneralTab from './SubscriptionGeneralTab'; import SubscriptionFormatsTab from './SubscriptionFormatsTab'; import SubscriptionBalancersTab from './SubscriptionBalancersTab'; @@ -41,6 +42,7 @@ const tabSlugs = [ 'security', 'telegram', 'email', + 'discord', 'subscription', 'subscription-formats', 'subscription-balancers', @@ -217,6 +219,8 @@ export default function SettingsPage() { return ; case 'email': return ; + case 'discord': + return ; case 'subscription': return ; case 'subscription-formats': diff --git a/frontend/src/pages/settings/TelegramTab.tsx b/frontend/src/pages/settings/TelegramTab.tsx index 66445f900..bcb2e0215 100644 --- a/frontend/src/pages/settings/TelegramTab.tsx +++ b/frontend/src/pages/settings/TelegramTab.tsx @@ -1,15 +1,14 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd'; +import { Alert, Button, Input, Select, Space, Switch, Tabs } from 'antd'; import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons'; -import { LanguageManager } from '@/utils'; -import { HttpUtil } from '@/utils'; -import { onNumber } from '@/utils/onNumber'; +import { HttpUtil, LanguageManager } from '@/utils'; import type { AllSetting } from '@/models/setting'; import { SettingListItem } from '@/components/ui'; import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; +import { NotifyTimeField } from './NotifyTimeField'; import SecretInput from './SecretInput'; interface TelegramTabProps { @@ -17,149 +16,6 @@ interface TelegramTabProps { updateSetting: (patch: Partial) => void; } -// The notification schedule is fed straight to robfig/cron's AddJob (see -// web.go startTask), which accepts @every , the @hourly/@daily/... -// macros, and full crontab expressions. This builder covers the common cases -// with dropdowns so users don't have to memorise the syntax, while "Custom" -// preserves the raw crontab escape hatch. -type Unit = 's' | 'm' | 'h'; -type Macro = '@hourly' | '@daily' | '@weekly' | '@monthly'; -type Mode = 'every' | Macro | 'custom'; -const MACROS: Macro[] = ['@hourly', '@daily', '@weekly', '@monthly']; -const EVERY_RE = /^@every\s+(\d+)\s*([smh])$/i; - -interface RunTime { - mode: Mode; - num: number; - unit: Unit; - custom: string; -} - -function parseRunTime(raw: string): RunTime { - const v = (raw ?? '').trim(); - const m = v.match(EVERY_RE); - if (m) { - return { - mode: 'every', - num: Math.max(1, Number(m[1]) || 1), - unit: m[2].toLowerCase() as Unit, - custom: '', - }; - } - if ((MACROS as string[]).includes(v)) { - return { mode: v as Macro, num: 1, unit: 'h', custom: '' }; - } - return { mode: 'custom', num: 1, unit: 'h', custom: v }; -} - -function composeRunTime(s: RunTime): string { - if (s.mode === 'every') return `@every ${Math.max(1, s.num || 1)}${s.unit}`; - if (s.mode === 'custom') return s.custom; - return s.mode; -} - -// The panel's cron runs with seconds enabled (cron.WithSeconds() in web.go), so -// crontab expressions are 6-field: "second minute hour day month weekday". When -// the user drops into Custom we seed the box with the crontab equivalent of the -// current selection rather than a bare @macro, so they get a real expression to -// edit (and one that the 6-field parser accepts). -function toCrontab(s: RunTime): string { - switch (s.mode) { - case '@hourly': - return '0 0 * * * *'; - case '@daily': - return '0 0 0 * * *'; - case '@weekly': - return '0 0 0 * * 0'; - case '@monthly': - return '0 0 0 1 * *'; - case 'every': { - const n = Math.max(1, s.num || 1); - if (s.unit === 's') return `*/${n} * * * * *`; - if (s.unit === 'm') return `0 */${n} * * * *`; - return `0 0 */${n} * * *`; - } - default: - return s.custom; - } -} - -function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: string) => void }) { - const { t } = useTranslation(); - // Init once: the Settings tabs only mount after settings are fetched, so the - // incoming value is already the persisted one. - const [state, setState] = useState(() => parseRunTime(value)); - - function update(patch: Partial) { - const next = { ...state, ...patch }; - setState(next); - onChange(composeRunTime(next)); - } - - function onModeChange(mode: Mode) { - // Seed Custom with the crontab equivalent of the current selection so the - // box starts from a real expression (e.g. "0 0 0 * * *", not "@daily"). - if (mode === 'custom' && !state.custom.trim()) { - update({ mode, custom: toCrontab(state) }); - } else { - update({ mode }); - } - } - - const modeOptions = [ - { value: 'every', label: t('pages.settings.notifyTime.every') }, - { value: '@hourly', label: t('pages.settings.notifyTime.hourly') }, - { value: '@daily', label: t('pages.settings.notifyTime.daily') }, - { value: '@weekly', label: t('pages.settings.notifyTime.weekly') }, - { value: '@monthly', label: t('pages.settings.notifyTime.monthly') }, - { value: 'custom', label: t('pages.settings.notifyTime.custom') }, - ]; - const unitOptions = [ - { value: 's', label: t('pages.settings.notifyTime.seconds') }, - { value: 'm', label: t('pages.settings.notifyTime.minutes') }, - { value: 'h', label: t('pages.settings.notifyTime.hours') }, - ]; - - return ( - - - style={{ width: '100%' }} - value={state.mode} - options={modeOptions} - onChange={onModeChange} - aria-label={t('pages.settings.telegramNotifyTime')} - /> - {state.mode === 'every' && ( - - update({ num: Math.max(1, v) }))} - aria-label={t('pages.settings.notifyTime.interval')} - /> - - style={{ width: '50%' }} - value={state.unit} - options={unitOptions} - onChange={(unit) => update({ unit })} - aria-label={t('pages.settings.notifyTime.unit')} - /> - - )} - {state.mode === 'custom' && ( - update({ custom: e.target.value })} - aria-label={t('pages.settings.notifyTime.custom')} - /> - )} - - ); -} - export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) { const { t } = useTranslation(); const { isMobile } = useMediaQuery(); diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts index aaec0be29..5d10c8288 100644 --- a/frontend/src/schemas/setting.ts +++ b/frontend/src/schemas/setting.ts @@ -132,6 +132,17 @@ export const AllSettingSchema = z hasWarpSecret: z.boolean().optional(), hasNordSecret: z.boolean().optional(), hasSmtpPassword: z.boolean().optional(), + hasDiscordBotToken: z.boolean().optional(), + discordBotEnable: z.boolean().optional(), + discordBotToken: z.string().optional(), + discordChannelId: z.string().optional(), + discordAdminIds: z.string().optional(), + discordRunTime: z.string().optional(), + discordBotBackup: z.boolean().optional(), + discordCpu: z.number().int().min(0).max(100).optional(), + discordMemory: z.number().int().min(0).max(100).optional(), + discordLang: z.string().optional(), + discordEnabledEvents: z.string().optional(), }) .loose(); diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index 74f352580..07cb06bc4 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -12,6 +12,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/entity" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/discord" "github.com/mhsanaei/3x-ui/v3/internal/web/service/email" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" "github.com/mhsanaei/3x-ui/v3/internal/web/session" @@ -34,10 +35,11 @@ type updateUserForm struct { // "unchanged", so clearing needs its own signal — see #5724). type updateSettingForm struct { entity.AllSetting - TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` - ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"` - ClearLdapPassword bool `json:"clearLdapPassword" form:"clearLdapPassword"` - ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"` + TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` + ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"` + ClearLdapPassword bool `json:"clearLdapPassword" form:"clearLdapPassword"` + ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"` + ClearDiscordBotToken bool `json:"clearDiscordBotToken" form:"clearDiscordBotToken"` } type validateRegexForm struct { @@ -78,6 +80,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) { g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled) g.POST("/testSmtp", a.testSmtp) g.POST("/testTgBot", a.testTgBot) + g.POST("/testDiscord", a.testDiscord) } func (a *SettingController) validateRegex(c *gin.Context) { @@ -131,6 +134,10 @@ func (a *SettingController) updateSetting(c *gin.Context) { oldTgToken, _ := a.settingService.GetTgBotToken() oldTgChatId, _ := a.settingService.GetTgBotChatId() oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer() + oldDiscordEnable, _ := a.settingService.GetDiscordBotEnable() + oldDiscordToken, _ := a.settingService.GetDiscordBotToken() + oldDiscordChannelId, _ := a.settingService.GetDiscordChannelId() + oldDiscordRunTime, _ := a.settingService.GetDiscordRunTime() if twoFactorErr == nil && oldTwoFactor { // Rebinding the authenticator is the same class of change as turning 2FA // off, so both need a current code. Blank still means "unchanged". @@ -144,9 +151,10 @@ func (a *SettingController) updateSetting(c *gin.Context) { } } err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{ - TgBotToken: form.ClearTgBotToken, - LdapPassword: form.ClearLdapPassword, - SmtpPassword: form.ClearSmtpPassword, + TgBotToken: form.ClearTgBotToken, + LdapPassword: form.ClearLdapPassword, + SmtpPassword: form.ClearSmtpPassword, + DiscordBotToken: form.ClearDiscordBotToken, }) if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable { if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil { @@ -171,6 +179,15 @@ func (a *SettingController) updateSetting(c *gin.Context) { reloadTgbotFunc() } } + if err == nil && reloadDiscordFunc != nil { + discordChanged := oldDiscordEnable != allSetting.DiscordBotEnable || + oldDiscordRunTime != allSetting.DiscordRunTime || + (allSetting.DiscordBotEnable && (oldDiscordToken != allSetting.DiscordBotToken || + oldDiscordChannelId != allSetting.DiscordChannelId)) + if discordChanged { + reloadDiscordFunc() + } + } jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) } @@ -347,3 +364,31 @@ var emailService *email.EmailService // SetEmailService registers the email service for test endpoints. func SetEmailService(s *email.EmailService) { emailService = s } + +// reloadDiscordFunc is wired from the web layer to reschedule or cancel Discord notify job. +var reloadDiscordFunc func() + +func SetReloadDiscordFunc(fn func()) { reloadDiscordFunc = fn } + +// discordService is set from web layer. +var discordService *discord.DiscordService + +// SetDiscordService registers the Discord service for test endpoints. +func SetDiscordService(s *discord.DiscordService) { discordService = s } + +func (a *SettingController) testDiscord(c *gin.Context) { + if discordService == nil { + jsonMsg(c, I18nWeb(c, "pages.settings.discordNotInitialized"), errors.New("discord service not available")) + return + } + enabled, err := a.settingService.GetDiscordBotEnable() + if err != nil || !enabled { + jsonMsg(c, I18nWeb(c, "pages.settings.discordBotNotEnabled"), errors.New("discord bot disabled")) + return + } + if err := discordService.SendTest(c.Request.Context()); err != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.discordTestFailed")+": "+err.Error(), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.settings.discordTestSuccess"), nil) +} diff --git a/internal/web/controller/setting_test.go b/internal/web/controller/setting_test.go index 88ae236e0..2b9f5ee0f 100644 --- a/internal/web/controller/setting_test.go +++ b/internal/web/controller/setting_test.go @@ -14,7 +14,9 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/discord" ) func TestValidateRegex(t *testing.T) { @@ -170,3 +172,78 @@ func TestUpdateSettingRequiresCodeToReplaceTwoFactorToken(t *testing.T) { } }) } + +func TestTestDiscordEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + + // 1. Service not initialized + SetDiscordService(nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key }) + c.Next() + }) + NewSettingController(router.Group("/panel/api")) + + req := httptest.NewRequest(http.MethodPost, "/panel/api/setting/testDiscord", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if !strings.Contains(resp.Body.String(), `"success":false`) || !strings.Contains(resp.Body.String(), "pages.settings.discordNotInitialized") { + t.Fatalf("expected uninitialized error, got %s", resp.Body.String()) + } + + // Setup DB + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { + _ = database.CloseDB() + SetDiscordService(nil) + }) + + settingService := service.SettingService{} + svc := discord.NewDiscordService(settingService) + SetDiscordService(svc) + + // 2. Discord bot disabled + _ = settingService.SetDiscordBotEnable(false) + req = httptest.NewRequest(http.MethodPost, "/panel/api/setting/testDiscord", nil) + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if !strings.Contains(resp.Body.String(), `"success":false`) || !strings.Contains(resp.Body.String(), "pages.settings.discordBotNotEnabled") { + t.Fatalf("expected disabled error, got %s", resp.Body.String()) + } + + // 3. Discord bot enabled but missing config + _ = settingService.SetDiscordBotEnable(true) + req = httptest.NewRequest(http.MethodPost, "/panel/api/setting/testDiscord", nil) + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if !strings.Contains(resp.Body.String(), `"success":false`) || !strings.Contains(resp.Body.String(), "pages.settings.discordTestFailed") { + t.Fatalf("expected send failure error, got %s", resp.Body.String()) + } + + // 4. Discord bot enabled with working server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id": "msg-1"}`)) + })) + defer server.Close() + + _ = settingService.SetDiscordBotToken("test-bot-token") + _ = settingService.SetDiscordChannelId("123456789") + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + req = httptest.NewRequest(http.MethodPost, "/panel/api/setting/testDiscord", nil) + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if !strings.Contains(resp.Body.String(), `"success":true`) || !strings.Contains(resp.Body.String(), "pages.settings.discordTestSuccess") { + t.Fatalf("expected success, got %s", resp.Body.String()) + } +} diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index 4e4082d30..f8f34f100 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -65,6 +65,17 @@ type AllSetting struct { SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"` SmtpMemory int `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"` + DiscordBotEnable bool `json:"discordBotEnable" form:"discordBotEnable"` + DiscordBotToken string `json:"discordBotToken" form:"discordBotToken"` + DiscordChannelId string `json:"discordChannelId" form:"discordChannelId"` + DiscordAdminIds string `json:"discordAdminIds" form:"discordAdminIds"` + DiscordRunTime string `json:"discordRunTime" form:"discordRunTime"` + DiscordBotBackup bool `json:"discordBotBackup" form:"discordBotBackup"` + DiscordCpu int `json:"discordCpu" form:"discordCpu" validate:"gte=0,lte=100"` + DiscordMemory int `json:"discordMemory" form:"discordMemory" validate:"gte=0,lte=100"` + DiscordLang string `json:"discordLang" form:"discordLang"` + DiscordEnabledEvents string `json:"discordEnabledEvents" form:"discordEnabledEvents"` + OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"` TimeLocation string `json:"timeLocation" form:"timeLocation"` @@ -168,13 +179,14 @@ type AllSetting struct { type AllSettingView struct { AllSetting - HasTgBotToken bool `json:"hasTgBotToken"` - HasTwoFactorToken bool `json:"hasTwoFactorToken"` - HasLdapPassword bool `json:"hasLdapPassword"` - HasApiToken bool `json:"hasApiToken"` - HasWarpSecret bool `json:"hasWarpSecret"` - HasNordSecret bool `json:"hasNordSecret"` - HasSmtpPassword bool `json:"hasSmtpPassword"` + HasTgBotToken bool `json:"hasTgBotToken"` + HasTwoFactorToken bool `json:"hasTwoFactorToken"` + HasLdapPassword bool `json:"hasLdapPassword"` + HasApiToken bool `json:"hasApiToken"` + HasWarpSecret bool `json:"hasWarpSecret"` + HasNordSecret bool `json:"hasNordSecret"` + HasSmtpPassword bool `json:"hasSmtpPassword"` + HasDiscordBotToken bool `json:"hasDiscordBotToken"` } func pathHasForbiddenChar(s string) bool { diff --git a/internal/web/job/discord_notify_job.go b/internal/web/job/discord_notify_job.go new file mode 100644 index 000000000..45ba326f5 --- /dev/null +++ b/internal/web/job/discord_notify_job.go @@ -0,0 +1,47 @@ +package job + +import ( + "context" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/discord" +) + +// DiscordNotifyJob sends periodic status reports and database backups via Discord bot. +type DiscordNotifyJob struct { + xrayService service.XrayService + serverService service.ServerService + inboundService service.InboundService + settingService service.SettingService + discordService *discord.DiscordService +} + +// NewDiscordNotifyJob creates a new Discord notification job instance. +func NewDiscordNotifyJob(discordService *discord.DiscordService) *DiscordNotifyJob { + return &DiscordNotifyJob{ + discordService: discordService, + } +} + +// Run executes the periodic status report if Discord bot is enabled and Xray is running. +func (j *DiscordNotifyJob) Run() { + if j.discordService == nil { + return + } + enabled, err := j.settingService.GetDiscordBotEnable() + if err != nil || !enabled { + return + } + if !j.xrayService.IsXrayRunning() { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if err := j.discordService.SendReport(ctx, &j.serverService, &j.inboundService); err != nil { + logger.Warning("DiscordNotifyJob: failed to send status report: ", err) + } +} diff --git a/internal/web/locale/locale.go b/internal/web/locale/locale.go index 95e854b2f..c3a0a0c37 100644 --- a/internal/web/locale/locale.go +++ b/internal/web/locale/locale.go @@ -3,7 +3,6 @@ package locale import ( - "embed" "encoding/json" "io/fs" "os" @@ -36,7 +35,7 @@ type SettingService interface { } // InitLocalizer initializes the internationalization system with embedded translation files. -func InitLocalizer(i18nFS embed.FS, settingService SettingService) error { +func InitLocalizer(i18nFS fs.FS, settingService SettingService) error { // set default bundle to English i18nBundle = i18n.NewBundle(language.MustParse("en-US")) i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal) @@ -105,6 +104,31 @@ func I18n(i18nType I18nType, key string, params ...string) string { return msg } +// LocalizerFor returns a new Localizer for the given language tag using the global bundle. +func LocalizerFor(lang string) *i18n.Localizer { + if i18nBundle == nil { + return nil + } + return i18n.NewLocalizer(i18nBundle, lang) +} + +// I18nForLang retrieves a localized message for a specific language tag with optional params. +func I18nForLang(lang string, key string, params ...string) string { + loc := LocalizerFor(lang) + if loc == nil { + return key + } + templateData := createTemplateData(params) + msg, err := loc.Localize(&i18n.LocalizeConfig{ + MessageID: key, + TemplateData: templateData, + }) + if err != nil { + return key + } + return msg +} + // initTGBotLocalizer initializes the bot localizer with the configured language. func initTGBotLocalizer(settingService SettingService) error { botLang, err := settingService.GetTgLang() @@ -167,7 +191,7 @@ func loadTranslationsFromDisk(bundle *i18n.Bundle) error { } // parseTranslationFiles parses embedded translation files and adds them to the i18n bundle. -func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error { +func parseTranslationFiles(i18nFS fs.FS, i18nBundle *i18n.Bundle) error { err := fs.WalkDir(i18nFS, "translation", func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -178,7 +202,7 @@ func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error { return nil } - data, err := i18nFS.ReadFile(path) + data, err := fs.ReadFile(i18nFS, path) if err != nil { return err } diff --git a/internal/web/service/discord/discord.go b/internal/web/service/discord/discord.go new file mode 100644 index 000000000..19f296470 --- /dev/null +++ b/internal/web/service/discord/discord.go @@ -0,0 +1,275 @@ +package discord + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) + +const ( + defaultDiscordBaseURL = "https://discord.com/api/v10" + discordUserAgent = "DiscordBot (https://github.com/mhsanaei/3x-ui, 3.x)" + + ColorGreen = 0x2ECC71 + ColorRed = 0xE74C3C + ColorOrange = 0xF39C12 + ColorBlue = 0x3498DB +) + +// FileAttachment represents a file attachment to be uploaded with a Discord message. +type FileAttachment struct { + Filename string + Data []byte +} + +// MessagePayload represents the Discord create message payload. +type MessagePayload struct { + Content string `json:"content,omitempty"` + Embeds []Embed `json:"embeds,omitempty"` +} + +// Embed represents a Discord embed object. +type Embed struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Color int `json:"color,omitempty"` + Fields []EmbedField `json:"fields,omitempty"` + Footer *EmbedFooter `json:"footer,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +// EmbedField represents a field in a Discord embed. +type EmbedField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline,omitempty"` +} + +// EmbedFooter represents a footer in a Discord embed. +type EmbedFooter struct { + Text string `json:"text"` +} + +// DiscordService manages communication with the Discord API. +type DiscordService struct { + settingService service.SettingService + httpClient *http.Client + baseURL string +} + +// NewDiscordService creates a new DiscordService. +func NewDiscordService(settingService service.SettingService) *DiscordService { + return &DiscordService{ + settingService: settingService, + baseURL: defaultDiscordBaseURL, + } +} + +// SetHTTPClient sets a custom HTTP client (useful for unit testing). +func (s *DiscordService) SetHTTPClient(client *http.Client) { + s.httpClient = client +} + +// SetBaseURL sets a custom base URL for the Discord API (useful for testing with httptest). +func (s *DiscordService) SetBaseURL(url string) { + s.baseURL = strings.TrimRight(url, "/") +} + +func (s *DiscordService) getClient() *http.Client { + if s.httpClient != nil { + return s.httpClient + } + return s.settingService.NewProxiedHTTPClient(10 * time.Second) +} + +func (s *DiscordService) getBaseURL() string { + if s.baseURL != "" { + return s.baseURL + } + return defaultDiscordBaseURL +} + +func (s *DiscordService) authCredentials() (token string, channelID string, err error) { + rawToken, err := s.settingService.GetDiscordBotToken() + if err != nil || strings.TrimSpace(rawToken) == "" { + return "", "", errors.New("discord bot token is not configured") + } + rawChannel, err := s.settingService.GetDiscordChannelId() + if err != nil || strings.TrimSpace(rawChannel) == "" { + return "", "", errors.New("discord channel id is not configured") + } + + cleanToken := strings.TrimSpace(rawToken) + cleanToken = strings.TrimPrefix(cleanToken, "Bot ") + cleanToken = strings.TrimSpace(cleanToken) + if cleanToken == "" { + return "", "", errors.New("discord bot token is not configured") + } + return cleanToken, strings.TrimSpace(rawChannel), nil +} + +func parseDiscordResponse(resp *http.Response) error { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + bodyStr := string(respBody) + + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated, http.StatusNoContent: + return nil + case http.StatusBadRequest: + return fmt.Errorf("discord bad request (400): %s", bodyStr) + case http.StatusUnauthorized: + return errors.New("discord unauthorized (401): invalid bot token") + case http.StatusForbidden: + return errors.New("discord forbidden (403): bot lacks permissions for channel") + case http.StatusNotFound: + return errors.New("discord not found (404): channel not found") + case http.StatusTooManyRequests: + return fmt.Errorf("discord rate limited (429): %s", bodyStr) + default: + return fmt.Errorf("discord API error (%d): %s", resp.StatusCode, bodyStr) + } +} + +// SendMessage sends a Discord message payload to the configured channel. +func (s *DiscordService) SendMessage(ctx context.Context, payload MessagePayload) error { + if ctx == nil { + ctx = context.Background() + } + cleanToken, channelID, err := s.authCredentials() + if err != nil { + return err + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal discord payload: %w", err) + } + + endpoint := fmt.Sprintf("%s/channels/%s/messages", s.getBaseURL(), channelID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("create discord request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bot "+cleanToken) + req.Header.Set("User-Agent", discordUserAgent) + + resp, err := s.getClient().Do(req) + if err != nil { + return fmt.Errorf("discord request failed: %w", err) + } + defer resp.Body.Close() + + return parseDiscordResponse(resp) +} + +// SendMessageWithFiles sends a Discord message payload with optional file attachments using multipart/form-data. +func (s *DiscordService) SendMessageWithFiles(ctx context.Context, payload MessagePayload, files ...FileAttachment) error { + if len(files) == 0 { + return s.SendMessage(ctx, payload) + } + if ctx == nil { + ctx = context.Background() + } + cleanToken, channelID, err := s.authCredentials() + if err != nil { + return err + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal discord payload: %w", err) + } + + if err := writer.WriteField("payload_json", string(payloadBytes)); err != nil { + return fmt.Errorf("write payload_json: %w", err) + } + + for i, file := range files { + part, err := writer.CreateFormFile(fmt.Sprintf("files[%d]", i), file.Filename) + if err != nil { + return fmt.Errorf("create form file part %d: %w", i, err) + } + if _, err := part.Write(file.Data); err != nil { + return fmt.Errorf("write form file part %d: %w", i, err) + } + } + + if err := writer.Close(); err != nil { + return fmt.Errorf("close multipart writer: %w", err) + } + + endpoint := fmt.Sprintf("%s/channels/%s/messages", s.getBaseURL(), channelID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) + if err != nil { + return fmt.Errorf("create discord request: %w", err) + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bot "+cleanToken) + req.Header.Set("User-Agent", discordUserAgent) + + resp, err := s.getClient().Do(req) + if err != nil { + return fmt.Errorf("discord request failed: %w", err) + } + defer resp.Body.Close() + + return parseDiscordResponse(resp) +} + +// SendEmbed is a helper to send an embed payload. +func (s *DiscordService) SendEmbed(ctx context.Context, embed Embed) error { + return s.SendMessage(ctx, MessagePayload{ + Embeds: []Embed{embed}, + }) +} + +// translator renders messages in the configured Discord bot language, read once per message. +func translator(settingService service.SettingService) func(key string, params ...string) string { + lang, err := settingService.GetDiscordLang() + if err != nil || lang == "" { + lang = "en-US" + } + return func(key string, params ...string) string { + return locale.I18nForLang(lang, key, params...) + } +} + +// SendTest sends a test embed to verify Discord bot configuration. +func (s *DiscordService) SendTest(ctx context.Context) error { + tr := translator(s.settingService) + now := time.Now().UTC().Format(time.RFC3339) + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "3x-ui" + } + embed := Embed{ + Title: tr("discord.test.title"), + Description: tr("discord.test.body"), + Color: ColorGreen, + Timestamp: now, + Fields: []EmbedField{ + {Name: tr("host"), Value: hostname, Inline: true}, + }, + Footer: &EmbedFooter{ + Text: tr("discord.footer"), + }, + } + return s.SendEmbed(ctx, embed) +} diff --git a/internal/web/service/discord/discord_test.go b/internal/web/service/discord/discord_test.go new file mode 100644 index 000000000..104c2a63d --- /dev/null +++ b/internal/web/service/discord/discord_test.go @@ -0,0 +1,355 @@ +package discord + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) + +func setupTestDB(t *testing.T) service.SettingService { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "x-ui.db") + if err := database.InitDB(dbPath); err != nil { + t.Fatalf("init db: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + return service.SettingService{} +} + +func TestSendMessage_Success(t *testing.T) { + settingService := setupTestDB(t) + if err := settingService.SetDiscordBotToken("test-bot-token"); err != nil { + t.Fatal(err) + } + if err := settingService.SetDiscordChannelId("123456789012345678"); err != nil { + t.Fatal(err) + } + + var reqMethod, reqPath, reqAuth, reqUA, reqCT string + var reqPayload MessagePayload + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqMethod = r.Method + reqPath = r.URL.Path + reqAuth = r.Header.Get("Authorization") + reqUA = r.Header.Get("User-Agent") + reqCT = r.Header.Get("Content-Type") + + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &reqPayload) + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id": "msg-123"}`)) + })) + defer server.Close() + + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + payload := MessagePayload{ + Content: "Hello Discord!", + Embeds: []Embed{ + { + Title: "Test Embed", + Description: "Desc", + Color: ColorGreen, + }, + }, + } + + if err := svc.SendMessage(context.Background(), payload); err != nil { + t.Fatalf("SendMessage failed: %v", err) + } + + if reqMethod != http.MethodPost { + t.Errorf("expected POST, got %s", reqMethod) + } + expectedPath := "/channels/123456789012345678/messages" + if reqPath != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, reqPath) + } + if reqAuth != "Bot test-bot-token" { + t.Errorf("expected auth 'Bot test-bot-token', got %s", reqAuth) + } + if reqUA != discordUserAgent { + t.Errorf("expected User-Agent %s, got %s", discordUserAgent, reqUA) + } + if reqCT != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", reqCT) + } + if reqPayload.Content != "Hello Discord!" || len(reqPayload.Embeds) != 1 { + t.Errorf("payload mismatch: %+v", reqPayload) + } +} + +func TestSendMessage_CreatedStatus(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("token") + _ = settingService.SetDiscordChannelId("ch-1") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + if err := svc.SendEmbed(context.Background(), Embed{Title: "Title"}); err != nil { + t.Fatalf("SendEmbed failed: %v", err) + } +} + +func TestSendMessage_StatusCodes(t *testing.T) { + cases := []struct { + name string + statusCode int + respBody string + wantErrSub string + }{ + {"Bad Request", http.StatusBadRequest, `{"message": "Invalid Form Body"}`, "discord bad request (400)"}, + {"Unauthorized", http.StatusUnauthorized, `{"message": "401: Unauthorized"}`, "discord unauthorized (401)"}, + {"Forbidden", http.StatusForbidden, `{"message": "Missing Permissions"}`, "discord forbidden (403)"}, + {"NotFound", http.StatusNotFound, `{"message": "Unknown Channel"}`, "discord not found (404)"}, + {"RateLimited", http.StatusTooManyRequests, `{"retry_after": 1.5}`, "discord rate limited (429)"}, + {"InternalError", http.StatusInternalServerError, `{"message": "Server Error"}`, "discord API error (500)"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("token") + _ = settingService.SetDiscordChannelId("ch-1") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.statusCode) + _, _ = w.Write([]byte(tc.respBody)) + })) + defer server.Close() + + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + err := svc.SendEmbed(context.Background(), Embed{Title: "Test"}) + if err == nil { + t.Fatalf("expected error for status %d, got nil", tc.statusCode) + } + if !strings.Contains(err.Error(), tc.wantErrSub) { + t.Errorf("expected error containing %q, got %q", tc.wantErrSub, err.Error()) + } + }) + } +} + +func TestSendMessage_MissingConfig(t *testing.T) { + settingService := setupTestDB(t) + svc := NewDiscordService(settingService) + + // Both empty + err := svc.SendMessage(context.Background(), MessagePayload{Content: "Hi"}) + if err == nil || !strings.Contains(err.Error(), "token is not configured") { + t.Fatalf("expected token not configured error, got %v", err) + } + + // Token set, channel empty + _ = settingService.SetDiscordBotToken("some-token") + err = svc.SendMessage(context.Background(), MessagePayload{Content: "Hi"}) + if err == nil || !strings.Contains(err.Error(), "channel id is not configured") { + t.Fatalf("expected channel id not configured error, got %v", err) + } +} + +func TestSendTest(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("test-bot-token") + _ = settingService.SetDiscordChannelId("999888777") + + var receivedPayload MessagePayload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &receivedPayload) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + if err := svc.SendTest(context.Background()); err != nil { + t.Fatalf("SendTest failed: %v", err) + } + + if len(receivedPayload.Embeds) != 1 { + t.Fatalf("expected 1 embed, got %d", len(receivedPayload.Embeds)) + } + + embed := receivedPayload.Embeds[0] + if embed.Color != ColorGreen { + t.Errorf("expected ColorGreen (0x%X), got 0x%X", ColorGreen, embed.Color) + } + if embed.Timestamp == "" { + t.Error("expected non-empty timestamp") + } else { + parsed, err := time.Parse(time.RFC3339, embed.Timestamp) + if err != nil { + t.Errorf("timestamp is not RFC3339: %v", err) + } + if parsed.Location() != time.UTC { + t.Errorf("expected UTC timestamp location, got %v", parsed.Location()) + } + } + if len(embed.Fields) == 0 { + t.Error("expected test embed to have fields") + } +} + +func TestSendMessage_BotPrefixHandling(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("Bot prefixed-token") + _ = settingService.SetDiscordChannelId("ch-100") + + var receivedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + if err := svc.SendEmbed(context.Background(), Embed{Title: "Prefix Test"}); err != nil { + t.Fatalf("SendEmbed failed: %v", err) + } + if receivedAuth != "Bot prefixed-token" { + t.Errorf("expected 'Bot prefixed-token', got %q", receivedAuth) + } +} + +func TestSendMessage_NoContentStatus(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("token") + _ = settingService.SetDiscordChannelId("ch-204") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + if err := svc.SendEmbed(context.Background(), Embed{Title: "204 Test"}); err != nil { + t.Fatalf("SendEmbed failed for 204: %v", err) + } +} + +func TestSendMessage_ContextCancelled(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("token") + _ = settingService.SetDiscordChannelId("ch-1") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := svc.SendMessage(ctx, MessagePayload{Content: "Cancelled"}) + if err == nil { + t.Fatal("expected error with cancelled context, got nil") + } +} + +func TestSendMessageWithFiles_Success(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("test-bot-token") + _ = settingService.SetDiscordChannelId("ch-multipart") + + var receivedCT string + var receivedPayload MessagePayload + receivedFiles := make(map[string][]byte) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedCT = r.Header.Get("Content-Type") + + mr, err := r.MultipartReader() + if err != nil { + t.Fatalf("MultipartReader error: %v", err) + } + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart error: %v", err) + } + data, _ := io.ReadAll(part) + formName := part.FormName() + if formName == "payload_json" { + _ = json.Unmarshal(data, &receivedPayload) + } else { + receivedFiles[part.FileName()] = data + } + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id": "msg-files"}`)) + })) + defer server.Close() + + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + payload := MessagePayload{ + Content: "Report message", + Embeds: []Embed{{Title: "Report Embed"}}, + } + files := []FileAttachment{ + {Filename: "x-ui.db", Data: []byte("sqlite-db-binary")}, + {Filename: "config.json", Data: []byte(`{"log":{}}`)}, + } + + if err := svc.SendMessageWithFiles(context.Background(), payload, files...); err != nil { + t.Fatalf("SendMessageWithFiles failed: %v", err) + } + + if !strings.HasPrefix(receivedCT, "multipart/form-data; boundary=") { + t.Errorf("expected multipart/form-data content type, got %s", receivedCT) + } + if receivedPayload.Content != "Report message" || len(receivedPayload.Embeds) != 1 { + t.Errorf("payload mismatch: %+v", receivedPayload) + } + if string(receivedFiles["x-ui.db"]) != "sqlite-db-binary" { + t.Errorf("x-ui.db mismatch: %s", string(receivedFiles["x-ui.db"])) + } + if string(receivedFiles["config.json"]) != `{"log":{}}` { + t.Errorf("config.json mismatch: %s", string(receivedFiles["config.json"])) + } +} diff --git a/internal/web/service/discord/gateway.go b/internal/web/service/discord/gateway.go new file mode 100644 index 000000000..b8c1f4d0a --- /dev/null +++ b/internal/web/service/discord/gateway.go @@ -0,0 +1,678 @@ +package discord + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +const ( + defaultGatewayURL = "wss://gateway.discord.gg/?v=10&encoding=json" + + opDispatch = 0 + opHeartbeat = 1 + opIdentify = 2 + opHello = 10 + opHeartbeatACK = 11 + + // GUILDS (1<<0) | GUILD_MESSAGES (1<<9) | DIRECT_MESSAGES (1<<12) | MESSAGE_CONTENT (1<<15) + discordIntents = 37377 +) + +// GatewayPayload represents a Discord Gateway WebSocket frame. +type GatewayPayload struct { + Op int `json:"op"` + D json.RawMessage `json:"d,omitempty"` + S *int64 `json:"s,omitempty"` + T string `json:"t,omitempty"` +} + +// HelloData represents the payload received in Opcode 10 Hello. +type HelloData struct { + HeartbeatInterval int `json:"heartbeat_interval"` +} + +// IdentifyData represents the payload sent in Opcode 2 Identify. +type IdentifyData struct { + Token string `json:"token"` + Intents int `json:"intents"` + Properties IdentifyProperties `json:"properties"` +} + +// IdentifyProperties metadata for Discord identification. +type IdentifyProperties struct { + OS string `json:"os"` + Browser string `json:"browser"` + Device string `json:"device"` +} + +// MessageCreateData represents incoming message data from Discord. +type MessageCreateData struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Content string `json:"content"` + Author struct { + ID string `json:"id"` + Username string `json:"username"` + Bot bool `json:"bot"` + } `json:"author"` +} + +// XrayRestartProvider abstracts restarting the core. +type XrayRestartProvider interface { + RestartXray(force bool) error +} + +// GatewayClient manages the Discord Gateway WebSocket connection for interactive commands. +type GatewayClient struct { + discordService *DiscordService + settingService service.SettingService + serverService ServerProvider + inboundService InboundProvider + xrayService XrayRestartProvider + gatewayURL string + egressProxyURL func() string + + mu sync.Mutex + writeMu sync.Mutex // gorilla panics on concurrent writes; the ticker and op 1 replies both write + conn *websocket.Conn + cancel context.CancelFunc + running bool + lastSeq *int64 +} + +// NewGatewayClient creates a new Discord Gateway client instance. +func NewGatewayClient( + discordService *DiscordService, + settingService service.SettingService, + server ServerProvider, + inbound InboundProvider, + xray XrayRestartProvider, +) *GatewayClient { + return &GatewayClient{ + discordService: discordService, + settingService: settingService, + serverService: server, + inboundService: inbound, + xrayService: xray, + gatewayURL: defaultGatewayURL, + egressProxyURL: settingService.PanelEgressProxyURL, + } +} + +// SetGatewayURL overrides the gateway URL for testing. +func (g *GatewayClient) SetGatewayURL(url string) { + g.gatewayURL = url +} + +// IsRunning reports whether the Gateway client is active. +func (g *GatewayClient) IsRunning() bool { + g.mu.Lock() + defer g.mu.Unlock() + return g.running +} + +// Start begins the Gateway connection and listening loop. +func (g *GatewayClient) Start(parentCtx context.Context) error { + g.mu.Lock() + if g.running { + g.mu.Unlock() + return nil + } + + ctx, cancel := context.WithCancel(parentCtx) + g.cancel = cancel + g.running = true + g.mu.Unlock() + + go func() { + defer func() { + g.mu.Lock() + g.running = false + g.mu.Unlock() + }() + + for { + select { + case <-ctx.Done(): + return + default: + } + + enabled, err := g.settingService.GetDiscordBotEnable() + if err != nil || !enabled { + return + } + + err = g.connectAndListen(ctx) + // Discord marks these close codes non-reconnectable: a bad token or an intent not enabled in the portal. + if websocket.IsCloseError(err, 4004, 4010, 4011, 4012, 4013, 4014) { + logger.Warning("Discord Gateway closed for good: ", err, "; not reconnecting until the bot token changes, the bot is re-enabled or the panel restarts") + return + } + if err != nil && ctx.Err() == nil { + logger.Warning("Discord Gateway disconnected: ", err, "; reconnecting in 5s...") + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + } + } + }() + + return nil +} + +// Stop terminates the Gateway connection cleanly. +func (g *GatewayClient) Stop() { + g.mu.Lock() + defer g.mu.Unlock() + if !g.running { + return + } + if g.cancel != nil { + g.cancel() + } + if g.conn != nil { + _ = g.conn.Close() + } + g.running = false +} + +func (g *GatewayClient) writeJSON(conn *websocket.Conn, v any) error { + g.writeMu.Lock() + defer g.writeMu.Unlock() + return conn.WriteJSON(v) +} + +func (g *GatewayClient) connectAndListen(ctx context.Context) error { + token, err := g.settingService.GetDiscordBotToken() + if err != nil || strings.TrimSpace(token) == "" { + return errors.New("discord bot token not configured") + } + cleanToken := strings.TrimSpace(token) + cleanToken = strings.TrimPrefix(cleanToken, "Bot ") + cleanToken = strings.TrimSpace(cleanToken) + + dialer := *websocket.DefaultDialer + if raw := g.egressProxyURL(); raw != "" { + proxyURL, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parse panel egress proxy: %w", err) + } + dialer.Proxy = http.ProxyURL(proxyURL) + } + conn, resp, err := dialer.DialContext(ctx, g.gatewayURL, nil) + if err != nil { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + return fmt.Errorf("dial discord gateway: %w", err) + } + + g.mu.Lock() + g.conn = conn + g.mu.Unlock() + + defer func() { + _ = conn.Close() + g.mu.Lock() + if g.conn == conn { + g.conn = nil + } + g.mu.Unlock() + }() + + // 1. Read Hello opcode 10 + var helloPayload GatewayPayload + if err := conn.ReadJSON(&helloPayload); err != nil { + return fmt.Errorf("read hello payload: %w", err) + } + if helloPayload.Op != opHello { + return fmt.Errorf("expected opcode 10, got %d", helloPayload.Op) + } + + var helloData HelloData + if err := json.Unmarshal(helloPayload.D, &helloData); err != nil { + return fmt.Errorf("unmarshal hello data: %w", err) + } + + // 2. Send Identify opcode 2 + identifyPayload := GatewayPayload{ + Op: opIdentify, + } + identData := IdentifyData{ + Token: "Bot " + cleanToken, + Intents: discordIntents, + Properties: IdentifyProperties{ + OS: "linux", + Browser: "3x-ui", + Device: "3x-ui", + }, + } + dataBytes, _ := json.Marshal(identData) + identifyPayload.D = dataBytes + + if err := conn.WriteJSON(identifyPayload); err != nil { + return fmt.Errorf("send identify payload: %w", err) + } + + // 3. Heartbeat loop + hbStop := make(chan struct{}) + defer close(hbStop) + + go func() { + interval := time.Duration(helloData.HeartbeatInterval) * time.Millisecond + if interval <= 0 { + interval = 40 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-hbStop: + return + case <-ctx.Done(): + return + case <-ticker.C: + g.mu.Lock() + seq := g.lastSeq + c := g.conn + g.mu.Unlock() + if c == nil { + return + } + hb := GatewayPayload{Op: opHeartbeat} + if seq != nil { + seqBytes, _ := json.Marshal(*seq) + hb.D = seqBytes + } + if err := g.writeJSON(c, hb); err != nil { + logger.Warning("Discord heartbeat write failed: ", err) + return + } + } + } + }() + + // 4. Message dispatch loop + for { + select { + case <-ctx.Done(): + return nil + default: + } + + var payload GatewayPayload + if err := conn.ReadJSON(&payload); err != nil { + return err + } + + if payload.S != nil { + g.mu.Lock() + g.lastSeq = payload.S + g.mu.Unlock() + } + + switch payload.Op { + case opHeartbeatACK: + // Heartbeat acknowledged + case opHeartbeat: + // Discord requested immediate heartbeat + g.mu.Lock() + seq := g.lastSeq + g.mu.Unlock() + hb := GatewayPayload{Op: opHeartbeat} + if seq != nil { + seqBytes, _ := json.Marshal(*seq) + hb.D = seqBytes + } + _ = g.writeJSON(conn, hb) + case opDispatch: + if payload.T == "MESSAGE_CREATE" { + var msg MessageCreateData + if err := json.Unmarshal(payload.D, &msg); err == nil { + go func(m MessageCreateData) { + defer func() { + if r := recover(); r != nil { + logger.Error("Recovered panic in Discord message handler: ", r) + } + }() + g.handleMessage(ctx, m) + }(msg) + } + } + } + } +} + +func (g *GatewayClient) handleMessage(ctx context.Context, msg MessageCreateData) { + if msg.Author.Bot { + return + } + channelID, err := g.settingService.GetDiscordChannelId() + if err != nil || strings.TrimSpace(channelID) == "" { + return + } + if msg.ChannelID != strings.TrimSpace(channelID) { + return + } + + content := strings.TrimSpace(msg.Content) + if !strings.HasPrefix(content, "!") && !strings.HasPrefix(content, "/") { + return + } + if !g.isAdmin(msg.Author.ID) { + return + } + + parts := strings.Fields(content) + if len(parts) == 0 { + return + } + + cmd := strings.ToLower(parts[0]) + cmd = strings.TrimLeft(cmd, "!/") + args := parts[1:] + + switch cmd { + case "help", "start": + g.sendHelp(ctx) + case "status": + g.sendStatus(ctx) + case "report": + _ = g.discordService.SendReport(ctx, g.serverService, g.inboundService) + case "backup": + g.sendBackup(ctx) + case "usage": + if len(args) == 0 { + _ = g.discordService.SendMessage(ctx, MessagePayload{ + Content: translator(g.settingService)("discord.commands.usageHint"), + }) + return + } + g.sendUsage(ctx, args[0]) + case "inbounds": + g.sendInbounds(ctx) + case "restart": + g.restartXray(ctx) + } +} + +// isAdmin reports whether a Discord user is listed in discordAdminIds; an empty list admits nobody. +func (g *GatewayClient) isAdmin(userID string) bool { + ids, err := g.settingService.GetDiscordAdminIds() + if err != nil { + return false + } + for id := range strings.SplitSeq(ids, ",") { + if id = strings.TrimSpace(id); id != "" && id == userID { + return true + } + } + return false +} + +func (g *GatewayClient) sendHelp(ctx context.Context) { + tr := translator(g.settingService) + embed := Embed{ + Title: tr("discord.commands.helpTitle"), + Description: tr("discord.commands.helpDescription"), + Color: ColorBlue, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Fields: []EmbedField{ + {Name: "!status", Value: tr("discord.commands.helpStatus"), Inline: false}, + {Name: "!report", Value: tr("discord.commands.helpReport"), Inline: false}, + {Name: "!backup", Value: tr("discord.commands.helpBackup"), Inline: false}, + {Name: "!usage ", Value: tr("discord.commands.helpUsage"), Inline: false}, + {Name: "!inbounds", Value: tr("discord.commands.helpInbounds"), Inline: false}, + {Name: "!restart", Value: tr("discord.commands.helpRestart"), Inline: false}, + {Name: "!help", Value: tr("discord.commands.helpHelp"), Inline: false}, + }, + Footer: &EmbedFooter{Text: tr("discord.footer")}, + } + _ = g.discordService.SendEmbed(ctx, embed) +} + +func (g *GatewayClient) sendStatus(ctx context.Context) { + var status *service.Status + if g.serverService != nil { + status = g.serverService.GetStatus(nil) + } + if status == nil { + status = &service.Status{} + } + + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "3x-ui" + } + + days := status.Uptime / 86400 + hours := (status.Uptime % 86400) / 3600 + + var onlines []string + if process := service.XrayProcess(); process != nil && process.IsRunning() { + onlines = process.GetOnlineClients() + } + + load1, load2, load3 := 0.0, 0.0, 0.0 + if len(status.Loads) > 0 { + load1 = status.Loads[0] + } + if len(status.Loads) > 1 { + load2 = status.Loads[1] + } + if len(status.Loads) > 2 { + load3 = status.Loads[2] + } + + tr := translator(g.settingService) + embed := Embed{ + Title: tr("discord.commands.statusTitle"), + Description: tr("discord.commands.statusDescription", "Host=="+hostname), + Color: ColorGreen, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Fields: []EmbedField{ + {Name: tr("discord.fields.panelVersion"), Value: config.GetPanelVersion(), Inline: true}, + {Name: tr("discord.fields.xrayCore"), Value: fmt.Sprintf("%s (%s)", status.Xray.Version, status.Xray.State), Inline: true}, + {Name: tr("pages.index.uptime"), Value: tr("discord.values.uptime", "Days=="+fmt.Sprint(days), "Hours=="+fmt.Sprint(hours)), Inline: true}, + {Name: tr("discord.fields.systemLoad"), Value: fmt.Sprintf("%.2f, %.2f, %.2f", load1, load2, load3), Inline: true}, + {Name: tr("pages.index.memory"), Value: fmt.Sprintf("%s / %s", common.FormatTraffic(int64(status.Mem.Current)), common.FormatTraffic(int64(status.Mem.Total))), Inline: true}, + {Name: tr("pages.index.historyTitleOnline"), Value: strconv.Itoa(len(onlines)), Inline: true}, + {Name: tr("pages.index.historyTabConnections"), Value: fmt.Sprintf("TCP: %d | UDP: %d", status.TcpCount, status.UdpCount), Inline: true}, + {Name: tr("pages.index.sent"), Value: common.FormatTraffic(int64(status.NetTraffic.Sent)), Inline: true}, + {Name: tr("pages.index.received"), Value: common.FormatTraffic(int64(status.NetTraffic.Recv)), Inline: true}, + }, + Footer: &EmbedFooter{Text: tr("discord.footer")}, + } + _ = g.discordService.SendEmbed(ctx, embed) +} + +func (g *GatewayClient) sendBackup(ctx context.Context) { + tr := translator(g.settingService) + if g.serverService == nil { + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.backupUnavailable")}) + return + } + + dbData, err := g.serverService.GetDb() + if err != nil || len(dbData) == 0 { + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.backupFailed", "Error=="+fmt.Sprint(err))}) + return + } + + filename := g.serverService.BackupFilename("") + if filename == "" { + filename = "x-ui.db" + } + + files := []FileAttachment{ + {Filename: filename, Data: dbData}, + } + + configPath := xray.GetConfigPath() + if configData, err := os.ReadFile(configPath); err == nil && len(configData) > 0 { + files = append(files, FileAttachment{ + Filename: "config.json", + Data: configData, + }) + } + + payload := MessagePayload{ + Embeds: []Embed{ + { + Title: tr("discord.commands.backupTitle"), + Description: tr("discord.commands.backupDescription", "Time=="+time.Now().UTC().Format(time.RFC3339)), + Color: ColorBlue, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Footer: &EmbedFooter{Text: tr("discord.footer")}, + }, + }, + } + + _ = g.discordService.SendMessageWithFiles(ctx, payload, files...) +} + +func (g *GatewayClient) sendUsage(ctx context.Context, email string) { + tr := translator(g.settingService) + if g.inboundService == nil { + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsUnavailable")}) + return + } + + inbounds, err := g.inboundService.GetAllInbounds() + if err != nil { + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsFailed", "Error=="+err.Error())}) + return + } + + target := strings.ToLower(strings.TrimSpace(email)) + for _, in := range inbounds { + for _, client := range in.ClientStats { + if strings.ToLower(client.Email) == target { + color := ColorGreen + statusStr := tr("enabled") + if !client.Enable { + color = ColorRed + statusStr = tr("disabled") + } + + expireStr := tr("unlimited") + if client.ExpiryTime > 0 { + expireStr = time.Unix(client.ExpiryTime/1000, 0).Format("2006-01-02 15:04:05") + } + + totalLimitStr := tr("unlimited") + if client.Total > 0 { + totalLimitStr = common.FormatTraffic(client.Total) + } + + embed := Embed{ + Title: tr("discord.commands.usageTitle", "Email=="+client.Email), + Description: tr("discord.commands.usageDescription", "Remark=="+in.Remark, "Port=="+strconv.Itoa(in.Port)), + Color: color, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Fields: []EmbedField{ + {Name: tr("status"), Value: statusStr, Inline: true}, + {Name: tr("pages.index.upload"), Value: common.FormatTraffic(client.Up), Inline: true}, + {Name: tr("pages.index.download"), Value: common.FormatTraffic(client.Down), Inline: true}, + {Name: tr("discord.fields.totalUsed"), Value: common.FormatTraffic(client.Up + client.Down), Inline: true}, + {Name: tr("discord.fields.quota"), Value: totalLimitStr, Inline: true}, + {Name: tr("pages.clients.expiryTime"), Value: expireStr, Inline: true}, + }, + Footer: &EmbedFooter{Text: tr("discord.footer")}, + } + _ = g.discordService.SendEmbed(ctx, embed) + return + } + } + } + + _ = g.discordService.SendMessage(ctx, MessagePayload{ + Content: tr("discord.commands.clientNotFound", "Email=="+email), + }) +} + +func (g *GatewayClient) sendInbounds(ctx context.Context) { + tr := translator(g.settingService) + if g.inboundService == nil { + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsUnavailable")}) + return + } + + inbounds, err := g.inboundService.GetAllInbounds() + if err != nil { + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.inboundsFailed", "Error=="+err.Error())}) + return + } + + if len(inbounds) == 0 { + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.noInbounds")}) + return + } + + var fields []EmbedField + for _, in := range inbounds { + state := tr("enabled") + if !in.Enable { + state = tr("disabled") + } + val := tr("discord.values.inbound", + "Protocol=="+string(in.Protocol), + "Port=="+strconv.Itoa(in.Port), + "Clients=="+strconv.Itoa(len(in.ClientStats)), + "Up=="+common.FormatTraffic(in.Up), + "Down=="+common.FormatTraffic(in.Down), + "State=="+state, + ) + fields = append(fields, EmbedField{ + Name: fmt.Sprintf("📍 %s", in.Remark), + Value: val, + Inline: false, + }) + } + + embed := Embed{ + Title: tr("discord.commands.inboundsTitle"), + Description: tr("discord.commands.inboundsDescription", "Count=="+strconv.Itoa(len(inbounds))), + Color: ColorBlue, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Fields: fields, + Footer: &EmbedFooter{Text: tr("discord.footer")}, + } + _ = g.discordService.SendEmbed(ctx, embed) +} + +func (g *GatewayClient) restartXray(ctx context.Context) { + tr := translator(g.settingService) + if g.xrayService == nil { + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.xrayUnavailable")}) + return + } + + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.restarting")}) + if err := g.xrayService.RestartXray(false); err != nil { + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.restartFailed", "Error=="+err.Error())}) + } else { + _ = g.discordService.SendMessage(ctx, MessagePayload{Content: tr("discord.commands.restartSuccess")}) + } +} diff --git a/internal/web/service/discord/gateway_test.go b/internal/web/service/discord/gateway_test.go new file mode 100644 index 000000000..c5340659c --- /dev/null +++ b/internal/web/service/discord/gateway_test.go @@ -0,0 +1,508 @@ +package discord + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +type mockXrayRestart struct { + restarted bool + err error +} + +func (m *mockXrayRestart) RestartXray(force bool) error { + m.restarted = true + return m.err +} + +func TestGatewayClient_EndToEndCommands(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotEnable(true) + _ = settingService.SetDiscordBotToken("test-gw-token") + _ = settingService.SetDiscordChannelId("ch-12345") + _ = settingService.SetDiscordAdminIds("u1") + + var sentMessages []MessagePayload + var mu sync.Mutex + + restServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var p MessagePayload + _ = json.NewDecoder(r.Body).Decode(&p) + mu.Lock() + sentMessages = append(sentMessages, p) + mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id": "msg-sent"}`)) + })) + defer restServer.Close() + + discordSvc := NewDiscordService(settingService) + discordSvc.SetBaseURL(restServer.URL) + discordSvc.SetHTTPClient(restServer.Client()) + + upgrader := websocket.Upgrader{} + wsConnected := make(chan struct{}) + + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + // 1. Send Op 10 Hello + hello := GatewayPayload{ + Op: opHello, + D: []byte(`{"heartbeat_interval": 500}`), + } + _ = conn.WriteJSON(hello) + + // 2. Read Op 2 Identify + var ident GatewayPayload + _ = conn.ReadJSON(&ident) + + close(wsConnected) + + // 3. Send !help message + helpMsg := MessageCreateData{ + ID: "m1", + ChannelID: "ch-12345", + Content: "!help", + Author: struct { + ID string `json:"id"` + Username string `json:"username"` + Bot bool `json:"bot"` + }{ID: "u1", Username: "Alice", Bot: false}, + } + helpBytes, _ := json.Marshal(helpMsg) + _ = conn.WriteJSON(GatewayPayload{ + Op: opDispatch, + T: "MESSAGE_CREATE", + D: helpBytes, + }) + + time.Sleep(50 * time.Millisecond) + + // 4. Send !status message + statusMsg := MessageCreateData{ + ID: "m2", + ChannelID: "ch-12345", + Content: "!status", + Author: struct { + ID string `json:"id"` + Username string `json:"username"` + Bot bool `json:"bot"` + }{ID: "u1", Username: "Alice", Bot: false}, + } + statusBytes, _ := json.Marshal(statusMsg) + _ = conn.WriteJSON(GatewayPayload{ + Op: opDispatch, + T: "MESSAGE_CREATE", + D: statusBytes, + }) + + time.Sleep(50 * time.Millisecond) + + // 5. Send message from a bot (must be ignored) + botMsg := MessageCreateData{ + ID: "m3", + ChannelID: "ch-12345", + Content: "!status", + Author: struct { + ID string `json:"id"` + Username string `json:"username"` + Bot bool `json:"bot"` + }{ID: "u2", Username: "OtherBot", Bot: true}, + } + botBytes, _ := json.Marshal(botMsg) + _ = conn.WriteJSON(GatewayPayload{ + Op: opDispatch, + T: "MESSAGE_CREATE", + D: botBytes, + }) + + time.Sleep(50 * time.Millisecond) + + // 6. Send !usage for existing client + usageMsg := MessageCreateData{ + ID: "m4", + ChannelID: "ch-12345", + Content: "!usage client@test.com", + Author: struct { + ID string `json:"id"` + Username string `json:"username"` + Bot bool `json:"bot"` + }{ID: "u1", Username: "Alice", Bot: false}, + } + usageBytes, _ := json.Marshal(usageMsg) + _ = conn.WriteJSON(GatewayPayload{ + Op: opDispatch, + T: "MESSAGE_CREATE", + D: usageBytes, + }) + + time.Sleep(50 * time.Millisecond) + + // 7. Send !restart command + restartMsg := MessageCreateData{ + ID: "m5", + ChannelID: "ch-12345", + Content: "!restart", + Author: struct { + ID string `json:"id"` + Username string `json:"username"` + Bot bool `json:"bot"` + }{ID: "u1", Username: "Alice", Bot: false}, + } + restartBytes, _ := json.Marshal(restartMsg) + _ = conn.WriteJSON(GatewayPayload{ + Op: opDispatch, + T: "MESSAGE_CREATE", + D: restartBytes, + }) + + // Keep connection alive until closed + for { + var p GatewayPayload + if err := conn.ReadJSON(&p); err != nil { + break + } + } + })) + defer wsServer.Close() + + mockServer := &mockServerProvider{ + status: &service.Status{ + Uptime: 10000, + Loads: []float64{0.1, 0.2, 0.3}, + TcpCount: 5, + UdpCount: 2, + }, + } + mockInbound := &mockInboundProvider{ + inbounds: []*model.Inbound{ + { + Id: 1, + Remark: "VLESS-Test", + Port: 8443, + Protocol: "vless", + Enable: true, + ClientStats: []xray.ClientTraffic{ + { + Email: "client@test.com", + Enable: true, + Up: 1024, + Down: 2048, + Total: 10485760, + }, + }, + }, + }, + } + mockXray := &mockXrayRestart{} + + wsURL := "ws" + strings.TrimPrefix(wsServer.URL, "http") + + gw := NewGatewayClient(discordSvc, settingService, mockServer, mockInbound, mockXray) + gw.SetGatewayURL(wsURL) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := gw.Start(ctx); err != nil { + t.Fatalf("gw.Start failed: %v", err) + } + + select { + case <-wsConnected: + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for WS connection") + } + + // Wait for dispatches to be processed + time.Sleep(300 * time.Millisecond) + + gw.Stop() + + if gw.IsRunning() { + t.Error("expected gateway not to be running after Stop") + } + + mu.Lock() + msgs := make([]MessagePayload, len(sentMessages)) + copy(msgs, sentMessages) + mu.Unlock() + + // We expect: + // 1. !help response embed + // 2. !status response embed + // (bot message ignored) + // 3. !usage response embed + // 4. !restart "Restarting..." and "Restarted successfully" + if len(msgs) < 4 { + t.Fatalf("expected at least 4 message responses, got %d: %+v", len(msgs), msgs) + } + + foundHelp := false + foundStatus := false + foundUsage := false + for _, m := range msgs { + for _, e := range m.Embeds { + if strings.Contains(e.Title, "Discord Bot Commands") { + foundHelp = true + } + if strings.Contains(e.Title, "Server Status") { + foundStatus = true + } + if strings.Contains(e.Title, "Client Usage: client@test.com") { + foundUsage = true + } + } + } + + if !foundHelp { + t.Error("expected help embed to be sent") + } + if !foundStatus { + t.Error("expected status embed to be sent") + } + if !foundUsage { + t.Error("expected usage embed to be sent") + } + if !mockXray.restarted { + t.Error("expected Xray core to be restarted") + } +} + +func TestGatewayRequestedHeartbeatDoesNotRaceTicker(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotEnable(true) + _ = settingService.SetDiscordBotToken("test-gw-token") + + var once sync.Once + flooded := make(chan struct{}) + upgrader := websocket.Upgrader{} + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + _ = conn.WriteJSON(GatewayPayload{Op: opHello, D: []byte(`{"heartbeat_interval": 1}`)}) + + readErr := make(chan error, 1) + go func() { + for { + if _, _, err := conn.ReadMessage(); err != nil { + readErr <- err + return + } + } + }() + // Op 1 from the server makes the read loop write while the 1ms ticker writes too. + for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); { + if err := conn.WriteJSON(GatewayPayload{Op: opHeartbeat}); err != nil { + break + } + } + select { + case err := <-readErr: + t.Errorf("server read a broken client frame during the flood: %v", err) + default: + } + once.Do(func() { close(flooded) }) + })) + defer wsServer.Close() + + gw := NewGatewayClient(NewDiscordService(settingService), settingService, nil, nil, nil) + gw.SetGatewayURL("ws" + strings.TrimPrefix(wsServer.URL, "http")) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := gw.Start(ctx); err != nil { + t.Fatalf("gw.Start failed: %v", err) + } + defer gw.Stop() + + select { + case <-flooded: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the heartbeat flood to finish") + } +} + +func TestGatewayStopsOnNonReconnectableCloseCode(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotEnable(true) + _ = settingService.SetDiscordBotToken("test-gw-token") + + var mu sync.Mutex + dials := 0 + upgrader := websocket.Upgrader{} + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + mu.Lock() + dials++ + mu.Unlock() + _ = conn.WriteJSON(GatewayPayload{Op: opHello, D: []byte(`{"heartbeat_interval": 45000}`)}) + var ident GatewayPayload + _ = conn.ReadJSON(&ident) + closeMsg := websocket.FormatCloseMessage(4014, "Disallowed intent(s).") + _ = conn.WriteControl(websocket.CloseMessage, closeMsg, time.Now().Add(time.Second)) + })) + defer wsServer.Close() + + gw := NewGatewayClient(NewDiscordService(settingService), settingService, nil, nil, nil) + gw.SetGatewayURL("ws" + strings.TrimPrefix(wsServer.URL, "http")) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := gw.Start(ctx); err != nil { + t.Fatalf("gw.Start failed: %v", err) + } + defer gw.Stop() + + for deadline := time.Now().Add(2 * time.Second); gw.IsRunning() && time.Now().Before(deadline); { + time.Sleep(20 * time.Millisecond) + } + if gw.IsRunning() { + t.Fatal("gateway still running after close code 4014, which Discord marks non-reconnectable") + } + mu.Lock() + defer mu.Unlock() + if dials != 1 { + t.Fatalf("gateway dialed %d times, want 1", dials) + } +} + +func TestGatewayDialsThroughPanelEgressProxy(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotEnable(true) + _ = settingService.SetDiscordBotToken("test-gw-token") + + identified := make(chan struct{}, 1) + upgrader := websocket.Upgrader{} + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + _ = conn.WriteJSON(GatewayPayload{Op: opHello, D: []byte(`{"heartbeat_interval": 45000}`)}) + var ident GatewayPayload + if conn.ReadJSON(&ident) == nil { + select { + case identified <- struct{}{}: + default: + } + } + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + })) + defer wsServer.Close() + + var mu sync.Mutex + tunneledTo := "" + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + http.Error(w, "CONNECT only", http.StatusMethodNotAllowed) + return + } + mu.Lock() + tunneledTo = r.Host + mu.Unlock() + upstream, err := net.Dial("tcp", r.Host) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer upstream.Close() + client, _, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer client.Close() + _, _ = client.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")) + go func() { _, _ = io.Copy(upstream, client) }() + _, _ = io.Copy(client, upstream) + })) + defer proxy.Close() + + gw := NewGatewayClient(NewDiscordService(settingService), settingService, nil, nil, nil) + gw.SetGatewayURL("ws" + strings.TrimPrefix(wsServer.URL, "http")) + gw.egressProxyURL = func() string { return proxy.URL } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := gw.Start(ctx); err != nil { + t.Fatalf("gw.Start failed: %v", err) + } + defer gw.Stop() + + select { + case <-identified: + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for the gateway to identify") + } + mu.Lock() + defer mu.Unlock() + if want := strings.TrimPrefix(wsServer.URL, "http://"); tunneledTo != want { + t.Fatalf("gateway tunneled to %q through the panel egress proxy, want %q", tunneledTo, want) + } +} + +func TestGatewayCommandsRequireListedAdmin(t *testing.T) { + cases := []struct { + name string + adminIDs string + author string + wantRestart bool + }{ + {"listed admin", "111, 222", "222", true}, + {"unlisted member", "111", "999", false}, + {"empty list allows nobody", "", "111", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("token") + _ = settingService.SetDiscordChannelId("ch-1") + _ = settingService.SetDiscordAdminIds(tc.adminIDs) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + restarter := &mockXrayRestart{} + msg := MessageCreateData{ChannelID: "ch-1", Content: "!restart"} + msg.Author.ID = tc.author + NewGatewayClient(svc, settingService, nil, nil, restarter).handleMessage(context.Background(), msg) + + if restarter.restarted != tc.wantRestart { + t.Fatalf("author %q with admin list %q: restarted = %v, want %v", tc.author, tc.adminIDs, restarter.restarted, tc.wantRestart) + } + }) + } +} diff --git a/internal/web/service/discord/locale_test.go b/internal/web/service/discord/locale_test.go new file mode 100644 index 000000000..96a0d969e --- /dev/null +++ b/internal/web/service/discord/locale_test.go @@ -0,0 +1,99 @@ +package discord + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" +) + +type fixedTgLang struct{} + +func (fixedTgLang) GetTgLang() (string, error) { return "en-US", nil } + +// TestMain loads the real translation files so embeds render text instead of bare keys. +func TestMain(m *testing.M) { + if err := locale.InitLocalizer(os.DirFS("../.."), fixedTgLang{}); err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +func TestDiscordMessagesFollowDiscordLang(t *testing.T) { + const lang = "ru-RU" + settingService := setupTestDB(t) + _ = settingService.SetDiscordLang(lang) + _ = settingService.SetDiscordBotToken("token") + _ = settingService.SetDiscordChannelId("ch-1") + _ = settingService.SetDiscordAdminIds("admin-1") + + titles := make(chan string, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var p MessagePayload + _ = json.NewDecoder(r.Body).Decode(&p) + if len(p.Embeds) > 0 { + titles <- p.Embeds[0].Title + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + sentTitle := func(t *testing.T) string { + t.Helper() + select { + case title := <-titles: + return title + default: + t.Fatal("no embed reached Discord") + return "" + } + } + + cases := []struct { + key string + render func(t *testing.T) string + }{ + {"discord.test.title", func(t *testing.T) string { + if err := svc.SendTest(context.Background()); err != nil { + t.Fatalf("SendTest: %v", err) + } + return sentTitle(t) + }}, + {"discord.alerts.xrayCrash", func(t *testing.T) string { + embed, _ := NewSubscriber(settingService, svc).FormatEmbed(eventbus.Event{Type: eventbus.EventXrayCrash}) + return embed.Title + }}, + {"discord.report.title", func(t *testing.T) string { + payload, _, err := svc.BuildReport(context.Background(), nil, nil) + if err != nil { + t.Fatalf("BuildReport: %v", err) + } + return payload.Embeds[0].Title + }}, + {"discord.commands.helpTitle", func(t *testing.T) string { + msg := MessageCreateData{ChannelID: "ch-1", Content: "!help"} + msg.Author.ID = "admin-1" + NewGatewayClient(svc, settingService, nil, nil, nil).handleMessage(context.Background(), msg) + return sentTitle(t) + }}, + } + for _, tc := range cases { + t.Run(tc.key, func(t *testing.T) { + want := locale.I18nForLang(lang, tc.key) + if want == locale.I18nForLang("en-US", tc.key) { + t.Fatalf("%s has no distinct %s translation", tc.key, lang) + } + if got := tc.render(t); got != want { + t.Fatalf("title = %q, want the %s text %q", got, lang, want) + } + }) + } +} diff --git a/internal/web/service/discord/report.go b/internal/web/service/discord/report.go new file mode 100644 index 000000000..012a1da9f --- /dev/null +++ b/internal/web/service/discord/report.go @@ -0,0 +1,241 @@ +package discord + +import ( + "context" + "fmt" + "net" + "os" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// ServerProvider abstracts server status and database backup operations. +type ServerProvider interface { + GetStatus(lastStatus *service.Status) *service.Status + GetDb() ([]byte, error) + BackupFilename(requestHost string) string +} + +// InboundProvider abstracts inbound management operations. +type InboundProvider interface { + GetAllInbounds() ([]*model.Inbound, error) +} + +// BuildReport constructs the status report payload and backup attachments. +func (s *DiscordService) BuildReport(ctx context.Context, server ServerProvider, inbound InboundProvider) (MessagePayload, []FileAttachment, error) { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "3x-ui" + } + + var status *service.Status + if server != nil { + status = server.GetStatus(nil) + } + if status == nil { + status = &service.Status{ + Loads: []float64{0, 0, 0}, + } + status.Xray.State = service.ProcessState("unknown") + status.Xray.Version = "unknown" + } + + var onlines []string + if process := service.XrayProcess(); process != nil && process.IsRunning() { + onlines = process.GetOnlineClients() + } + + trDiff := int64(0) + exDiff := int64(0) + now := time.Now().Unix() * 1000 + + trafficThreshold, err := s.settingService.GetTrafficDiff() + if err == nil && trafficThreshold > 0 { + trDiff = int64(trafficThreshold) * 1073741824 + } + expireThreshold, err := s.settingService.GetExpireDiff() + if err == nil && expireThreshold > 0 { + exDiff = int64(expireThreshold) * 86400000 + } + + var totalInbounds, disabledInbounds, exhaustedInbounds int + var totalClients, disabledClients, exhaustedClients int + seenClients := make(map[string]bool) + + if inbound != nil { + inbounds, err := inbound.GetAllInbounds() + if err != nil { + logger.Warning("Discord report: unable to load inbounds: ", err) + } else { + totalInbounds = len(inbounds) + for _, in := range inbounds { + if !in.Enable { + disabledInbounds++ + } else if (in.ExpiryTime > 0 && (in.ExpiryTime-now < exDiff)) || + (in.Total > 0 && (in.Total-(in.Up+in.Down) < trDiff)) { + exhaustedInbounds++ + } + + for _, client := range in.ClientStats { + if seenClients[client.Email] { + continue + } + seenClients[client.Email] = true + totalClients++ + if !client.Enable { + disabledClients++ + } else if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) || + (client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) { + exhaustedClients++ + } + } + } + } + } + + tr := translator(s.settingService) + days := status.Uptime / 86400 + hours := (status.Uptime % 86400) / 3600 + uptimeStr := tr("discord.values.uptime", "Days=="+fmt.Sprint(days), "Hours=="+fmt.Sprint(hours)) + + ramStr := fmt.Sprintf("%s / %s", common.FormatTraffic(int64(status.Mem.Current)), common.FormatTraffic(int64(status.Mem.Total))) + trafficStr := tr("discord.values.traffic", + "Up=="+common.FormatTraffic(int64(status.NetTraffic.Sent)), + "Down=="+common.FormatTraffic(int64(status.NetTraffic.Recv)), + "Total=="+common.FormatTraffic(int64(status.NetTraffic.Sent+status.NetTraffic.Recv)), + ) + load1, load2, load3 := 0.0, 0.0, 0.0 + if len(status.Loads) > 0 { + load1 = status.Loads[0] + } + if len(status.Loads) > 1 { + load2 = status.Loads[1] + } + if len(status.Loads) > 2 { + load3 = status.Loads[2] + } + loadStr := fmt.Sprintf("%.2f, %.2f, %.2f", load1, load2, load3) + + fields := []EmbedField{ + {Name: tr("host"), Value: hostname, Inline: true}, + {Name: tr("discord.fields.panelVersion"), Value: config.GetPanelVersion(), Inline: true}, + {Name: tr("discord.fields.xrayCore"), Value: fmt.Sprintf("%s (%s)", status.Xray.Version, status.Xray.State), Inline: true}, + {Name: tr("pages.index.uptime"), Value: uptimeStr, Inline: true}, + {Name: tr("discord.fields.systemLoad"), Value: loadStr, Inline: true}, + {Name: tr("pages.index.memory"), Value: ramStr, Inline: true}, + {Name: tr("discord.fields.networkTraffic"), Value: trafficStr, Inline: false}, + {Name: tr("pages.index.historyTabConnections"), Value: fmt.Sprintf("TCP: %d | UDP: %d", status.TcpCount, status.UdpCount), Inline: true}, + {Name: tr("pages.index.historyTitleOnline"), Value: strconv.Itoa(len(onlines)), Inline: true}, + {Name: tr("tgbot.inbounds"), Value: tr("discord.values.counts", "Total=="+strconv.Itoa(totalInbounds), "Depleting=="+strconv.Itoa(exhaustedInbounds), "Disabled=="+strconv.Itoa(disabledInbounds)), Inline: false}, + {Name: tr("clients"), Value: tr("discord.values.counts", "Total=="+strconv.Itoa(totalClients), "Depleting=="+strconv.Itoa(exhaustedClients), "Disabled=="+strconv.Itoa(disabledClients)), Inline: false}, + } + + ipv4, ipv6 := getInterfaceIPs() + if ipv4 != "" { + fields = append(fields, EmbedField{Name: "IPv4", Value: ipv4, Inline: true}) + } + if ipv6 != "" { + fields = append(fields, EmbedField{Name: "IPv6", Value: ipv6, Inline: true}) + } + + runTime, _ := s.settingService.GetDiscordRunTime() + if runTime == "" { + runTime = "@daily" + } + + embed := Embed{ + Title: tr("discord.report.title"), + Description: tr("discord.report.summary", "Host=="+hostname), + Color: ColorBlue, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Fields: fields, + Footer: &EmbedFooter{ + Text: tr("discord.report.footer", "RunTime=="+runTime), + }, + } + + payload := MessagePayload{ + Embeds: []Embed{embed}, + } + + var files []FileAttachment + backupEnabled, err := s.settingService.GetDiscordBotBackup() + if err == nil && backupEnabled && server != nil { + dbData, err := server.GetDb() + if err != nil { + logger.Warning("Discord report: failed to get DB backup: ", err) + } else if len(dbData) > 0 { + filename := server.BackupFilename("") + if filename == "" { + filename = "x-ui.db" + } + files = append(files, FileAttachment{ + Filename: filename, + Data: dbData, + }) + } + + configPath := xray.GetConfigPath() + if configData, err := os.ReadFile(configPath); err == nil && len(configData) > 0 { + files = append(files, FileAttachment{ + Filename: "config.json", + Data: configData, + }) + } + } + + return payload, files, nil +} + +// SendReport generates and sends the periodic report to Discord. +func (s *DiscordService) SendReport(ctx context.Context, server ServerProvider, inbound InboundProvider) error { + payload, files, err := s.BuildReport(ctx, server, inbound) + if err != nil { + return fmt.Errorf("build discord report: %w", err) + } + // Separate messages: a backup over Discord's upload cap must not drop the report with it. + if err := s.SendMessage(ctx, payload); err != nil { + return err + } + if len(files) == 0 { + return nil + } + return s.SendMessageWithFiles(ctx, MessagePayload{}, files...) +} + +func getInterfaceIPs() (ipv4, ipv6 string) { + netInterfaces, err := net.Interfaces() + if err != nil { + return "", "" + } + var v4s, v6s []string + for _, iface := range netInterfaces { + if (iface.Flags&net.FlagUp) == 0 || (iface.Flags&net.FlagLoopback) != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if !ok || ipnet.IP.IsLoopback() { + continue + } + if ip := ipnet.IP.To4(); ip != nil { + v4s = append(v4s, ip.String()) + } else if ip := ipnet.IP.To16(); ip != nil && !ipnet.IP.IsLinkLocalUnicast() { + v6s = append(v6s, ip.String()) + } + } + } + return strings.Join(v4s, ", "), strings.Join(v6s, ", ") +} diff --git a/internal/web/service/discord/report_test.go b/internal/web/service/discord/report_test.go new file mode 100644 index 000000000..ce75d140d --- /dev/null +++ b/internal/web/service/discord/report_test.go @@ -0,0 +1,231 @@ +package discord + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +type mockServerProvider struct { + status *service.Status + dbData []byte + dbErr error + filename string +} + +func (m *mockServerProvider) GetStatus(lastStatus *service.Status) *service.Status { + return m.status +} + +func (m *mockServerProvider) GetDb() ([]byte, error) { + return m.dbData, m.dbErr +} + +func (m *mockServerProvider) BackupFilename(requestHost string) string { + if m.filename != "" { + return m.filename + } + return "x-ui_test.db" +} + +type mockInboundProvider struct { + inbounds []*model.Inbound + err error +} + +func (m *mockInboundProvider) GetAllInbounds() ([]*model.Inbound, error) { + return m.inbounds, m.err +} + +func TestBuildReport_NoBackup(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("test-bot-token") + _ = settingService.SetDiscordChannelId("12345") + _ = settingService.SetDiscordBotBackup(false) + _ = settingService.SetDiscordRunTime("@daily") + + mockStatus := &service.Status{ + Uptime: 172800, + Loads: []float64{0.5, 0.4, 0.3}, + TcpCount: 15, + UdpCount: 5, + } + mockStatus.Xray.State = service.Running + mockStatus.Xray.Version = "25.1.0" + + mockServer := &mockServerProvider{ + status: mockStatus, + dbData: []byte("sqlite-backup-bytes"), + } + + mockInbound := &mockInboundProvider{ + inbounds: []*model.Inbound{ + { + Id: 1, + Remark: "VLESS-TCP", + Enable: true, + Port: 443, + ClientStats: []xray.ClientTraffic{ + {Email: "user1@example.com", Enable: true, Up: 100, Down: 200}, + {Email: "user2@example.com", Enable: false}, + }, + }, + }, + } + + svc := NewDiscordService(settingService) + payload, files, err := svc.BuildReport(context.Background(), mockServer, mockInbound) + if err != nil { + t.Fatalf("BuildReport failed: %v", err) + } + + if len(payload.Embeds) != 1 { + t.Fatalf("expected 1 embed, got %d", len(payload.Embeds)) + } + embed := payload.Embeds[0] + if embed.Color != ColorBlue { + t.Errorf("expected ColorBlue, got %X", embed.Color) + } + if len(files) != 0 { + t.Errorf("expected 0 files when backup is disabled, got %d", len(files)) + } + + foundHost, foundUptime, foundXray := false, false, false + for _, field := range embed.Fields { + if field.Name == "Host" { + foundHost = true + } + if field.Name == "Uptime" && field.Value == "2d 0h" { + foundUptime = true + } + if field.Name == "Xray Core" && field.Value == "25.1.0 (running)" { + foundXray = true + } + } + if !foundHost || !foundUptime || !foundXray { + t.Errorf("expected fields not found in embed: %+v", embed.Fields) + } +} + +func TestBuildReport_WithBackup(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("test-bot-token") + _ = settingService.SetDiscordChannelId("12345") + _ = settingService.SetDiscordBotBackup(true) + + mockStatus := &service.Status{ + Uptime: 3600, + } + mockStatus.Xray.State = service.Running + mockStatus.Xray.Version = "25.1.0" + + mockServer := &mockServerProvider{ + status: mockStatus, + dbData: []byte("test-db-content"), + filename: "x-ui_backup.db", + } + + svc := NewDiscordService(settingService) + _, files, err := svc.BuildReport(context.Background(), mockServer, nil) + if err != nil { + t.Fatalf("BuildReport failed: %v", err) + } + + if len(files) == 0 { + t.Fatal("expected at least 1 backup file, got 0") + } + if files[0].Filename != "x-ui_backup.db" { + t.Errorf("expected filename 'x-ui_backup.db', got %q", files[0].Filename) + } + if string(files[0].Data) != "test-db-content" { + t.Errorf("expected db content 'test-db-content', got %q", string(files[0].Data)) + } +} + +func TestSendReport_Integration(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("test-token") + _ = settingService.SetDiscordChannelId("998877") + _ = settingService.SetDiscordBotBackup(true) + + var receivedRequest bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedRequest = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id": "msg-123"}`)) + })) + defer server.Close() + + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + mockStatus := &service.Status{ + Uptime: 86400, + } + mockStatus.Xray.State = service.Running + mockStatus.Xray.Version = "25.1.0" + + mockServer := &mockServerProvider{ + status: mockStatus, + dbData: []byte("sqlite-data"), + } + + err := svc.SendReport(context.Background(), mockServer, nil) + if err != nil { + t.Fatalf("SendReport failed: %v", err) + } + if !receivedRequest { + t.Error("expected server to receive report request") + } +} + +func TestSendReport_DeliversEmbedWhenBackupUploadIsRejected(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("test-token") + _ = settingService.SetDiscordChannelId("998877") + _ = settingService.SetDiscordBotBackup(true) + + embeds := make(chan int, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") { + w.WriteHeader(http.StatusRequestEntityTooLarge) + _, _ = w.Write([]byte(`{"message": "Request entity too large", "code": 40005}`)) + return + } + var p MessagePayload + _ = json.NewDecoder(r.Body).Decode(&p) + embeds <- len(p.Embeds) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + svc := NewDiscordService(settingService) + svc.SetBaseURL(server.URL) + svc.SetHTTPClient(server.Client()) + + mockServer := &mockServerProvider{ + status: &service.Status{Uptime: 86400}, + dbData: []byte("sqlite-data-over-the-upload-cap"), + } + + err := svc.SendReport(context.Background(), mockServer, nil) + if err == nil || !strings.Contains(err.Error(), "(413)") { + t.Fatalf("SendReport error = %v, want the rejected backup upload (413)", err) + } + select { + case n := <-embeds: + if n != 1 { + t.Fatalf("report message carried %d embeds, want 1", n) + } + default: + t.Fatal("report embed was never delivered: it rode on the rejected backup upload") + } +} diff --git a/internal/web/service/discord/subscriber.go b/internal/web/service/discord/subscriber.go new file mode 100644 index 000000000..85ffac080 --- /dev/null +++ b/internal/web/service/discord/subscriber.go @@ -0,0 +1,338 @@ +package discord + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) + +// Subscriber handles event bus messages and forwards them to Discord. +type Subscriber struct { + settingService service.SettingService + discordService *DiscordService + limiter *eventbus.RateLimiter +} + +// NewSubscriber creates a new Discord event subscriber. +func NewSubscriber(settingService service.SettingService, discordService *DiscordService) *Subscriber { + return &Subscriber{ + settingService: settingService, + discordService: discordService, + limiter: eventbus.NewRateLimiter(1 * time.Minute), + } +} + +// HandleEvent is the eventbus subscriber callback. +func (s *Subscriber) HandleEvent(e eventbus.Event) { + if s.discordService == nil { + return + } + if on, err := s.settingService.GetDiscordBotEnable(); err != nil || !on { + return + } + if !s.isEventEnabled(e.Type) { + return + } + embed, ok := s.FormatEmbed(e) + if !ok { + return + } + if e.Type != eventbus.EventLoginAttempt { + if !s.limiter.Allow(e.Type, e.Source) { + return + } + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := s.discordService.SendEmbed(ctx, embed); err != nil { + logger.Warning("discord subscriber: send failed:", err) + } +} + +func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool { + events, err := s.settingService.GetDiscordEnabledEvents() + if err != nil || events == "" { + return false + } + for e := range strings.SplitSeq(events, ",") { + if strings.TrimSpace(e) == string(t) { + return true + } + } + return false +} + +func truncateRunes(s string, maxRunes int) string { + r := []rune(s) + if len(r) <= maxRunes { + return s + } + if maxRunes <= 3 { + return string(r[:maxRunes]) + } + return string(r[:maxRunes-3]) + "..." +} + +func cleanField(name, value string, inline bool) EmbedField { + name = strings.TrimSpace(name) + if name == "" { + name = "-" + } else { + name = truncateRunes(name, 256) + } + value = strings.TrimSpace(value) + if value == "" { + value = "-" + } else { + value = truncateRunes(value, 1024) + } + return EmbedField{ + Name: name, + Value: value, + Inline: inline, + } +} + +// FormatEmbed converts an eventbus.Event into a Discord Embed. +// Returns false if the event should not produce a message (e.g. thresholds not exceeded). +func (s *Subscriber) FormatEmbed(e eventbus.Event) (Embed, bool) { + h, _ := os.Hostname() + if h == "" { + h = "unknown" + } + var ts string + if e.Timestamp.IsZero() { + ts = time.Now().UTC().Format(time.RFC3339) + } else { + ts = e.Timestamp.UTC().Format(time.RFC3339) + } + + footer := &EmbedFooter{ + Text: truncateRunes("3x-ui • "+h, 2048), + } + tr := translator(s.settingService) + + switch e.Type { + case eventbus.EventOutboundDown: + fields := []EmbedField{ + cleanField(tr("discord.fields.outbound"), e.Source, true), + } + var data *eventbus.OutboundHealthData + switch d := e.Data.(type) { + case *eventbus.OutboundHealthData: + data = d + case eventbus.OutboundHealthData: + data = &d + } + if data != nil { + if data.Error != "" { + fields = append(fields, cleanField(tr("discord.fields.error"), data.Error, false)) + } + if data.Delay > 0 { + fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.Delay), true)) + } + } + return Embed{ + Title: tr("discord.alerts.outboundDown"), + Color: ColorRed, + Timestamp: ts, + Fields: fields, + Footer: footer, + }, true + + case eventbus.EventOutboundUp: + fields := []EmbedField{ + cleanField(tr("discord.fields.outbound"), e.Source, true), + } + var data *eventbus.OutboundHealthData + switch d := e.Data.(type) { + case *eventbus.OutboundHealthData: + data = d + case eventbus.OutboundHealthData: + data = &d + } + if data != nil && data.Delay > 0 { + fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.Delay), true)) + } + return Embed{ + Title: tr("discord.alerts.outboundUp"), + Color: ColorGreen, + Timestamp: ts, + Fields: fields, + Footer: footer, + }, true + + case eventbus.EventNodeDown: + fields := []EmbedField{ + cleanField(tr("discord.fields.node"), e.Source, true), + } + var data *eventbus.NodeHealthData + switch d := e.Data.(type) { + case *eventbus.NodeHealthData: + data = d + case eventbus.NodeHealthData: + data = &d + } + if data != nil && data.XrayError != "" { + fields = append(fields, cleanField(tr("discord.fields.error"), data.XrayError, false)) + } + return Embed{ + Title: tr("discord.alerts.nodeDown"), + Color: ColorRed, + Timestamp: ts, + Fields: fields, + Footer: footer, + }, true + + case eventbus.EventNodeUp: + fields := []EmbedField{ + cleanField(tr("discord.fields.node"), e.Source, true), + } + var data *eventbus.NodeHealthData + switch d := e.Data.(type) { + case *eventbus.NodeHealthData: + data = d + case eventbus.NodeHealthData: + data = &d + } + if data != nil && data.LatencyMs > 0 { + fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.LatencyMs), true)) + } + return Embed{ + Title: tr("discord.alerts.nodeUp"), + Color: ColorGreen, + Timestamp: ts, + Fields: fields, + Footer: footer, + }, true + + case eventbus.EventXrayCrash: + var fields []EmbedField + if e.Data != nil { + fields = append(fields, cleanField(tr("discord.fields.error"), fmt.Sprint(e.Data), false)) + } + return Embed{ + Title: tr("discord.alerts.xrayCrash"), + Color: ColorRed, + Timestamp: ts, + Fields: fields, + Footer: footer, + }, true + + case eventbus.EventCPUHigh: + var data *eventbus.SystemMetricData + switch d := e.Data.(type) { + case *eventbus.SystemMetricData: + data = d + case eventbus.SystemMetricData: + data = &d + } + if data != nil { + discordCpu, err := s.settingService.GetDiscordCpu() + if err != nil || discordCpu <= 0 || data.Percent <= float64(discordCpu) { + return Embed{}, false + } + fields := []EmbedField{ + cleanField(tr("usage"), fmt.Sprintf("%.2f%%", data.Percent), true), + cleanField(tr("discord.fields.threshold"), fmt.Sprintf("%d%%", discordCpu), true), + } + return Embed{ + Title: tr("discord.alerts.cpuHigh"), + Color: ColorOrange, + Timestamp: ts, + Fields: fields, + Footer: footer, + }, true + } + return Embed{}, false + + case eventbus.EventMemoryHigh: + var data *eventbus.SystemMetricData + switch d := e.Data.(type) { + case *eventbus.SystemMetricData: + data = d + case eventbus.SystemMetricData: + data = &d + } + if data != nil { + discordMem, err := s.settingService.GetDiscordMemory() + if err != nil || discordMem <= 0 || data.Percent <= float64(discordMem) { + return Embed{}, false + } + fields := []EmbedField{ + cleanField(tr("usage"), fmt.Sprintf("%.2f%%", data.Percent), true), + cleanField(tr("discord.fields.threshold"), fmt.Sprintf("%d%%", discordMem), true), + } + return Embed{ + Title: tr("discord.alerts.memoryHigh"), + Color: ColorOrange, + Timestamp: ts, + Fields: fields, + Footer: footer, + }, true + } + return Embed{}, false + + case eventbus.EventLoginAttempt: + var data *eventbus.LoginEventData + switch d := e.Data.(type) { + case *eventbus.LoginEventData: + data = d + case eventbus.LoginEventData: + data = &d + } + if data != nil { + if data.Status == "success" { + fields := []EmbedField{ + cleanField(tr("username"), data.Username, true), + cleanField("IP", data.IP, true), + } + if data.Time != "" { + fields = append(fields, cleanField(tr("discord.fields.time"), data.Time, true)) + } + return Embed{ + Title: tr("discord.alerts.loginSuccess"), + Color: ColorGreen, + Timestamp: ts, + Fields: fields, + Footer: footer, + }, true + } + fields := []EmbedField{ + cleanField(tr("username"), data.Username, true), + cleanField("IP", data.IP, true), + } + if data.Reason != "" { + fields = append(fields, cleanField(tr("discord.fields.reason"), data.Reason, false)) + } + if data.Time != "" { + fields = append(fields, cleanField(tr("discord.fields.time"), data.Time, true)) + } + return Embed{ + Title: tr("discord.alerts.loginFailed"), + Color: ColorRed, + Timestamp: ts, + Fields: fields, + Footer: footer, + }, true + } + fields := []EmbedField{ + cleanField(tr("discord.fields.source"), e.Source, true), + } + return Embed{ + Title: tr("discord.alerts.loginFailed"), + Color: ColorRed, + Timestamp: ts, + Fields: fields, + Footer: footer, + }, true + } + + return Embed{}, false +} diff --git a/internal/web/service/discord/subscriber_test.go b/internal/web/service/discord/subscriber_test.go new file mode 100644 index 000000000..0daba256b --- /dev/null +++ b/internal/web/service/discord/subscriber_test.go @@ -0,0 +1,510 @@ +package discord + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" +) + +func TestFormatEmbed_OutboundDownAndUp(t *testing.T) { + settingService := setupTestDB(t) + discordService := NewDiscordService(settingService) + sub := NewSubscriber(settingService, discordService) + + now := time.Date(2026, 9, 12, 12, 0, 0, 0, time.UTC) + + // Outbound down + downEvent := eventbus.Event{ + Type: eventbus.EventOutboundDown, + Source: "proxy-1", + Timestamp: now, + Data: &eventbus.OutboundHealthData{ + Delay: 500, + Error: "timeout connecting", + }, + } + embed, ok := sub.FormatEmbed(downEvent) + if !ok { + t.Fatal("expected embed to be formatted") + } + if embed.Color != ColorRed { + t.Errorf("expected ColorRed, got 0x%X", embed.Color) + } + if embed.Timestamp != "2026-09-12T12:00:00Z" { + t.Errorf("expected RFC3339 UTC timestamp, got %s", embed.Timestamp) + } + if len(embed.Fields) != 3 { + t.Fatalf("expected 3 fields, got %d", len(embed.Fields)) + } + + // Outbound up + upEvent := eventbus.Event{ + Type: eventbus.EventOutboundUp, + Source: "proxy-1", + Timestamp: now, + Data: &eventbus.OutboundHealthData{ + Delay: 120, + }, + } + embedUp, ok := sub.FormatEmbed(upEvent) + if !ok { + t.Fatal("expected embed to be formatted") + } + if embedUp.Color != ColorGreen { + t.Errorf("expected ColorGreen, got 0x%X", embedUp.Color) + } +} + +func TestFormatEmbed_NodeDownAndUp(t *testing.T) { + settingService := setupTestDB(t) + discordService := NewDiscordService(settingService) + sub := NewSubscriber(settingService, discordService) + + now := time.Now().UTC() + + // Node down + downEvent := eventbus.Event{ + Type: eventbus.EventNodeDown, + Source: "node-us", + Timestamp: now, + Data: &eventbus.NodeHealthData{ + XrayError: "connection refused", + }, + } + embed, ok := sub.FormatEmbed(downEvent) + if !ok { + t.Fatal("expected embed to be formatted") + } + if embed.Color != ColorRed { + t.Errorf("expected ColorRed, got 0x%X", embed.Color) + } + + // Node up + upEvent := eventbus.Event{ + Type: eventbus.EventNodeUp, + Source: "node-us", + Timestamp: now, + Data: &eventbus.NodeHealthData{ + LatencyMs: 45, + }, + } + embedUp, ok := sub.FormatEmbed(upEvent) + if !ok { + t.Fatal("expected embed to be formatted") + } + if embedUp.Color != ColorGreen { + t.Errorf("expected ColorGreen, got 0x%X", embedUp.Color) + } +} + +func TestFormatEmbed_XrayCrash(t *testing.T) { + settingService := setupTestDB(t) + discordService := NewDiscordService(settingService) + sub := NewSubscriber(settingService, discordService) + + crashEvent := eventbus.Event{ + Type: eventbus.EventXrayCrash, + Timestamp: time.Now().UTC(), + Data: "panic: core dump", + } + embed, ok := sub.FormatEmbed(crashEvent) + if !ok { + t.Fatal("expected embed to be formatted") + } + if embed.Color != ColorRed { + t.Errorf("expected ColorRed, got 0x%X", embed.Color) + } +} + +func TestFormatEmbed_CpuAndMemoryThresholds(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordCpu(80) + _ = settingService.SetDiscordMemory(75) + + discordService := NewDiscordService(settingService) + sub := NewSubscriber(settingService, discordService) + + now := time.Now().UTC() + + // CPU below threshold -> no embed + _, ok := sub.FormatEmbed(eventbus.Event{ + Type: eventbus.EventCPUHigh, + Timestamp: now, + Data: &eventbus.SystemMetricData{Percent: 79.5}, + }) + if ok { + t.Error("expected no embed when CPU is below threshold") + } + + // CPU above threshold -> Orange embed + embedCpu, ok := sub.FormatEmbed(eventbus.Event{ + Type: eventbus.EventCPUHigh, + Timestamp: now, + Data: &eventbus.SystemMetricData{Percent: 85.2}, + }) + if !ok { + t.Fatal("expected embed when CPU is above threshold") + } + if embedCpu.Color != ColorOrange { + t.Errorf("expected ColorOrange (0x%X), got 0x%X", ColorOrange, embedCpu.Color) + } + + // Memory below threshold -> no embed + _, ok = sub.FormatEmbed(eventbus.Event{ + Type: eventbus.EventMemoryHigh, + Timestamp: now, + Data: &eventbus.SystemMetricData{Percent: 70.0}, + }) + if ok { + t.Error("expected no embed when Memory is below threshold") + } + + // Memory above threshold -> Orange embed + embedMem, ok := sub.FormatEmbed(eventbus.Event{ + Type: eventbus.EventMemoryHigh, + Timestamp: now, + Data: &eventbus.SystemMetricData{Percent: 90.0}, + }) + if !ok { + t.Fatal("expected embed when Memory is above threshold") + } + if embedMem.Color != ColorOrange { + t.Errorf("expected ColorOrange (0x%X), got 0x%X", ColorOrange, embedMem.Color) + } +} + +func TestFormatEmbed_LoginAttempt(t *testing.T) { + settingService := setupTestDB(t) + discordService := NewDiscordService(settingService) + sub := NewSubscriber(settingService, discordService) + + now := time.Now().UTC() + + // Login success -> Green + successEvent := eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Timestamp: now, + Data: &eventbus.LoginEventData{ + Username: "admin", + IP: "1.2.3.4", + Time: "2026-09-12 12:00:00", + Status: "success", + }, + } + embedSuccess, ok := sub.FormatEmbed(successEvent) + if !ok { + t.Fatal("expected embed for login success") + } + if embedSuccess.Color != ColorGreen { + t.Errorf("expected ColorGreen, got 0x%X", embedSuccess.Color) + } + + // Login fail -> Red + failEvent := eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Timestamp: now, + Data: &eventbus.LoginEventData{ + Username: "attacker", + IP: "5.6.7.8", + Time: "2026-09-12 12:01:00", + Status: "fail", + Reason: "wrong password", + }, + } + embedFail, ok := sub.FormatEmbed(failEvent) + if !ok { + t.Fatal("expected embed for login failure") + } + if embedFail.Color != ColorRed { + t.Errorf("expected ColorRed, got 0x%X", embedFail.Color) + } + + // Fallback when data is nil + fallbackEvent := eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Source: "unknown-source", + Timestamp: now, + } + embedFallback, ok := sub.FormatEmbed(fallbackEvent) + if !ok { + t.Fatal("expected embed for fallback login") + } + if embedFallback.Color != ColorRed { + t.Errorf("expected ColorRed, got 0x%X", embedFallback.Color) + } +} + +func TestCleanField_Protection(t *testing.T) { + field := cleanField("", " ", true) + if field.Name != "-" || field.Value != "-" { + t.Errorf("expected '-' for empty field name/value, got name=%q, value=%q", field.Name, field.Value) + } + + field2 := cleanField(" Name ", " Value ", false) + if field2.Name != "Name" || field2.Value != "Value" { + t.Errorf("expected trimmed name/value, got name=%q, value=%q", field2.Name, field2.Value) + } +} + +func TestHandleEvent_EndToEndWithServer(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotEnable(true) + _ = settingService.SetDiscordBotToken("test-token") + _ = settingService.SetDiscordChannelId("ch-test") + _ = settingService.SetDiscordEnabledEvents("login.attempt,outbound.down") + + receivedCh := make(chan MessagePayload, 10) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var p MessagePayload + _ = json.Unmarshal(body, &p) + receivedCh <- p + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + discordService := NewDiscordService(settingService) + discordService.SetBaseURL(server.URL) + discordService.SetHTTPClient(server.Client()) + + sub := NewSubscriber(settingService, discordService) + + // 1. Send enabled event (outbound.down) + sub.HandleEvent(eventbus.Event{ + Type: eventbus.EventOutboundDown, + Source: "out-1", + Timestamp: time.Now().UTC(), + }) + + select { + case p := <-receivedCh: + if len(p.Embeds) != 1 || p.Embeds[0].Color != ColorRed { + t.Errorf("unexpected payload: %+v", p) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for outbound.down message") + } + + // 2. Duplicate outbound.down within rate limit -> should be suppressed + sub.HandleEvent(eventbus.Event{ + Type: eventbus.EventOutboundDown, + Source: "out-1", + Timestamp: time.Now().UTC(), + }) + + select { + case p := <-receivedCh: + t.Fatalf("rate limited event was unexpectedly sent: %+v", p) + case <-time.After(150 * time.Millisecond): + // OK + } + + // 3. Login attempt bypasses rate limit + sub.HandleEvent(eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Timestamp: time.Now().UTC(), + Data: &eventbus.LoginEventData{ + Username: "admin", + IP: "1.1.1.1", + Status: "success", + }, + }) + sub.HandleEvent(eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Timestamp: time.Now().UTC(), + Data: &eventbus.LoginEventData{ + Username: "admin", + IP: "1.1.1.1", + Status: "success", + }, + }) + + // Both should arrive + for i := 0; i < 2; i++ { + select { + case <-receivedCh: + // OK + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for login attempt message %d", i+1) + } + } + + // 4. Disabled event type (cpu.high is not in discordEnabledEvents) + _ = settingService.SetDiscordCpu(50) + sub.HandleEvent(eventbus.Event{ + Type: eventbus.EventCPUHigh, + Timestamp: time.Now().UTC(), + Data: &eventbus.SystemMetricData{Percent: 99.0}, + }) + + select { + case p := <-receivedCh: + t.Fatalf("disabled event was unexpectedly sent: %+v", p) + case <-time.After(150 * time.Millisecond): + // OK + } + + // 5. Bot disabled entirely + _ = settingService.SetDiscordBotEnable(false) + sub.HandleEvent(eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Timestamp: time.Now().UTC(), + Data: &eventbus.LoginEventData{ + Username: "admin", + IP: "1.1.1.1", + Status: "success", + }, + }) + + select { + case p := <-receivedCh: + t.Fatalf("event sent while bot disabled: %+v", p) + case <-time.After(150 * time.Millisecond): + // OK + } +} + +func TestCleanField_Truncation(t *testing.T) { + longName := strings.Repeat("А", 300) // 300 runes of 2-byte UTF-8 + longValue := strings.Repeat("🔥", 1200) // 1200 runes of 4-byte UTF-8 + + field := cleanField(longName, longValue, false) + nameRunes := []rune(field.Name) + valRunes := []rune(field.Value) + + if len(nameRunes) > 256 { + t.Errorf("expected name runes <= 256, got %d", len(nameRunes)) + } + if !strings.HasSuffix(field.Name, "...") { + t.Errorf("expected truncated name to end with '...', got %s", field.Name) + } + + if len(valRunes) > 1024 { + t.Errorf("expected value runes <= 1024, got %d", len(valRunes)) + } + if !strings.HasSuffix(field.Value, "...") { + t.Errorf("expected truncated value to end with '...', got %s", field.Value) + } +} + +func TestHandleEvent_BelowThresholdDoesNotBurnRateLimiter(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotEnable(true) + _ = settingService.SetDiscordBotToken("test-token") + _ = settingService.SetDiscordChannelId("ch-test") + _ = settingService.SetDiscordEnabledEvents("cpu.high") + _ = settingService.SetDiscordCpu(80) + + receivedCh := make(chan MessagePayload, 5) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var p MessagePayload + _ = json.Unmarshal(body, &p) + receivedCh <- p + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + discordService := NewDiscordService(settingService) + discordService.SetBaseURL(server.URL) + discordService.SetHTTPClient(server.Client()) + + sub := NewSubscriber(settingService, discordService) + + // 1. CPU at 50% (below 80% threshold) - must NOT be sent and must NOT burn rate limiter + sub.HandleEvent(eventbus.Event{ + Type: eventbus.EventCPUHigh, + Timestamp: time.Now().UTC(), + Data: &eventbus.SystemMetricData{Percent: 50.0}, + }) + + select { + case p := <-receivedCh: + t.Fatalf("sub-threshold CPU event was unexpectedly sent: %+v", p) + case <-time.After(150 * time.Millisecond): + // OK + } + + // 2. CPU immediately spikes to 95% (above 80% threshold) - MUST be sent! + sub.HandleEvent(eventbus.Event{ + Type: eventbus.EventCPUHigh, + Timestamp: time.Now().UTC(), + Data: &eventbus.SystemMetricData{Percent: 95.0}, + }) + + select { + case p := <-receivedCh: + if len(p.Embeds) != 1 || p.Embeds[0].Color != ColorOrange { + t.Errorf("unexpected payload for critical CPU alert: %+v", p) + } + case <-time.After(2 * time.Second): + t.Fatal("critical CPU alert was incorrectly suppressed by rate limiter after below-threshold event") + } +} + +func TestFormatEmbed_ValueTypes(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordCpu(80) + _ = settingService.SetDiscordMemory(80) + sub := NewSubscriber(settingService, NewDiscordService(settingService)) + now := time.Now().UTC() + + // OutboundHealthData by value + embed, ok := sub.FormatEmbed(eventbus.Event{ + Type: eventbus.EventOutboundDown, + Source: "out-val", + Timestamp: now, + Data: eventbus.OutboundHealthData{ + Delay: 350, + Error: "connection lost", + }, + }) + if !ok || len(embed.Fields) != 3 { + t.Fatalf("expected 3 fields for OutboundDown value type, got ok=%v, fields=%d", ok, len(embed.Fields)) + } + + // NodeHealthData by value + embedNode, ok := sub.FormatEmbed(eventbus.Event{ + Type: eventbus.EventNodeUp, + Source: "node-val", + Timestamp: now, + Data: eventbus.NodeHealthData{ + LatencyMs: 25, + }, + }) + if !ok || len(embedNode.Fields) != 2 { + t.Fatalf("expected 2 fields for NodeUp value type, got ok=%v, fields=%d", ok, len(embedNode.Fields)) + } + + // SystemMetricData by value + embedCPU, ok := sub.FormatEmbed(eventbus.Event{ + Type: eventbus.EventCPUHigh, + Timestamp: now, + Data: eventbus.SystemMetricData{ + Percent: 90.0, + }, + }) + if !ok || embedCPU.Color != ColorOrange { + t.Fatalf("expected orange embed for CPU high value type, got ok=%v", ok) + } + + // LoginEventData by value + embedLogin, ok := sub.FormatEmbed(eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Timestamp: now, + Data: eventbus.LoginEventData{ + Username: "admin", + IP: "127.0.0.1", + Status: "success", + }, + }) + if !ok || embedLogin.Color != ColorGreen { + t.Fatalf("expected green embed for Login success value type, got ok=%v", ok) + } +} diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index df33b2ee3..1d1823c1b 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -206,6 +206,18 @@ var defaultValueMap = map[string]string{ "smtpFromName": "", "smtpTo": "", "smtpEncryptionType": "starttls", // no, starttls, tls + + // Discord bot notifications + "discordBotEnable": "false", + "discordBotToken": "", + "discordChannelId": "", + "discordAdminIds": "", + "discordRunTime": "@daily", + "discordBotBackup": "false", + "discordCpu": "80", + "discordMemory": "80", + "discordLang": "en-US", + "discordEnabledEvents": "login.attempt,cpu.high", } // SettingService provides business logic for application settings management. @@ -299,6 +311,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) { view.HasWarpSecret = secretConfigured(mustString(s.GetWarp())) view.HasNordSecret = secretConfigured(mustString(s.GetNord())) view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword) + view.HasDiscordBotToken = secretConfigured(allSetting.DiscordBotToken) var apiTokenCount int64 if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil { view.HasApiToken = apiTokenCount > 0 @@ -307,6 +320,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) { view.TwoFactorToken = "" view.LdapPassword = "" view.SmtpPassword = "" + view.DiscordBotToken = "" return view, nil } @@ -1320,6 +1334,88 @@ func (s *SettingService) SetSmtpMemory(value int) error { return s.setInt("smtpMemory", value) } +// Discord bot settings + +func (s *SettingService) GetDiscordBotEnable() (bool, error) { + return s.getBool("discordBotEnable") +} + +func (s *SettingService) SetDiscordBotEnable(value bool) error { + return s.setBool("discordBotEnable", value) +} + +func (s *SettingService) GetDiscordBotToken() (string, error) { + return s.getString("discordBotToken") +} + +func (s *SettingService) SetDiscordBotToken(value string) error { + return s.setString("discordBotToken", value) +} + +func (s *SettingService) GetDiscordChannelId() (string, error) { + return s.getString("discordChannelId") +} + +func (s *SettingService) SetDiscordChannelId(value string) error { + return s.setString("discordChannelId", value) +} + +func (s *SettingService) GetDiscordAdminIds() (string, error) { + return s.getString("discordAdminIds") +} + +func (s *SettingService) SetDiscordAdminIds(value string) error { + return s.setString("discordAdminIds", value) +} + +func (s *SettingService) GetDiscordEnabledEvents() (string, error) { + return s.getString("discordEnabledEvents") +} + +func (s *SettingService) SetDiscordEnabledEvents(events string) error { + return s.setString("discordEnabledEvents", events) +} + +func (s *SettingService) GetDiscordCpu() (int, error) { + return s.getInt("discordCpu") +} + +func (s *SettingService) SetDiscordCpu(value int) error { + return s.setInt("discordCpu", value) +} + +func (s *SettingService) GetDiscordMemory() (int, error) { + return s.getInt("discordMemory") +} + +func (s *SettingService) SetDiscordMemory(value int) error { + return s.setInt("discordMemory", value) +} + +func (s *SettingService) GetDiscordRunTime() (string, error) { + return s.getString("discordRunTime") +} + +func (s *SettingService) SetDiscordRunTime(value string) error { + return s.setString("discordRunTime", value) +} + +func (s *SettingService) GetDiscordBotBackup() (bool, error) { + return s.getBool("discordBotBackup") +} + +func (s *SettingService) SetDiscordBotBackup(value bool) error { + return s.setBool("discordBotBackup", value) +} + +func (s *SettingService) GetDiscordLang() (string, error) { + return s.getString("discordLang") +} + +func (s *SettingService) SetDiscordLang(value string) error { + return s.setString("discordLang", value) +} + // GetOutboundDownThreshold returns how many consecutive failed observatory // probes an outbound must accumulate before an outbound.down notification is // emitted. 1 preserves the legacy "notify on the first failed probe" behaviour. @@ -1335,9 +1431,10 @@ func (s *SettingService) SetOutboundDownThreshold(value int) error { // flag, a blank submitted secret means "unchanged" (the field is always served // blank to the browser) and the stored value is preserved. type SecretClears struct { - TgBotToken bool - LdapPassword bool - SmtpPassword bool + TgBotToken bool + LdapPassword bool + SmtpPassword bool + DiscordBotToken bool } func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error { @@ -1464,6 +1561,13 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, } allSetting.SmtpPassword = value } + if !clears.DiscordBotToken && strings.TrimSpace(allSetting.DiscordBotToken) == "" { + value, err := s.GetDiscordBotToken() + if err != nil { + return err + } + allSetting.DiscordBotToken = value + } return nil } @@ -1671,10 +1775,11 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { } var factoryDefaultSecretKeys = map[string]bool{ - "tgBotToken": true, - "twoFactorToken": true, - "ldapPassword": true, - "smtpPassword": true, + "tgBotToken": true, + "twoFactorToken": true, + "ldapPassword": true, + "smtpPassword": true, + "discordBotToken": true, } /* diff --git a/internal/web/service/setting_factory_defaults_test.go b/internal/web/service/setting_factory_defaults_test.go index 333c09673..422d31d31 100644 --- a/internal/web/service/setting_factory_defaults_test.go +++ b/internal/web/service/setting_factory_defaults_test.go @@ -58,6 +58,7 @@ func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) { "twoFactorToken", "ldapPassword", "smtpPassword", + "discordBotToken", } { t.Run(key, func(t *testing.T) { if _, ok := defaults[key]; ok { diff --git a/internal/web/service/setting_security_test.go b/internal/web/service/setting_security_test.go index 01060c36b..cb98fa8b4 100644 --- a/internal/web/service/setting_security_test.go +++ b/internal/web/service/setting_security_test.go @@ -76,6 +76,9 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) { if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil { t.Fatal(err) } + if err := s.saveSetting("discordBotToken", "discord-secret"); err != nil { + t.Fatal(err) + } if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil { t.Fatal(err) } @@ -84,10 +87,10 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) { if err != nil { t.Fatal(err) } - if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" { + if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" || view.DiscordBotToken != "" { t.Fatalf("settings view leaked secrets: %#v", view) } - if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword { + if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword || !view.HasDiscordBotToken { t.Fatalf("settings view did not report configured secret flags: %#v", view) } } @@ -110,6 +113,9 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) { if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil { t.Fatal(err) } + if err := s.saveSetting("discordBotToken", "discord-secret"); err != nil { + t.Fatal(err) + } view, err := s.GetAllSettingView() if err != nil { @@ -131,6 +137,9 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) { if got, _ := s.GetSmtpPassword(); got != "smtp-secret" { t.Fatalf("smtp password = %q, want preserved secret", got) } + if got, _ := s.GetDiscordBotToken(); got != "discord-secret" { + t.Fatalf("discord token = %q, want preserved secret", got) + } } func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) { @@ -145,6 +154,9 @@ func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) { if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil { t.Fatal(err) } + if err := s.saveSetting("discordBotToken", "discord-secret"); err != nil { + t.Fatal(err) + } view, err := s.GetAllSettingView() if err != nil { @@ -162,6 +174,9 @@ func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) { if got, _ := s.GetLdapPassword(); got != "ldap-secret" { t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got) } + if got, _ := s.GetDiscordBotToken(); got != "discord-secret" { + t.Fatalf("discord token = %q, unflagged secret must stay preserved", got) + } view, err = s.GetAllSettingView() if err != nil { @@ -170,7 +185,7 @@ func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) { if view.HasSmtpPassword { t.Fatal("hasSmtpPassword must report false after clearing") } - if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true}); err != nil { + if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true, DiscordBotToken: true}); err != nil { t.Fatal(err) } if got, _ := s.GetTgBotToken(); got != "" { @@ -179,6 +194,17 @@ func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) { if got, _ := s.GetLdapPassword(); got != "" { t.Fatalf("ldap password = %q, want cleared", got) } + if got, _ := s.GetDiscordBotToken(); got != "" { + t.Fatalf("discord token = %q, want cleared", got) + } + + view, err = s.GetAllSettingView() + if err != nil { + t.Fatal(err) + } + if view.HasDiscordBotToken { + t.Fatal("hasDiscordBotToken must report false after clearing") + } } func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) { diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index ed6ba10c1..464ec8a58 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "Network & TUN Engine", "subHappGroupThemes": "Appearance & Theme", "subHappGroupFailover": "Migration & App Management", - "subHappGroupAndroid": "Android Per-App Proxy" - + "subHappGroupAndroid": "Android Per-App Proxy", + "discordSettings": "بوت ديسكورد", + "discordBotEnable": "تفعيل إشعارات ديسكورد", + "discordBotEnableDesc": "إرسال تنبيهات النظام والأحداث إلى قناة ديسكورد عبر البوت", + "discordBotToken": "رمز بوت ديسكورد", + "discordBotTokenDesc": "رمز البوت من بوابة مطوري ديسكورد", + "discordTokenConfigured": "تم تكوين الرمز. أدخل رمزاً جديداً لاستبداله.", + "discordTokenPlaceholder": "أدخل رمز البوت", + "discordChannelId": "معرّف القناة", + "discordChannelIdDesc": "معرّف قناة ديسكورد حيث سيتم إرسال الإشعارات", + "discordAdminIds": "معرّفات المستخدمين المسؤولين", + "discordAdminIdsDesc": "معرّفات مستخدمي Discord المسموح لهم بتشغيل أوامر البوت، مفصولة بفواصل. تُتجاهل رسائل أي شخص آخر، والقائمة الفارغة تعطّل الأوامر.", + "discordEventBusNotify": "إشعارات ديسكورد", + "testDiscord": "إرسال إشعار تجريبي", + "testDiscordDesc": "إرسال إشعار تجريبي للتحقق من رمز البوت ومعرّف القناة", + "discordNotInitialized": "خدمة ديسكورد غير مهيأة", + "discordBotNotEnabled": "بوت ديسكورد غير مفعّل", + "discordTestFailed": "فشل اختبار ديسكورد", + "discordTestSuccess": "تم إرسال الإشعار التجريبي بنجاح", + "discordBotLanguage": "لغة بوت Discord", + "discordNotifyTime": "وقت الإشعار", + "discordNotifyTimeDesc": "عدد مرات إرسال بوت ديسكورد للتقارير الدورية. اختر فترة جاهزة، أو اختر «مخصص» لإدخال تعبير crontab.", + "discordNotifyBackup": "نسخة احتياطية لقاعدة البيانات", + "discordNotifyBackupDesc": "ابعت ملف النسخة الاحتياطية لقاعدة البيانات مع التقرير.", + "discordEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات Discord" }, "xray": { "save": "احفظ", @@ -2390,6 +2413,81 @@ "chooseInbound": "اختار الإدخال" } }, + "discord": { + "test": { + "title": "اختبار إشعارات Discord في 3x-ui", + "body": "هذا إشعار تجريبي يؤكد أن إشعارات Discord مُعدّة بشكل صحيح." + }, + "footer": "لوحة 3x-ui", + "fields": { + "panelVersion": "إصدار اللوحة", + "xrayCore": "نواة Xray", + "systemLoad": "حمل النظام", + "networkTraffic": "حركة الشبكة", + "totalUsed": "إجمالي المستخدم", + "quota": "الحصة", + "error": "خطأ", + "delay": "زمن الاستجابة", + "outbound": "الصادر", + "node": "العقدة", + "threshold": "الحد", + "reason": "السبب", + "time": "الوقت", + "source": "المصدر" + }, + "values": { + "uptime": "{{ .Days }} يوم {{ .Hours }} ساعة", + "traffic": "↑{{ .Up }} ↓{{ .Down }} (الإجمالي: {{ .Total }})", + "counts": "الإجمالي: {{ .Total }} | قارب على النفاد: {{ .Depleting }} | معطّل: {{ .Disabled }}", + "inbound": "البروتوكول: `{{ .Protocol }}` | المنفذ: `{{ .Port }}` | العملاء: `{{ .Clients }}` | حركة المرور: `↑{{ .Up }} ↓{{ .Down }}` | الحالة: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "الصادر متوقف", + "outboundUp": "الصادر يعمل", + "nodeDown": "العقدة متوقفة", + "nodeUp": "العقدة تعمل", + "xrayCrash": "تعطّلت نواة Xray", + "cpuHigh": "تم تجاوز حد المعالج", + "memoryHigh": "تم تجاوز حد الذاكرة", + "loginSuccess": "تسجيل دخول ناجح", + "loginFailed": "فشل تسجيل الدخول" + }, + "report": { + "title": "📊 تقرير حالة 3x-ui", + "summary": "تقرير دوري عن حالة الخادم والوكيل لـ **{{ .Host }}**", + "footer": "تقرير 3x-ui المجدول • الجدول: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 أوامر بوت Discord في 3x-ui", + "helpDescription": "الأوامر المتاحة لمراقبة خادم 3x-ui وإدارته:", + "helpStatus": "عرض حمل النظام والذاكرة والمعالج والاتصالات والمستخدمين المتصلين", + "helpReport": "إنشاء تقرير حالة كامل (مع نسخة احتياطية لقاعدة البيانات إن كانت مفعّلة)", + "helpBackup": "إرسال ملف النسخة الاحتياطية لقاعدة البيانات فورًا", + "helpUsage": "الاستعلام عن استهلاك حركة المرور والحصة وتاريخ الانتهاء لعميل", + "helpInbounds": "عرض كل الواردات المُعدّة مع المنافذ وإحصاءات العملاء", + "helpRestart": "إعادة تشغيل نواة Xray", + "helpHelp": "عرض قائمة الأوامر المتاحة هذه", + "statusTitle": "⚡ حالة خادم 3x-ui", + "statusDescription": "مقاييس التشغيل الحالية لـ **{{ .Host }}**", + "backupTitle": "🗄️ نسخة احتياطية لقاعدة البيانات", + "backupDescription": "أرشيف النسخة الاحتياطية لـ 3x-ui أُنشئ في `{{ .Time }}`", + "backupUnavailable": "❌ خدمة النسخ الاحتياطي غير متاحة", + "backupFailed": "❌ تعذّرت قراءة النسخة الاحتياطية لقاعدة البيانات: {{ .Error }}", + "usageHint": "⚠️ الاستخدام: `!usage ` أو `/usage `", + "usageTitle": "👤 استخدام العميل: {{ .Email }}", + "usageDescription": "الوارد: **{{ .Remark }}** (المنفذ {{ .Port }})", + "clientNotFound": "⚠️ لم يُعثر على العميل `{{ .Email }}` في أي وارد مُعدّ.", + "inboundsUnavailable": "❌ خدمة الواردات غير متاحة", + "inboundsFailed": "❌ تعذّر تحميل الواردات: {{ .Error }}", + "inboundsTitle": "🔌 الواردات المُعدّة", + "inboundsDescription": "إجمالي الواردات: **{{ .Count }}**", + "noInbounds": "ℹ️ لا توجد واردات مُعدّة.", + "xrayUnavailable": "❌ خدمة Xray غير متاحة", + "restarting": "🔄 جارٍ إعادة تشغيل نواة Xray...", + "restartFailed": "❌ تعذّرت إعادة تشغيل Xray: {{ .Error }}", + "restartSuccess": "✅ أُعيد تشغيل نواة Xray بنجاح." + } + }, "email": { "labelStatus": "الحالة", "labelOutbound": "الصادر", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index df291eb62..15019417f 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1705,8 +1705,31 @@ "subHappGroupNetwork": "Network & TUN Engine", "subHappGroupThemes": "Appearance & Theme", "subHappGroupFailover": "Migration & App Management", - "subHappGroupAndroid": "Android Per-App Proxy" - + "subHappGroupAndroid": "Android Per-App Proxy", + "discordSettings": "Discord Bot", + "discordBotEnable": "Enable Discord Notifications", + "discordBotEnableDesc": "Send system and event alerts to a Discord channel via bot", + "discordBotToken": "Discord Bot Token", + "discordBotTokenDesc": "Bot token from the Discord Developer Portal", + "discordTokenConfigured": "Token is configured. Enter a new token to replace it.", + "discordTokenPlaceholder": "Enter bot token", + "discordChannelId": "Channel ID", + "discordChannelIdDesc": "The Discord channel ID where notifications will be sent", + "discordAdminIds": "Admin User IDs", + "discordAdminIdsDesc": "Comma-separated Discord user IDs allowed to run bot commands. Messages from anyone else are ignored, and an empty list turns commands off.", + "discordEventBusNotify": "Discord Notifications", + "testDiscord": "Send Test Notification", + "testDiscordDesc": "Send a test notification to verify your bot token and channel ID", + "discordNotInitialized": "Discord service not initialized", + "discordBotNotEnabled": "Discord bot is not enabled", + "discordTestFailed": "Discord test failed", + "discordTestSuccess": "Test notification sent successfully", + "discordBotLanguage": "Discord Bot Language", + "discordNotifyTime": "Notification Time", + "discordNotifyTimeDesc": "How often the Discord bot sends periodic reports. Pick a preset interval, or choose Custom to enter a raw crontab expression.", + "discordNotifyBackup": "Database Backup", + "discordNotifyBackupDesc": "Send a database backup file with a report.", + "discordEventBusNotifyDesc": "Select which events trigger Discord notifications" }, "xray": { "save": "Save", @@ -2390,6 +2413,81 @@ "chooseInbound": "Choose an Inbound" } }, + "discord": { + "test": { + "title": "3x-ui Discord Notification Test", + "body": "This is a test notification confirming that Discord notifications are configured correctly." + }, + "footer": "3x-ui Panel", + "fields": { + "panelVersion": "Panel Version", + "xrayCore": "Xray Core", + "systemLoad": "System Load", + "networkTraffic": "Network Traffic", + "totalUsed": "Total Used", + "quota": "Quota", + "error": "Error", + "delay": "Delay", + "outbound": "Outbound", + "node": "Node", + "threshold": "Threshold", + "reason": "Reason", + "time": "Time", + "source": "Source" + }, + "values": { + "uptime": "{{ .Days }}d {{ .Hours }}h", + "traffic": "↑{{ .Up }} ↓{{ .Down }} (Total: {{ .Total }})", + "counts": "Total: {{ .Total }} | Depleting: {{ .Depleting }} | Disabled: {{ .Disabled }}", + "inbound": "Protocol: `{{ .Protocol }}` | Port: `{{ .Port }}` | Clients: `{{ .Clients }}` | Traffic: `↑{{ .Up }} ↓{{ .Down }}` | State: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "Outbound Down", + "outboundUp": "Outbound Up", + "nodeDown": "Node Down", + "nodeUp": "Node Up", + "xrayCrash": "Xray Core Crashed", + "cpuHigh": "CPU Threshold Exceeded", + "memoryHigh": "Memory Threshold Exceeded", + "loginSuccess": "Login Success", + "loginFailed": "Login Failed" + }, + "report": { + "title": "📊 3x-ui Status Report", + "summary": "Periodic server and proxy status report for **{{ .Host }}**", + "footer": "3x-ui Scheduled Report • Schedule: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 3x-ui Discord Bot Commands", + "helpDescription": "Available commands for monitoring and managing the 3x-ui server:", + "helpStatus": "Display system load, RAM, CPU, connections, and online users", + "helpReport": "Generate a full status report (with DB backup if configured)", + "helpBackup": "Send the database backup file immediately", + "helpUsage": "Query traffic usage, quota, and expiry for a client", + "helpInbounds": "List all configured inbounds with ports and client stats", + "helpRestart": "Restart the Xray core", + "helpHelp": "Display this list of available commands", + "statusTitle": "⚡ 3x-ui Server Status", + "statusDescription": "Current operational metrics for **{{ .Host }}**", + "backupTitle": "🗄️ Database Backup", + "backupDescription": "Backup archive for 3x-ui generated at `{{ .Time }}`", + "backupUnavailable": "❌ Backup service unavailable", + "backupFailed": "❌ Failed to read database backup: {{ .Error }}", + "usageHint": "⚠️ Usage: `!usage ` or `/usage `", + "usageTitle": "👤 Client Usage: {{ .Email }}", + "usageDescription": "Inbound: **{{ .Remark }}** (Port {{ .Port }})", + "clientNotFound": "⚠️ Client `{{ .Email }}` was not found in any configured inbound.", + "inboundsUnavailable": "❌ Inbound service unavailable", + "inboundsFailed": "❌ Failed to load inbounds: {{ .Error }}", + "inboundsTitle": "🔌 Configured Inbounds", + "inboundsDescription": "Total inbounds: **{{ .Count }}**", + "noInbounds": "ℹ️ No inbounds configured.", + "xrayUnavailable": "❌ Xray service unavailable", + "restarting": "🔄 Restarting Xray core...", + "restartFailed": "❌ Failed to restart Xray: {{ .Error }}", + "restartSuccess": "✅ Xray core restarted successfully." + } + }, "email": { "labelStatus": "Status", "labelOutbound": "Outbound", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index d5916d165..cde0968ee 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "Network & TUN Engine", "subHappGroupThemes": "Appearance & Theme", "subHappGroupFailover": "Migration & App Management", - "subHappGroupAndroid": "Android Per-App Proxy" - + "subHappGroupAndroid": "Android Per-App Proxy", + "discordSettings": "Bot de Discord", + "discordBotEnable": "Habilitar notificaciones de Discord", + "discordBotEnableDesc": "Enviar alertas del sistema y eventos a un canal de Discord a través del bot", + "discordBotToken": "Token del bot de Discord", + "discordBotTokenDesc": "Token del bot del Portal de Desarrolladores de Discord", + "discordTokenConfigured": "El token está configurado. Ingrese un nuevo token para reemplazarlo.", + "discordTokenPlaceholder": "Ingrese el token del bot", + "discordChannelId": "ID del canal", + "discordChannelIdDesc": "ID del canal de Discord donde se enviarán las notificaciones", + "discordAdminIds": "ID de usuarios administradores", + "discordAdminIdsDesc": "ID de usuarios de Discord, separados por comas, que pueden ejecutar comandos del bot. Los mensajes de cualquier otra persona se ignoran y una lista vacía desactiva los comandos.", + "discordEventBusNotify": "Notificaciones de Discord", + "testDiscord": "Enviar notificación de prueba", + "testDiscordDesc": "Enviar una notificación de prueba para verificar el token del bot y el ID del canal", + "discordNotInitialized": "Servicio de Discord no inicializado", + "discordBotNotEnabled": "El bot de Discord no está habilitado", + "discordTestFailed": "Prueba de Discord fallida", + "discordTestSuccess": "Notificación de prueba enviada con éxito", + "discordBotLanguage": "Idioma del Bot de Discord", + "discordNotifyTime": "Hora de Notificación del Bot de Discord", + "discordNotifyTimeDesc": "Con qué frecuencia el bot de Discord envía informes periódicos. Elige un intervalo predefinido o selecciona Personalizado para introducir una expresión crontab.", + "discordNotifyBackup": "Respaldo de Base de Datos", + "discordNotifyBackupDesc": "Incluir archivo de respaldo de base de datos con notificación de informe.", + "discordEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones de Discord" }, "xray": { "save": "Guardar configuración", @@ -2390,6 +2413,81 @@ "chooseInbound": "Elige un Inbound" } }, + "discord": { + "test": { + "title": "Prueba de notificación de Discord de 3x-ui", + "body": "Esta es una notificación de prueba que confirma que las notificaciones de Discord están configuradas correctamente." + }, + "footer": "Panel 3x-ui", + "fields": { + "panelVersion": "Versión del panel", + "xrayCore": "Núcleo Xray", + "systemLoad": "Carga del sistema", + "networkTraffic": "Tráfico de red", + "totalUsed": "Total usado", + "quota": "Cuota", + "error": "Error", + "delay": "Retraso", + "outbound": "Salida", + "node": "Nodo", + "threshold": "Umbral", + "reason": "Motivo", + "time": "Hora", + "source": "Origen" + }, + "values": { + "uptime": "{{ .Days }} d {{ .Hours }} h", + "traffic": "↑{{ .Up }} ↓{{ .Down }} (Total: {{ .Total }})", + "counts": "Total: {{ .Total }} | Por agotarse: {{ .Depleting }} | Deshabilitados: {{ .Disabled }}", + "inbound": "Protocolo: `{{ .Protocol }}` | Puerto: `{{ .Port }}` | Clientes: `{{ .Clients }}` | Tráfico: `↑{{ .Up }} ↓{{ .Down }}` | Estado: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "Salida caída", + "outboundUp": "Salida restablecida", + "nodeDown": "Nodo caído", + "nodeUp": "Nodo restablecido", + "xrayCrash": "El núcleo Xray se bloqueó", + "cpuHigh": "Umbral de CPU superado", + "memoryHigh": "Umbral de memoria superado", + "loginSuccess": "Inicio de sesión correcto", + "loginFailed": "Inicio de sesión fallido" + }, + "report": { + "title": "📊 Informe de estado de 3x-ui", + "summary": "Informe periódico del estado del servidor y del proxy de **{{ .Host }}**", + "footer": "Informe programado de 3x-ui • Programación: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 Comandos del bot de Discord de 3x-ui", + "helpDescription": "Comandos disponibles para supervisar y administrar el servidor 3x-ui:", + "helpStatus": "Muestra la carga del sistema, RAM, CPU, conexiones y usuarios en línea", + "helpReport": "Genera un informe de estado completo (con copia de seguridad de la BD si está configurada)", + "helpBackup": "Envía de inmediato el archivo de copia de seguridad de la base de datos", + "helpUsage": "Consulta el uso de tráfico, la cuota y la caducidad de un cliente", + "helpInbounds": "Lista todas las entradas configuradas con puertos y estadísticas de clientes", + "helpRestart": "Reinicia el núcleo Xray", + "helpHelp": "Muestra esta lista de comandos disponibles", + "statusTitle": "⚡ Estado del servidor 3x-ui", + "statusDescription": "Métricas operativas actuales de **{{ .Host }}**", + "backupTitle": "🗄️ Copia de seguridad de la base de datos", + "backupDescription": "Archivo de copia de seguridad de 3x-ui generado el `{{ .Time }}`", + "backupUnavailable": "❌ Servicio de copia de seguridad no disponible", + "backupFailed": "❌ No se pudo leer la copia de seguridad de la base de datos: {{ .Error }}", + "usageHint": "⚠️ Uso: `!usage ` o `/usage `", + "usageTitle": "👤 Uso del cliente: {{ .Email }}", + "usageDescription": "Entrada: **{{ .Remark }}** (puerto {{ .Port }})", + "clientNotFound": "⚠️ No se encontró el cliente `{{ .Email }}` en ninguna entrada configurada.", + "inboundsUnavailable": "❌ Servicio de entradas no disponible", + "inboundsFailed": "❌ No se pudieron cargar las entradas: {{ .Error }}", + "inboundsTitle": "🔌 Entradas configuradas", + "inboundsDescription": "Total de entradas: **{{ .Count }}**", + "noInbounds": "ℹ️ No hay entradas configuradas.", + "xrayUnavailable": "❌ Servicio de Xray no disponible", + "restarting": "🔄 Reiniciando el núcleo Xray...", + "restartFailed": "❌ No se pudo reiniciar Xray: {{ .Error }}", + "restartSuccess": "✅ El núcleo Xray se reinició correctamente." + } + }, "email": { "labelStatus": "Estado", "labelOutbound": "Saliente", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 40e54b741..f48131832 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "تنظیمات شبکه و TUN", "subHappGroupThemes": "ظاهر و پوسته برنامه", "subHappGroupFailover": "مهاجرت و مدیریت کلاینت", - "subHappGroupAndroid": "پراکسی برنامه‌های اندروید" - + "subHappGroupAndroid": "پراکسی برنامه‌های اندروید", + "discordSettings": "ربات دیسکورد", + "discordBotEnable": "فعال‌سازی اعلان‌های دیسکورد", + "discordBotEnableDesc": "ارسال هشدارهای سیستم و رویدادها به کانال دیسکورد از طریق ربات", + "discordBotToken": "توکن ربات دیسکورد", + "discordBotTokenDesc": "توکن ربات از پنل توسعه‌دهندگان دیسکورد", + "discordTokenConfigured": "توکن پیکربندی شده است. برای جایگزینی، توکن جدید را وارد کنید.", + "discordTokenPlaceholder": "توکن ربات را وارد کنید", + "discordChannelId": "شناسه کانال", + "discordChannelIdDesc": "شناسه کانال دیسکورد برای دریافت اعلان‌ها", + "discordAdminIds": "شناسه کاربران ادمین", + "discordAdminIdsDesc": "شناسه کاربران دیسکورد که مجاز به اجرای دستورات ربات هستند، جداشده با کاما. پیام‌های سایر افراد نادیده گرفته می‌شوند و خالی گذاشتن فهرست، دستورات را غیرفعال می‌کند.", + "discordEventBusNotify": "اعلان‌های دیسکورد", + "testDiscord": "ارسال اعلان آزمایشی", + "testDiscordDesc": "ارسال یک اعلان آزمایشی برای بررسی توکن ربات و شناسه کانال", + "discordNotInitialized": "سرویس دیسکورد راه‌اندازی نشده است", + "discordBotNotEnabled": "ربات دیسکورد فعال نیست", + "discordTestFailed": "آزمایش دیسکورد ناموفق بود", + "discordTestSuccess": "اعلان آزمایشی با موفقیت ارسال شد", + "discordBotLanguage": "زبان ربات دیسکورد", + "discordNotifyTime": "زمان نوتیفیکیشن", + "discordNotifyTimeDesc": "هر چند وقت یک‌بار ربات دیسکورد گزارش دوره‌ای بفرستد. یک بازهٔ آماده انتخاب کنید یا گزینهٔ سفارشی را بزنید تا عبارت crontab وارد کنید.", + "discordNotifyBackup": "پشتیبان‌گیری از دیتابیس", + "discordNotifyBackupDesc": "فایل پشتیبان‌دیتابیس را به‌همراه گزارش ارسال می‌کند", + "discordEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان دیسکورد را فعال می‌کنند" }, "xray": { "save": "ذخیره", @@ -2390,6 +2413,81 @@ "chooseInbound": "یک ورودی انتخاب کنید" } }, + "discord": { + "test": { + "title": "آزمایش اعلان دیسکورد 3x-ui", + "body": "این یک اعلان آزمایشی است که تأیید می‌کند اعلان‌های دیسکورد به‌درستی پیکربندی شده‌اند." + }, + "footer": "پنل 3x-ui", + "fields": { + "panelVersion": "نسخه پنل", + "xrayCore": "هسته Xray", + "systemLoad": "بار سیستم", + "networkTraffic": "ترافیک شبکه", + "totalUsed": "مجموع مصرف", + "quota": "سهمیه", + "error": "خطا", + "delay": "تأخیر", + "outbound": "خروجی", + "node": "نود", + "threshold": "آستانه", + "reason": "دلیل", + "time": "زمان", + "source": "منبع" + }, + "values": { + "uptime": "{{ .Days }} روز {{ .Hours }} ساعت", + "traffic": "↑{{ .Up }} ↓{{ .Down }} (مجموع: {{ .Total }})", + "counts": "مجموع: {{ .Total }} | در حال اتمام: {{ .Depleting }} | غیرفعال: {{ .Disabled }}", + "inbound": "پروتکل: `{{ .Protocol }}` | پورت: `{{ .Port }}` | کاربران: `{{ .Clients }}` | ترافیک: `↑{{ .Up }} ↓{{ .Down }}` | وضعیت: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "خروجی از دسترس خارج شد", + "outboundUp": "خروجی در دسترس است", + "nodeDown": "نود از دسترس خارج شد", + "nodeUp": "نود در دسترس است", + "xrayCrash": "هسته Xray از کار افتاد", + "cpuHigh": "عبور از آستانه پردازنده", + "memoryHigh": "عبور از آستانه حافظه", + "loginSuccess": "ورود موفق", + "loginFailed": "ورود ناموفق" + }, + "report": { + "title": "📊 گزارش وضعیت 3x-ui", + "summary": "گزارش دوره‌ای وضعیت سرور و پراکسی برای **{{ .Host }}**", + "footer": "گزارش زمان‌بندی‌شده 3x-ui • زمان‌بندی: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 دستورات ربات دیسکورد 3x-ui", + "helpDescription": "دستورات موجود برای پایش و مدیریت سرور 3x-ui:", + "helpStatus": "نمایش بار سیستم، رم، پردازنده، اتصالات و کاربران آنلاین", + "helpReport": "ایجاد گزارش کامل وضعیت (همراه پشتیبان پایگاه داده در صورت فعال بودن)", + "helpBackup": "ارسال فوری فایل پشتیبان پایگاه داده", + "helpUsage": "نمایش مصرف ترافیک، سهمیه و تاریخ انقضای یک کاربر", + "helpInbounds": "فهرست همه ورودی‌های پیکربندی‌شده همراه پورت‌ها و آمار کاربران", + "helpRestart": "راه‌اندازی مجدد هسته Xray", + "helpHelp": "نمایش همین فهرست دستورات", + "statusTitle": "⚡ وضعیت سرور 3x-ui", + "statusDescription": "شاخص‌های عملکرد فعلی **{{ .Host }}**", + "backupTitle": "🗄️ پشتیبان پایگاه داده", + "backupDescription": "آرشیو پشتیبان 3x-ui ایجادشده در `{{ .Time }}`", + "backupUnavailable": "❌ سرویس پشتیبان‌گیری در دسترس نیست", + "backupFailed": "❌ خواندن پشتیبان پایگاه داده ناموفق بود: {{ .Error }}", + "usageHint": "⚠️ نحوه استفاده: `!usage ` یا `/usage `", + "usageTitle": "👤 مصرف کاربر: {{ .Email }}", + "usageDescription": "ورودی: **{{ .Remark }}** (پورت {{ .Port }})", + "clientNotFound": "⚠️ کاربر `{{ .Email }}` در هیچ ورودی پیکربندی‌شده‌ای یافت نشد.", + "inboundsUnavailable": "❌ سرویس ورودی‌ها در دسترس نیست", + "inboundsFailed": "❌ بارگذاری ورودی‌ها ناموفق بود: {{ .Error }}", + "inboundsTitle": "🔌 ورودی‌های پیکربندی‌شده", + "inboundsDescription": "تعداد کل ورودی‌ها: **{{ .Count }}**", + "noInbounds": "ℹ️ هیچ ورودی‌ای پیکربندی نشده است.", + "xrayUnavailable": "❌ سرویس Xray در دسترس نیست", + "restarting": "🔄 در حال راه‌اندازی مجدد هسته Xray...", + "restartFailed": "❌ راه‌اندازی مجدد Xray ناموفق بود: {{ .Error }}", + "restartSuccess": "✅ هسته Xray با موفقیت راه‌اندازی مجدد شد." + } + }, "email": { "labelStatus": "وضعیت", "labelOutbound": "خروجی", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index e3b24e3ed..9468c6963 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "Network & TUN Engine", "subHappGroupThemes": "Appearance & Theme", "subHappGroupFailover": "Migration & App Management", - "subHappGroupAndroid": "Android Per-App Proxy" - + "subHappGroupAndroid": "Android Per-App Proxy", + "discordSettings": "Bot Discord", + "discordBotEnable": "Aktifkan Notifikasi Discord", + "discordBotEnableDesc": "Kirim peringatan sistem dan acara ke saluran Discord melalui bot", + "discordBotToken": "Token Bot Discord", + "discordBotTokenDesc": "Token bot dari Discord Developer Portal", + "discordTokenConfigured": "Token telah dikonfigurasi. Masukkan token baru untuk menggantinya.", + "discordTokenPlaceholder": "Masukkan token bot", + "discordChannelId": "ID Saluran", + "discordChannelIdDesc": "ID saluran Discord tempat notifikasi akan dikirim", + "discordAdminIds": "ID Pengguna Admin", + "discordAdminIdsDesc": "ID pengguna Discord yang boleh menjalankan perintah bot, dipisahkan koma. Pesan dari orang lain diabaikan, dan daftar kosong menonaktifkan perintah.", + "discordEventBusNotify": "Notifikasi Discord", + "testDiscord": "Kirim Notifikasi Uji", + "testDiscordDesc": "Kirim notifikasi uji untuk memverifikasi token bot dan ID saluran Anda", + "discordNotInitialized": "Layanan Discord belum diinisialisasi", + "discordBotNotEnabled": "Bot Discord belum diaktifkan", + "discordTestFailed": "Pengujian Discord gagal", + "discordTestSuccess": "Pemberitahuan uji coba berhasil dikirim", + "discordBotLanguage": "Bahasa Bot Discord", + "discordNotifyTime": "Waktu Notifikasi", + "discordNotifyTimeDesc": "Seberapa sering bot Discord mengirim laporan berkala. Pilih interval siap pakai, atau pilih Kustom untuk memasukkan ekspresi crontab.", + "discordNotifyBackup": "Cadangan Database", + "discordNotifyBackupDesc": "Kirim berkas cadangan database dengan laporan.", + "discordEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi Discord" }, "xray": { "save": "Simpan", @@ -2390,6 +2413,81 @@ "chooseInbound": "Pilih Inbound" } }, + "discord": { + "test": { + "title": "Uji Notifikasi Discord 3x-ui", + "body": "Ini adalah notifikasi uji yang memastikan notifikasi Discord telah dikonfigurasi dengan benar." + }, + "footer": "Panel 3x-ui", + "fields": { + "panelVersion": "Versi Panel", + "xrayCore": "Inti Xray", + "systemLoad": "Beban Sistem", + "networkTraffic": "Lalu Lintas Jaringan", + "totalUsed": "Total Terpakai", + "quota": "Kuota", + "error": "Galat", + "delay": "Latensi", + "outbound": "Outbound", + "node": "Node", + "threshold": "Ambang Batas", + "reason": "Alasan", + "time": "Waktu", + "source": "Sumber" + }, + "values": { + "uptime": "{{ .Days }} hari {{ .Hours }} jam", + "traffic": "↑{{ .Up }} ↓{{ .Down }} (Total: {{ .Total }})", + "counts": "Total: {{ .Total }} | Hampir habis: {{ .Depleting }} | Nonaktif: {{ .Disabled }}", + "inbound": "Protokol: `{{ .Protocol }}` | Port: `{{ .Port }}` | Klien: `{{ .Clients }}` | Lalu lintas: `↑{{ .Up }} ↓{{ .Down }}` | Status: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "Outbound Mati", + "outboundUp": "Outbound Aktif", + "nodeDown": "Node Mati", + "nodeUp": "Node Aktif", + "xrayCrash": "Inti Xray Mengalami Crash", + "cpuHigh": "Ambang Batas CPU Terlampaui", + "memoryHigh": "Ambang Batas Memori Terlampaui", + "loginSuccess": "Login Berhasil", + "loginFailed": "Login Gagal" + }, + "report": { + "title": "📊 Laporan Status 3x-ui", + "summary": "Laporan berkala status server dan proxy untuk **{{ .Host }}**", + "footer": "Laporan Terjadwal 3x-ui • Jadwal: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 Perintah Bot Discord 3x-ui", + "helpDescription": "Perintah yang tersedia untuk memantau dan mengelola server 3x-ui:", + "helpStatus": "Tampilkan beban sistem, RAM, CPU, koneksi, dan pengguna online", + "helpReport": "Buat laporan status lengkap (dengan cadangan DB jika dikonfigurasi)", + "helpBackup": "Kirim file cadangan database sekarang juga", + "helpUsage": "Cek pemakaian lalu lintas, kuota, dan masa berlaku klien", + "helpInbounds": "Daftar semua inbound yang dikonfigurasi beserta port dan statistik klien", + "helpRestart": "Mulai ulang inti Xray", + "helpHelp": "Tampilkan daftar perintah ini", + "statusTitle": "⚡ Status Server 3x-ui", + "statusDescription": "Metrik operasional terkini untuk **{{ .Host }}**", + "backupTitle": "🗄️ Cadangan Database", + "backupDescription": "Arsip cadangan 3x-ui dibuat pada `{{ .Time }}`", + "backupUnavailable": "❌ Layanan cadangan tidak tersedia", + "backupFailed": "❌ Gagal membaca cadangan database: {{ .Error }}", + "usageHint": "⚠️ Penggunaan: `!usage ` atau `/usage `", + "usageTitle": "👤 Pemakaian Klien: {{ .Email }}", + "usageDescription": "Inbound: **{{ .Remark }}** (Port {{ .Port }})", + "clientNotFound": "⚠️ Klien `{{ .Email }}` tidak ditemukan di inbound mana pun.", + "inboundsUnavailable": "❌ Layanan inbound tidak tersedia", + "inboundsFailed": "❌ Gagal memuat inbound: {{ .Error }}", + "inboundsTitle": "🔌 Inbound yang Dikonfigurasi", + "inboundsDescription": "Total inbound: **{{ .Count }}**", + "noInbounds": "ℹ️ Belum ada inbound yang dikonfigurasi.", + "xrayUnavailable": "❌ Layanan Xray tidak tersedia", + "restarting": "🔄 Memulai ulang inti Xray...", + "restartFailed": "❌ Gagal memulai ulang Xray: {{ .Error }}", + "restartSuccess": "✅ Inti Xray berhasil dimulai ulang." + } + }, "email": { "labelStatus": "Status", "labelOutbound": "Outbound", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 3175228e3..de8d731fc 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "Network & TUN Engine", "subHappGroupThemes": "Appearance & Theme", "subHappGroupFailover": "Migration & App Management", - "subHappGroupAndroid": "Android Per-App Proxy" - + "subHappGroupAndroid": "Android Per-App Proxy", + "discordSettings": "Discord Bot", + "discordBotEnable": "Discord通知を有効化", + "discordBotEnableDesc": "Botを介してDiscordチャンネルにシステムおよびイベント通知を送信します", + "discordBotToken": "Discord Botトークン", + "discordBotTokenDesc": "Discord Developer PortalからのBotトークン", + "discordTokenConfigured": "トークンは設定されています。新しいトークンを入力して置き換えます。", + "discordTokenPlaceholder": "Botトークンを入力", + "discordChannelId": "チャンネルID", + "discordChannelIdDesc": "通知が送信されるDiscordチャンネルID", + "discordAdminIds": "管理者ユーザーID", + "discordAdminIdsDesc": "ボットコマンドの実行を許可するDiscordユーザーID(カンマ区切り)。それ以外のユーザーのメッセージは無視され、空欄の場合はコマンドが無効になります。", + "discordEventBusNotify": "Discord通知", + "testDiscord": "テスト通知を送信", + "testDiscordDesc": "BotトークンとチャンネルIDを確認するためのテスト通知を送信します", + "discordNotInitialized": "Discord サービスが初期化されていません", + "discordBotNotEnabled": "Discord ボットが有効になっていません", + "discordTestFailed": "Discord テストに失敗しました", + "discordTestSuccess": "テスト通知が正常に送信されました", + "discordBotLanguage": "Discord Botの言語", + "discordNotifyTime": "通知時間", + "discordNotifyTimeDesc": "Discord ボットが定期レポートを送信する頻度です。プリセットの間隔を選ぶか、「カスタム」を選んで crontab 式を入力します。", + "discordNotifyBackup": "データベースバックアップ", + "discordNotifyBackupDesc": "レポート付きのデータベースバックアップファイルを送信", + "discordEventBusNotifyDesc": "Discord通知をトリガーするイベントを選択してください" }, "xray": { "importRules": "ルールをインポート", @@ -2390,6 +2413,81 @@ "chooseInbound": "インバウンドを選択" } }, + "discord": { + "test": { + "title": "3x-ui Discord通知テスト", + "body": "これはDiscord通知が正しく設定されていることを確認するためのテスト通知です。" + }, + "footer": "3x-ui パネル", + "fields": { + "panelVersion": "パネルバージョン", + "xrayCore": "Xray コア", + "systemLoad": "システム負荷", + "networkTraffic": "ネットワークトラフィック", + "totalUsed": "合計使用量", + "quota": "クォータ", + "error": "エラー", + "delay": "遅延", + "outbound": "アウトバウンド", + "node": "ノード", + "threshold": "しきい値", + "reason": "理由", + "time": "時刻", + "source": "送信元" + }, + "values": { + "uptime": "{{ .Days }}日 {{ .Hours }}時間", + "traffic": "↑{{ .Up }} ↓{{ .Down }}(合計: {{ .Total }})", + "counts": "合計: {{ .Total }} | 残りわずか: {{ .Depleting }} | 無効: {{ .Disabled }}", + "inbound": "プロトコル: `{{ .Protocol }}` | ポート: `{{ .Port }}` | クライアント: `{{ .Clients }}` | トラフィック: `↑{{ .Up }} ↓{{ .Down }}` | 状態: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "アウトバウンド停止", + "outboundUp": "アウトバウンド復旧", + "nodeDown": "ノード停止", + "nodeUp": "ノード復旧", + "xrayCrash": "Xray コアがクラッシュしました", + "cpuHigh": "CPU しきい値超過", + "memoryHigh": "メモリしきい値超過", + "loginSuccess": "ログイン成功", + "loginFailed": "ログイン失敗" + }, + "report": { + "title": "📊 3x-ui ステータスレポート", + "summary": "**{{ .Host }}** のサーバーとプロキシの定期ステータスレポート", + "footer": "3x-ui 定期レポート • スケジュール: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 3x-ui Discord Bot コマンド", + "helpDescription": "3x-ui サーバーの監視と管理に使えるコマンド:", + "helpStatus": "システム負荷、RAM、CPU、接続数、オンラインユーザーを表示", + "helpReport": "完全なステータスレポートを生成(設定されていればDBバックアップ付き)", + "helpBackup": "データベースのバックアップファイルを今すぐ送信", + "helpUsage": "クライアントのトラフィック使用量、クォータ、有効期限を照会", + "helpInbounds": "設定済みのすべてのインバウンドをポートとクライアント統計付きで一覧表示", + "helpRestart": "Xray コアを再起動", + "helpHelp": "このコマンド一覧を表示", + "statusTitle": "⚡ 3x-ui サーバーステータス", + "statusDescription": "**{{ .Host }}** の現在の稼働メトリクス", + "backupTitle": "🗄️ データベースバックアップ", + "backupDescription": "`{{ .Time }}` に生成された 3x-ui のバックアップ", + "backupUnavailable": "❌ バックアップサービスを利用できません", + "backupFailed": "❌ データベースのバックアップを読み込めませんでした: {{ .Error }}", + "usageHint": "⚠️ 使い方: `!usage ` または `/usage `", + "usageTitle": "👤 クライアント使用状況: {{ .Email }}", + "usageDescription": "インバウンド: **{{ .Remark }}**(ポート {{ .Port }})", + "clientNotFound": "⚠️ クライアント `{{ .Email }}` は設定済みのどのインバウンドにも見つかりませんでした。", + "inboundsUnavailable": "❌ インバウンドサービスを利用できません", + "inboundsFailed": "❌ インバウンドを読み込めませんでした: {{ .Error }}", + "inboundsTitle": "🔌 設定済みインバウンド", + "inboundsDescription": "インバウンド総数: **{{ .Count }}**", + "noInbounds": "ℹ️ 設定済みのインバウンドはありません。", + "xrayUnavailable": "❌ Xray サービスを利用できません", + "restarting": "🔄 Xray コアを再起動しています...", + "restartFailed": "❌ Xray を再起動できませんでした: {{ .Error }}", + "restartSuccess": "✅ Xray コアを再起動しました。" + } + }, "email": { "labelStatus": "ステータス", "labelOutbound": "アウトバウンド", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index e5e7491d4..c2174e1b3 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "Network & TUN Engine", "subHappGroupThemes": "Appearance & Theme", "subHappGroupFailover": "Migration & App Management", - "subHappGroupAndroid": "Android Per-App Proxy" - + "subHappGroupAndroid": "Android Per-App Proxy", + "discordSettings": "Bot do Discord", + "discordBotEnable": "Ativar notificações do Discord", + "discordBotEnableDesc": "Enviar alertas de sistema e eventos para um canal do Discord via bot", + "discordBotToken": "Token do Bot do Discord", + "discordBotTokenDesc": "Token do bot do Portal de Desenvolvedores do Discord", + "discordTokenConfigured": "O token está configurado. Insira um novo token para substituí-lo.", + "discordTokenPlaceholder": "Insira o token do bot", + "discordChannelId": "ID do canal", + "discordChannelIdDesc": "ID do canal do Discord onde as notificações serão enviadas", + "discordAdminIds": "IDs de usuários administradores", + "discordAdminIdsDesc": "IDs de usuários do Discord, separados por vírgulas, que podem executar comandos do bot. Mensagens de qualquer outra pessoa são ignoradas e uma lista vazia desativa os comandos.", + "discordEventBusNotify": "Notificações do Discord", + "testDiscord": "Enviar notificação de teste", + "testDiscordDesc": "Enviar uma notificação de teste para verificar o token do bot e o ID do canal", + "discordNotInitialized": "Serviço Discord não inicializado", + "discordBotNotEnabled": "Bot do Discord não está ativado", + "discordTestFailed": "Teste do Discord falhou", + "discordTestSuccess": "Notificação de teste enviada com sucesso", + "discordBotLanguage": "Idioma do Bot do Discord", + "discordNotifyTime": "Hora da Notificação", + "discordNotifyTimeDesc": "Com que frequência o bot do Discord envia relatórios periódicos. Escolha um intervalo predefinido ou selecione Personalizado para inserir uma expressão crontab.", + "discordNotifyBackup": "Backup do Banco de Dados", + "discordNotifyBackupDesc": "Enviar arquivo de backup do banco de dados junto com o relatório.", + "discordEventBusNotifyDesc": "Selecione quais eventos disparam notificações no Discord" }, "xray": { "importRules": "Importar regras", @@ -2390,6 +2413,81 @@ "chooseInbound": "Escolha um Inbound" } }, + "discord": { + "test": { + "title": "Teste de notificação do Discord do 3x-ui", + "body": "Esta é uma notificação de teste confirmando que as notificações do Discord estão configuradas corretamente." + }, + "footer": "Painel 3x-ui", + "fields": { + "panelVersion": "Versão do painel", + "xrayCore": "Núcleo Xray", + "systemLoad": "Carga do sistema", + "networkTraffic": "Tráfego de rede", + "totalUsed": "Total usado", + "quota": "Cota", + "error": "Erro", + "delay": "Atraso", + "outbound": "Saída", + "node": "Nó", + "threshold": "Limite", + "reason": "Motivo", + "time": "Horário", + "source": "Origem" + }, + "values": { + "uptime": "{{ .Days }}d {{ .Hours }}h", + "traffic": "↑{{ .Up }} ↓{{ .Down }} (Total: {{ .Total }})", + "counts": "Total: {{ .Total }} | Esgotando: {{ .Depleting }} | Desativados: {{ .Disabled }}", + "inbound": "Protocolo: `{{ .Protocol }}` | Porta: `{{ .Port }}` | Clientes: `{{ .Clients }}` | Tráfego: `↑{{ .Up }} ↓{{ .Down }}` | Estado: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "Saída fora do ar", + "outboundUp": "Saída restabelecida", + "nodeDown": "Nó fora do ar", + "nodeUp": "Nó restabelecido", + "xrayCrash": "O núcleo Xray travou", + "cpuHigh": "Limite de CPU excedido", + "memoryHigh": "Limite de memória excedido", + "loginSuccess": "Login bem-sucedido", + "loginFailed": "Falha no login" + }, + "report": { + "title": "📊 Relatório de status do 3x-ui", + "summary": "Relatório periódico de status do servidor e do proxy de **{{ .Host }}**", + "footer": "Relatório agendado do 3x-ui • Agendamento: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 Comandos do bot do Discord do 3x-ui", + "helpDescription": "Comandos disponíveis para monitorar e gerenciar o servidor 3x-ui:", + "helpStatus": "Exibe carga do sistema, RAM, CPU, conexões e usuários online", + "helpReport": "Gera um relatório de status completo (com backup do BD, se configurado)", + "helpBackup": "Envia imediatamente o arquivo de backup do banco de dados", + "helpUsage": "Consulta uso de tráfego, cota e validade de um cliente", + "helpInbounds": "Lista todas as entradas configuradas com portas e estatísticas de clientes", + "helpRestart": "Reinicia o núcleo Xray", + "helpHelp": "Exibe esta lista de comandos disponíveis", + "statusTitle": "⚡ Status do servidor 3x-ui", + "statusDescription": "Métricas operacionais atuais de **{{ .Host }}**", + "backupTitle": "🗄️ Backup do banco de dados", + "backupDescription": "Arquivo de backup do 3x-ui gerado em `{{ .Time }}`", + "backupUnavailable": "❌ Serviço de backup indisponível", + "backupFailed": "❌ Falha ao ler o backup do banco de dados: {{ .Error }}", + "usageHint": "⚠️ Uso: `!usage ` ou `/usage `", + "usageTitle": "👤 Uso do cliente: {{ .Email }}", + "usageDescription": "Entrada: **{{ .Remark }}** (porta {{ .Port }})", + "clientNotFound": "⚠️ O cliente `{{ .Email }}` não foi encontrado em nenhuma entrada configurada.", + "inboundsUnavailable": "❌ Serviço de entradas indisponível", + "inboundsFailed": "❌ Falha ao carregar as entradas: {{ .Error }}", + "inboundsTitle": "🔌 Entradas configuradas", + "inboundsDescription": "Total de entradas: **{{ .Count }}**", + "noInbounds": "ℹ️ Nenhuma entrada configurada.", + "xrayUnavailable": "❌ Serviço do Xray indisponível", + "restarting": "🔄 Reiniciando o núcleo Xray...", + "restartFailed": "❌ Falha ao reiniciar o Xray: {{ .Error }}", + "restartSuccess": "✅ O núcleo Xray foi reiniciado com sucesso." + } + }, "email": { "labelStatus": "Status", "labelOutbound": "Outbound", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 46f4d06b4..cf7d4a6b9 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "Сетевые настройки и TUN", "subHappGroupThemes": "Внешний вид и темы", "subHappGroupFailover": "Миграция и управление", - "subHappGroupAndroid": "Прокси приложений Android" - + "subHappGroupAndroid": "Прокси приложений Android", + "discordSettings": "Discord бот", + "discordBotEnable": "Включить уведомления в Discord", + "discordBotEnableDesc": "Отправлять оповещения о событиях в канал Discord через бота", + "discordBotToken": "Токен Discord бота", + "discordBotTokenDesc": "Токен бота из Discord Developer Portal", + "discordTokenConfigured": "Токен настроен. Введите новый токен для замены.", + "discordTokenPlaceholder": "Введите токен бота", + "discordChannelId": "ID канала", + "discordChannelIdDesc": "ID канала Discord, куда будут отправляться уведомления", + "discordAdminIds": "ID администраторов", + "discordAdminIdsDesc": "ID пользователей Discord через запятую, которым разрешено выполнять команды бота. Сообщения остальных игнорируются, а пустой список отключает команды.", + "discordEventBusNotify": "Уведомления Discord", + "testDiscord": "Отправить тестовое уведомление", + "testDiscordDesc": "Отправить тестовое сообщение для проверки токена бота и ID канала", + "discordNotInitialized": "Служба Discord не инициализирована", + "discordBotNotEnabled": "Discord-бот не включен", + "discordTestFailed": "Тест Discord не удался", + "discordTestSuccess": "Тестовое уведомление успешно отправлено", + "discordBotLanguage": "Язык Discord-бота", + "discordNotifyTime": "Частота уведомлений от Discord-бота", + "discordNotifyTimeDesc": "Как часто бот Discord отправляет периодические отчёты. Выберите готовый интервал или «Произвольный», чтобы ввести выражение crontab.", + "discordNotifyBackup": "Резервное копирование базы данных", + "discordNotifyBackupDesc": "Отправлять уведомление с файлом резервной копии базы данных", + "discordEventBusNotifyDesc": "Выберите события для Discord уведомлений" }, "xray": { "importRules": "Импорт правил", @@ -2390,6 +2413,81 @@ "chooseInbound": "Выберите входящее подключение" } }, + "discord": { + "test": { + "title": "Тестовое уведомление Discord от 3x-ui", + "body": "Это тестовое уведомление подтверждает, что уведомления Discord настроены правильно." + }, + "footer": "Панель 3x-ui", + "fields": { + "panelVersion": "Версия панели", + "xrayCore": "Ядро Xray", + "systemLoad": "Нагрузка системы", + "networkTraffic": "Сетевой трафик", + "totalUsed": "Всего использовано", + "quota": "Лимит", + "error": "Ошибка", + "delay": "Задержка", + "outbound": "Исходящее", + "node": "Узел", + "threshold": "Порог", + "reason": "Причина", + "time": "Время", + "source": "Источник" + }, + "values": { + "uptime": "{{ .Days }} д {{ .Hours }} ч", + "traffic": "↑{{ .Up }} ↓{{ .Down }} (Всего: {{ .Total }})", + "counts": "Всего: {{ .Total }} | Почти исчерпано: {{ .Depleting }} | Отключено: {{ .Disabled }}", + "inbound": "Протокол: `{{ .Protocol }}` | Порт: `{{ .Port }}` | Клиенты: `{{ .Clients }}` | Трафик: `↑{{ .Up }} ↓{{ .Down }}` | Состояние: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "Исходящее недоступно", + "outboundUp": "Исходящее восстановлено", + "nodeDown": "Узел недоступен", + "nodeUp": "Узел снова в сети", + "xrayCrash": "Сбой ядра Xray", + "cpuHigh": "Превышен порог CPU", + "memoryHigh": "Превышен порог памяти", + "loginSuccess": "Успешный вход", + "loginFailed": "Неудачный вход" + }, + "report": { + "title": "📊 Отчёт о состоянии 3x-ui", + "summary": "Периодический отчёт о состоянии сервера и прокси для **{{ .Host }}**", + "footer": "Плановый отчёт 3x-ui • Расписание: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 Команды Discord-бота 3x-ui", + "helpDescription": "Доступные команды для мониторинга и управления сервером 3x-ui:", + "helpStatus": "Показать нагрузку системы, RAM, CPU, соединения и пользователей онлайн", + "helpReport": "Сформировать полный отчёт о состоянии (с резервной копией БД, если включена)", + "helpBackup": "Немедленно отправить файл резервной копии базы данных", + "helpUsage": "Узнать расход трафика, лимит и срок действия клиента", + "helpInbounds": "Список всех настроенных входящих подключений с портами и статистикой клиентов", + "helpRestart": "Перезапустить ядро Xray", + "helpHelp": "Показать этот список команд", + "statusTitle": "⚡ Состояние сервера 3x-ui", + "statusDescription": "Текущие рабочие показатели **{{ .Host }}**", + "backupTitle": "🗄️ Резервная копия базы данных", + "backupDescription": "Резервная копия 3x-ui создана `{{ .Time }}`", + "backupUnavailable": "❌ Служба резервного копирования недоступна", + "backupFailed": "❌ Не удалось прочитать резервную копию базы данных: {{ .Error }}", + "usageHint": "⚠️ Использование: `!usage ` или `/usage `", + "usageTitle": "👤 Использование клиента: {{ .Email }}", + "usageDescription": "Входящее: **{{ .Remark }}** (порт {{ .Port }})", + "clientNotFound": "⚠️ Клиент `{{ .Email }}` не найден ни в одном настроенном входящем подключении.", + "inboundsUnavailable": "❌ Служба входящих подключений недоступна", + "inboundsFailed": "❌ Не удалось загрузить входящие подключения: {{ .Error }}", + "inboundsTitle": "🔌 Настроенные входящие подключения", + "inboundsDescription": "Всего входящих подключений: **{{ .Count }}**", + "noInbounds": "ℹ️ Входящие подключения не настроены.", + "xrayUnavailable": "❌ Служба Xray недоступна", + "restarting": "🔄 Перезапуск ядра Xray...", + "restartFailed": "❌ Не удалось перезапустить Xray: {{ .Error }}", + "restartSuccess": "✅ Ядро Xray успешно перезапущено." + } + }, "email": { "labelStatus": "Статус", "labelOutbound": "Исходящее подключение", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 68f0f3a02..f2d0d9281 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "Network & TUN Engine", "subHappGroupThemes": "Appearance & Theme", "subHappGroupFailover": "Migration & App Management", - "subHappGroupAndroid": "Android Per-App Proxy" - + "subHappGroupAndroid": "Android Per-App Proxy", + "discordSettings": "Discord Botu", + "discordBotEnable": "Discord Bildirimlerini Etkinleştir", + "discordBotEnableDesc": "Sistem ve olay uyarılarını bot aracılığıyla bir Discord kanalına gönderin", + "discordBotToken": "Discord Bot Belirteci", + "discordBotTokenDesc": "Discord Geliştirici Portalından alınan bot belirteci", + "discordTokenConfigured": "Belirteç yapılandırıldı. Değiştirmek için yeni bir belirteç girin.", + "discordTokenPlaceholder": "Bot belirtecini girin", + "discordChannelId": "Kanal Kimliği", + "discordChannelIdDesc": "Bildirimlerin gönderileceği Discord kanal kimliği", + "discordAdminIds": "Yönetici Kullanıcı Kimlikleri", + "discordAdminIdsDesc": "Bot komutlarını çalıştırmasına izin verilen, virgülle ayrılmış Discord kullanıcı kimlikleri. Diğer herkesin mesajları yok sayılır; liste boşsa komutlar kapalıdır.", + "discordEventBusNotify": "Discord Bildirimleri", + "testDiscord": "Test Bildirimi Gönder", + "testDiscordDesc": "Bot belirtecinizi ve kanal kimliğinizi doğrulamak için bir test bildirimi gönderin", + "discordNotInitialized": "Discord servisi başlatılmadı", + "discordBotNotEnabled": "Discord botu etkin değil", + "discordTestFailed": "Discord testi başarısız oldu", + "discordTestSuccess": "Test bildirimi başarıyla gönderildi", + "discordBotLanguage": "Discord Bot Dili", + "discordNotifyTime": "Bildirim Zamanı", + "discordNotifyTimeDesc": "Discord botunun periyodik raporları gönderme sıklığı. Hazır bir aralık seçin veya bir crontab ifadesi girmek için Özel'i seçin.", + "discordNotifyBackup": "Veritabanı Yedeği", + "discordNotifyBackupDesc": "Bir rapor ile birlikte veritabanı yedek dosyasını gönderir.", + "discordEventBusNotifyDesc": "Hangi olayların Discord bildirimi tetikleyeceğini seçin" }, "xray": { "save": "Kaydet", @@ -2390,6 +2413,81 @@ "chooseInbound": "Bir Gelen Bağlantı Seçin" } }, + "discord": { + "test": { + "title": "3x-ui Discord Bildirim Testi", + "body": "Bu, Discord bildirimlerinin doğru yapılandırıldığını doğrulayan bir test bildirimidir." + }, + "footer": "3x-ui Paneli", + "fields": { + "panelVersion": "Panel Sürümü", + "xrayCore": "Xray Çekirdeği", + "systemLoad": "Sistem Yükü", + "networkTraffic": "Ağ Trafiği", + "totalUsed": "Toplam Kullanım", + "quota": "Kota", + "error": "Hata", + "delay": "Gecikme", + "outbound": "Giden", + "node": "Düğüm", + "threshold": "Eşik", + "reason": "Neden", + "time": "Zaman", + "source": "Kaynak" + }, + "values": { + "uptime": "{{ .Days }}g {{ .Hours }}s", + "traffic": "↑{{ .Up }} ↓{{ .Down }} (Toplam: {{ .Total }})", + "counts": "Toplam: {{ .Total }} | Tükenmek üzere: {{ .Depleting }} | Devre dışı: {{ .Disabled }}", + "inbound": "Protokol: `{{ .Protocol }}` | Port: `{{ .Port }}` | İstemciler: `{{ .Clients }}` | Trafik: `↑{{ .Up }} ↓{{ .Down }}` | Durum: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "Giden Bağlantı Kesildi", + "outboundUp": "Giden Bağlantı Aktif", + "nodeDown": "Düğüm Çevrimdışı", + "nodeUp": "Düğüm Çevrimiçi", + "xrayCrash": "Xray Çekirdeği Çöktü", + "cpuHigh": "CPU Eşiği Aşıldı", + "memoryHigh": "Bellek Eşiği Aşıldı", + "loginSuccess": "Giriş Başarılı", + "loginFailed": "Giriş Başarısız" + }, + "report": { + "title": "📊 3x-ui Durum Raporu", + "summary": "**{{ .Host }}** için periyodik sunucu ve proxy durum raporu", + "footer": "3x-ui Zamanlanmış Rapor • Zamanlama: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 3x-ui Discord Bot Komutları", + "helpDescription": "3x-ui sunucusunu izlemek ve yönetmek için kullanılabilir komutlar:", + "helpStatus": "Sistem yükünü, RAM'i, CPU'yu, bağlantıları ve çevrimiçi kullanıcıları gösterir", + "helpReport": "Tam durum raporu oluşturur (yapılandırıldıysa veritabanı yedeğiyle)", + "helpBackup": "Veritabanı yedek dosyasını hemen gönderir", + "helpUsage": "Bir istemcinin trafik kullanımını, kotasını ve bitiş tarihini sorgular", + "helpInbounds": "Yapılandırılmış tüm gelen bağlantıları portları ve istemci istatistikleriyle listeler", + "helpRestart": "Xray çekirdeğini yeniden başlatır", + "helpHelp": "Bu komut listesini gösterir", + "statusTitle": "⚡ 3x-ui Sunucu Durumu", + "statusDescription": "**{{ .Host }}** için güncel çalışma metrikleri", + "backupTitle": "🗄️ Veritabanı Yedeği", + "backupDescription": "`{{ .Time }}` tarihinde oluşturulan 3x-ui yedek arşivi", + "backupUnavailable": "❌ Yedekleme hizmeti kullanılamıyor", + "backupFailed": "❌ Veritabanı yedeği okunamadı: {{ .Error }}", + "usageHint": "⚠️ Kullanım: `!usage ` veya `/usage `", + "usageTitle": "👤 İstemci Kullanımı: {{ .Email }}", + "usageDescription": "Gelen: **{{ .Remark }}** (Port {{ .Port }})", + "clientNotFound": "⚠️ `{{ .Email }}` istemcisi yapılandırılmış hiçbir gelen bağlantıda bulunamadı.", + "inboundsUnavailable": "❌ Gelen bağlantı hizmeti kullanılamıyor", + "inboundsFailed": "❌ Gelen bağlantılar yüklenemedi: {{ .Error }}", + "inboundsTitle": "🔌 Yapılandırılmış Gelen Bağlantılar", + "inboundsDescription": "Toplam gelen bağlantı: **{{ .Count }}**", + "noInbounds": "ℹ️ Yapılandırılmış gelen bağlantı yok.", + "xrayUnavailable": "❌ Xray hizmeti kullanılamıyor", + "restarting": "🔄 Xray çekirdeği yeniden başlatılıyor...", + "restartFailed": "❌ Xray yeniden başlatılamadı: {{ .Error }}", + "restartSuccess": "✅ Xray çekirdeği başarıyla yeniden başlatıldı." + } + }, "email": { "labelStatus": "Durum", "labelOutbound": "Giden Bağlantı", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 12e8fbc29..485e10041 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "Network & TUN Engine", "subHappGroupThemes": "Appearance & Theme", "subHappGroupFailover": "Migration & App Management", - "subHappGroupAndroid": "Android Per-App Proxy" - + "subHappGroupAndroid": "Android Per-App Proxy", + "discordSettings": "Discord бот", + "discordBotEnable": "Увімкнути сповіщення Discord", + "discordBotEnableDesc": "Надсилати системні сповіщення та події в канал Discord через бота", + "discordBotToken": "Токен бота Discord", + "discordBotTokenDesc": "Токен бота з Discord Developer Portal", + "discordTokenConfigured": "Токен налаштовано. Введіть новий токен для заміни.", + "discordTokenPlaceholder": "Введіть токен бота", + "discordChannelId": "ID каналу", + "discordChannelIdDesc": "ID каналу Discord, куди надсилатимуться сповіщення", + "discordAdminIds": "ID адміністраторів", + "discordAdminIdsDesc": "ID користувачів Discord через кому, яким дозволено виконувати команди бота. Повідомлення інших ігноруються, а порожній список вимикає команди.", + "discordEventBusNotify": "Сповіщення Discord", + "testDiscord": "Надіслати тестове сповіщення", + "testDiscordDesc": "Надіслати тестове сповіщення для перевірки токена бота та ID каналу", + "discordNotInitialized": "Служба Discord не ініціалізована", + "discordBotNotEnabled": "Discord-бот не увімкнено", + "discordTestFailed": "Тест Discord не вдався", + "discordTestSuccess": "Тестове сповіщення успішно надіслано", + "discordBotLanguage": "Мова Discord-бота", + "discordNotifyTime": "Час сповіщення", + "discordNotifyTimeDesc": "Як часто бот Discord надсилає періодичні звіти. Виберіть готовий інтервал або «Власний», щоб ввести вираз crontab.", + "discordNotifyBackup": "Резервне копіювання бази даних", + "discordNotifyBackupDesc": "Надіслати файл резервної копії бази даних зі звітом.", + "discordEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення в Discord" }, "xray": { "save": "Зберегти", @@ -2390,6 +2413,81 @@ "chooseInbound": "Виберіть Вхідний" } }, + "discord": { + "test": { + "title": "Тестове сповіщення Discord від 3x-ui", + "body": "Це тестове сповіщення підтверджує, що сповіщення Discord налаштовано правильно." + }, + "footer": "Панель 3x-ui", + "fields": { + "panelVersion": "Версія панелі", + "xrayCore": "Ядро Xray", + "systemLoad": "Навантаження системи", + "networkTraffic": "Мережевий трафік", + "totalUsed": "Всього використано", + "quota": "Ліміт", + "error": "Помилка", + "delay": "Затримка", + "outbound": "Вихідне", + "node": "Вузол", + "threshold": "Поріг", + "reason": "Причина", + "time": "Час", + "source": "Джерело" + }, + "values": { + "uptime": "{{ .Days }} д {{ .Hours }} год", + "traffic": "↑{{ .Up }} ↓{{ .Down }} (Всього: {{ .Total }})", + "counts": "Всього: {{ .Total }} | Майже вичерпано: {{ .Depleting }} | Вимкнено: {{ .Disabled }}", + "inbound": "Протокол: `{{ .Protocol }}` | Порт: `{{ .Port }}` | Клієнти: `{{ .Clients }}` | Трафік: `↑{{ .Up }} ↓{{ .Down }}` | Стан: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "Вихідне недоступне", + "outboundUp": "Вихідне відновлено", + "nodeDown": "Вузол недоступний", + "nodeUp": "Вузол знову в мережі", + "xrayCrash": "Збій ядра Xray", + "cpuHigh": "Перевищено поріг CPU", + "memoryHigh": "Перевищено поріг пам'яті", + "loginSuccess": "Успішний вхід", + "loginFailed": "Невдалий вхід" + }, + "report": { + "title": "📊 Звіт про стан 3x-ui", + "summary": "Періодичний звіт про стан сервера та проксі для **{{ .Host }}**", + "footer": "Плановий звіт 3x-ui • Розклад: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 Команди Discord-бота 3x-ui", + "helpDescription": "Доступні команди для моніторингу та керування сервером 3x-ui:", + "helpStatus": "Показати навантаження системи, RAM, CPU, з'єднання та користувачів онлайн", + "helpReport": "Сформувати повний звіт про стан (з резервною копією БД, якщо увімкнено)", + "helpBackup": "Негайно надіслати файл резервної копії бази даних", + "helpUsage": "Дізнатися витрату трафіку, ліміт і термін дії клієнта", + "helpInbounds": "Список усіх налаштованих вхідних підключень із портами та статистикою клієнтів", + "helpRestart": "Перезапустити ядро Xray", + "helpHelp": "Показати цей список команд", + "statusTitle": "⚡ Стан сервера 3x-ui", + "statusDescription": "Поточні робочі показники **{{ .Host }}**", + "backupTitle": "🗄️ Резервна копія бази даних", + "backupDescription": "Резервну копію 3x-ui створено `{{ .Time }}`", + "backupUnavailable": "❌ Служба резервного копіювання недоступна", + "backupFailed": "❌ Не вдалося прочитати резервну копію бази даних: {{ .Error }}", + "usageHint": "⚠️ Використання: `!usage ` або `/usage `", + "usageTitle": "👤 Використання клієнта: {{ .Email }}", + "usageDescription": "Вхідне: **{{ .Remark }}** (порт {{ .Port }})", + "clientNotFound": "⚠️ Клієнта `{{ .Email }}` не знайдено в жодному налаштованому вхідному підключенні.", + "inboundsUnavailable": "❌ Служба вхідних підключень недоступна", + "inboundsFailed": "❌ Не вдалося завантажити вхідні підключення: {{ .Error }}", + "inboundsTitle": "🔌 Налаштовані вхідні підключення", + "inboundsDescription": "Усього вхідних підключень: **{{ .Count }}**", + "noInbounds": "ℹ️ Вхідні підключення не налаштовано.", + "xrayUnavailable": "❌ Служба Xray недоступна", + "restarting": "🔄 Перезапуск ядра Xray...", + "restartFailed": "❌ Не вдалося перезапустити Xray: {{ .Error }}", + "restartSuccess": "✅ Ядро Xray успішно перезапущено." + } + }, "email": { "labelStatus": "Статус", "labelOutbound": "Вихідне з'єднання", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 44b60d60a..274e0eec1 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "Network & TUN Engine", "subHappGroupThemes": "Appearance & Theme", "subHappGroupFailover": "Migration & App Management", - "subHappGroupAndroid": "Android Per-App Proxy" - + "subHappGroupAndroid": "Android Per-App Proxy", + "discordSettings": "Bot Discord", + "discordBotEnable": "Bật thông báo Discord", + "discordBotEnableDesc": "Gửi cảnh báo hệ thống và sự kiện đến kênh Discord qua bot", + "discordBotToken": "Token bot Discord", + "discordBotTokenDesc": "Token bot từ Discord Developer Portal", + "discordTokenConfigured": "Token đã được định cấu hình. Nhập token mới để thay thế.", + "discordTokenPlaceholder": "Nhập token bot", + "discordChannelId": "ID kênh", + "discordChannelIdDesc": "ID kênh Discord nơi thông báo sẽ được gửi", + "discordAdminIds": "ID người dùng quản trị", + "discordAdminIdsDesc": "ID người dùng Discord được phép chạy lệnh bot, phân tách bằng dấu phẩy. Tin nhắn của người khác bị bỏ qua, và danh sách trống sẽ tắt lệnh.", + "discordEventBusNotify": "Thông báo Discord", + "testDiscord": "Gửi thông báo thử nghiệm", + "testDiscordDesc": "Gửi thông báo thử nghiệm để xác minh token bot và ID kênh", + "discordNotInitialized": "Dịch vụ Discord chưa được khởi tạo", + "discordBotNotEnabled": "Bot Discord chưa được bật", + "discordTestFailed": "Kiểm tra Discord thất bại", + "discordTestSuccess": "Đã gửi thông báo thử nghiệm thành công", + "discordBotLanguage": "Ngôn ngữ của Bot Discord", + "discordNotifyTime": "Thời gian thông báo của bot Discord", + "discordNotifyTimeDesc": "Tần suất bot Discord gửi báo cáo định kỳ. Chọn một khoảng thời gian có sẵn, hoặc chọn Tùy chỉnh để nhập biểu thức crontab.", + "discordNotifyBackup": "Sao lưu Cơ sở dữ liệu", + "discordNotifyBackupDesc": "Bao gồm tệp sao lưu cơ sở dữ liệu với thông báo báo cáo.", + "discordEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua Discord" }, "xray": { "importRules": "Nhập quy tắc", @@ -2390,6 +2413,81 @@ "chooseInbound": "Chọn một Inbound" } }, + "discord": { + "test": { + "title": "Kiểm tra thông báo Discord của 3x-ui", + "body": "Đây là thông báo thử nghiệm xác nhận rằng thông báo Discord đã được cấu hình đúng." + }, + "footer": "Bảng điều khiển 3x-ui", + "fields": { + "panelVersion": "Phiên bản bảng điều khiển", + "xrayCore": "Lõi Xray", + "systemLoad": "Tải hệ thống", + "networkTraffic": "Lưu lượng mạng", + "totalUsed": "Tổng đã dùng", + "quota": "Hạn mức", + "error": "Lỗi", + "delay": "Độ trễ", + "outbound": "Outbound", + "node": "Nút", + "threshold": "Ngưỡng", + "reason": "Lý do", + "time": "Thời gian", + "source": "Nguồn" + }, + "values": { + "uptime": "{{ .Days }} ngày {{ .Hours }} giờ", + "traffic": "↑{{ .Up }} ↓{{ .Down }} (Tổng: {{ .Total }})", + "counts": "Tổng: {{ .Total }} | Sắp hết: {{ .Depleting }} | Đã tắt: {{ .Disabled }}", + "inbound": "Giao thức: `{{ .Protocol }}` | Cổng: `{{ .Port }}` | Máy khách: `{{ .Clients }}` | Lưu lượng: `↑{{ .Up }} ↓{{ .Down }}` | Trạng thái: `{{ .State }}`" + }, + "alerts": { + "outboundDown": "Outbound ngừng hoạt động", + "outboundUp": "Outbound hoạt động trở lại", + "nodeDown": "Nút ngừng hoạt động", + "nodeUp": "Nút hoạt động trở lại", + "xrayCrash": "Lõi Xray bị sập", + "cpuHigh": "Vượt ngưỡng CPU", + "memoryHigh": "Vượt ngưỡng bộ nhớ", + "loginSuccess": "Đăng nhập thành công", + "loginFailed": "Đăng nhập thất bại" + }, + "report": { + "title": "📊 Báo cáo trạng thái 3x-ui", + "summary": "Báo cáo định kỳ trạng thái máy chủ và proxy của **{{ .Host }}**", + "footer": "Báo cáo định kỳ 3x-ui • Lịch: {{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 Lệnh bot Discord của 3x-ui", + "helpDescription": "Các lệnh có sẵn để giám sát và quản lý máy chủ 3x-ui:", + "helpStatus": "Hiển thị tải hệ thống, RAM, CPU, kết nối và người dùng trực tuyến", + "helpReport": "Tạo báo cáo trạng thái đầy đủ (kèm bản sao lưu CSDL nếu được cấu hình)", + "helpBackup": "Gửi ngay tệp sao lưu cơ sở dữ liệu", + "helpUsage": "Tra cứu lưu lượng đã dùng, hạn mức và thời hạn của một máy khách", + "helpInbounds": "Liệt kê tất cả inbound đã cấu hình kèm cổng và thống kê máy khách", + "helpRestart": "Khởi động lại lõi Xray", + "helpHelp": "Hiển thị danh sách lệnh này", + "statusTitle": "⚡ Trạng thái máy chủ 3x-ui", + "statusDescription": "Chỉ số vận hành hiện tại của **{{ .Host }}**", + "backupTitle": "🗄️ Sao lưu cơ sở dữ liệu", + "backupDescription": "Bản sao lưu 3x-ui được tạo lúc `{{ .Time }}`", + "backupUnavailable": "❌ Dịch vụ sao lưu không khả dụng", + "backupFailed": "❌ Không thể đọc bản sao lưu cơ sở dữ liệu: {{ .Error }}", + "usageHint": "⚠️ Cách dùng: `!usage ` hoặc `/usage `", + "usageTitle": "👤 Mức dùng của máy khách: {{ .Email }}", + "usageDescription": "Inbound: **{{ .Remark }}** (Cổng {{ .Port }})", + "clientNotFound": "⚠️ Không tìm thấy máy khách `{{ .Email }}` trong bất kỳ inbound nào đã cấu hình.", + "inboundsUnavailable": "❌ Dịch vụ inbound không khả dụng", + "inboundsFailed": "❌ Không thể tải inbound: {{ .Error }}", + "inboundsTitle": "🔌 Các inbound đã cấu hình", + "inboundsDescription": "Tổng số inbound: **{{ .Count }}**", + "noInbounds": "ℹ️ Chưa cấu hình inbound nào.", + "xrayUnavailable": "❌ Dịch vụ Xray không khả dụng", + "restarting": "🔄 Đang khởi động lại lõi Xray...", + "restartFailed": "❌ Không thể khởi động lại Xray: {{ .Error }}", + "restartSuccess": "✅ Đã khởi động lại lõi Xray thành công." + } + }, "email": { "labelStatus": "Trạng thái", "labelOutbound": "Outbound", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index b58ad9686..c11cff237 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "网络与 TUN 引擎", "subHappGroupThemes": "界面与主题外观", "subHappGroupFailover": "迁移与客户端管理", - "subHappGroupAndroid": "Android 分应用代理" - + "subHappGroupAndroid": "Android 分应用代理", + "discordSettings": "Discord 机器人", + "discordBotEnable": "启用 Discord 通知", + "discordBotEnableDesc": "通过机器人向 Discord 频道发送系统和事件警报", + "discordBotToken": "Discord 机器人令牌", + "discordBotTokenDesc": "来自 Discord 开发者门户的机器人令牌", + "discordTokenConfigured": "令牌已配置。输入新令牌以替换。", + "discordTokenPlaceholder": "输入机器人令牌", + "discordChannelId": "频道 ID", + "discordChannelIdDesc": "接收通知的 Discord 频道 ID", + "discordAdminIds": "管理员用户 ID", + "discordAdminIdsDesc": "允许运行机器人命令的 Discord 用户 ID,以英文逗号分隔。其他人的消息会被忽略,列表为空时命令将被关闭。", + "discordEventBusNotify": "Discord 通知", + "testDiscord": "发送测试通知", + "testDiscordDesc": "发送测试通知以验证您的机器人令牌和频道 ID", + "discordNotInitialized": "Discord 服务未初始化", + "discordBotNotEnabled": "Discord 机器人未启用", + "discordTestFailed": "Discord 测试失败", + "discordTestSuccess": "测试通知发送成功", + "discordBotLanguage": "Discord 机器人语言", + "discordNotifyTime": "通知时间", + "discordNotifyTimeDesc": "Discord 机器人发送周期性报告的频率。选择预设间隔,或选择“自定义”以输入 crontab 表达式。", + "discordNotifyBackup": "数据库备份", + "discordNotifyBackupDesc": "发送带有报告的数据库备份文件", + "discordEventBusNotifyDesc": "选择触发 Discord 通知的事件" }, "xray": { "importRules": "导入规则", @@ -2390,6 +2413,81 @@ "chooseInbound": "选择一个入站" } }, + "discord": { + "test": { + "title": "3x-ui Discord 通知测试", + "body": "这是一条测试通知,确认 Discord 通知已正确配置。" + }, + "footer": "3x-ui 面板", + "fields": { + "panelVersion": "面板版本", + "xrayCore": "Xray 核心", + "systemLoad": "系统负载", + "networkTraffic": "网络流量", + "totalUsed": "已用总量", + "quota": "配额", + "error": "错误", + "delay": "延迟", + "outbound": "出站", + "node": "节点", + "threshold": "阈值", + "reason": "原因", + "time": "时间", + "source": "来源" + }, + "values": { + "uptime": "{{ .Days }}天 {{ .Hours }}小时", + "traffic": "↑{{ .Up }} ↓{{ .Down }}(总计:{{ .Total }})", + "counts": "总计:{{ .Total }} | 即将耗尽:{{ .Depleting }} | 已禁用:{{ .Disabled }}", + "inbound": "协议:`{{ .Protocol }}` | 端口:`{{ .Port }}` | 客户端:`{{ .Clients }}` | 流量:`↑{{ .Up }} ↓{{ .Down }}` | 状态:`{{ .State }}`" + }, + "alerts": { + "outboundDown": "出站已断开", + "outboundUp": "出站已恢复", + "nodeDown": "节点已离线", + "nodeUp": "节点已恢复", + "xrayCrash": "Xray 核心崩溃", + "cpuHigh": "CPU 超出阈值", + "memoryHigh": "内存超出阈值", + "loginSuccess": "登录成功", + "loginFailed": "登录失败" + }, + "report": { + "title": "📊 3x-ui 状态报告", + "summary": "**{{ .Host }}** 的服务器与代理定期状态报告", + "footer": "3x-ui 定时报告 • 计划:{{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 3x-ui Discord 机器人命令", + "helpDescription": "用于监控和管理 3x-ui 服务器的可用命令:", + "helpStatus": "显示系统负载、内存、CPU、连接数和在线用户", + "helpReport": "生成完整状态报告(如已配置则附带数据库备份)", + "helpBackup": "立即发送数据库备份文件", + "helpUsage": "查询客户端的流量使用、配额和到期时间", + "helpInbounds": "列出所有已配置的入站及其端口和客户端统计", + "helpRestart": "重启 Xray 核心", + "helpHelp": "显示此可用命令列表", + "statusTitle": "⚡ 3x-ui 服务器状态", + "statusDescription": "**{{ .Host }}** 的当前运行指标", + "backupTitle": "🗄️ 数据库备份", + "backupDescription": "3x-ui 备份生成于 `{{ .Time }}`", + "backupUnavailable": "❌ 备份服务不可用", + "backupFailed": "❌ 读取数据库备份失败:{{ .Error }}", + "usageHint": "⚠️ 用法:`!usage ` 或 `/usage `", + "usageTitle": "👤 客户端用量:{{ .Email }}", + "usageDescription": "入站:**{{ .Remark }}**(端口 {{ .Port }})", + "clientNotFound": "⚠️ 在任何已配置的入站中均未找到客户端 `{{ .Email }}`。", + "inboundsUnavailable": "❌ 入站服务不可用", + "inboundsFailed": "❌ 加载入站失败:{{ .Error }}", + "inboundsTitle": "🔌 已配置的入站", + "inboundsDescription": "入站总数:**{{ .Count }}**", + "noInbounds": "ℹ️ 尚未配置任何入站。", + "xrayUnavailable": "❌ Xray 服务不可用", + "restarting": "🔄 正在重启 Xray 核心...", + "restartFailed": "❌ 重启 Xray 失败:{{ .Error }}", + "restartSuccess": "✅ Xray 核心已成功重启。" + } + }, "email": { "labelStatus": "状态", "labelOutbound": "出站", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 51b110267..28a2b4d62 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1587,8 +1587,31 @@ "subHappGroupNetwork": "网络与 TUN 引擎", "subHappGroupThemes": "界面与主题外观", "subHappGroupFailover": "迁移与客户端管理", - "subHappGroupAndroid": "Android 分应用代理" - + "subHappGroupAndroid": "Android 分应用代理", + "discordSettings": "Discord 機器人", + "discordBotEnable": "啟用 Discord 通知", + "discordBotEnableDesc": "透過機器人向 Discord 頻道發送系統與事件警報", + "discordBotToken": "Discord 機器人權杖", + "discordBotTokenDesc": "來自 Discord 開發者門戶的機器人權杖", + "discordTokenConfigured": "權杖已設定。輸入新權杖以取代。", + "discordTokenPlaceholder": "輸入機器人權杖", + "discordChannelId": "頻道 ID", + "discordChannelIdDesc": "接收通知的 Discord 頻道 ID", + "discordAdminIds": "管理員使用者 ID", + "discordAdminIdsDesc": "允許執行機器人指令的 Discord 使用者 ID,以英文逗號分隔。其他人的訊息會被忽略,清單為空時指令將被停用。", + "discordEventBusNotify": "Discord 通知", + "testDiscord": "傳送測試通知", + "testDiscordDesc": "傳送測試通知以驗證您的機器人權杖與頻道 ID", + "discordNotInitialized": "Discord 服務未初始化", + "discordBotNotEnabled": "Discord 機器人未啟用", + "discordTestFailed": "Discord 測試失敗", + "discordTestSuccess": "測試通知發送成功", + "discordBotLanguage": "Discord 機器人語言", + "discordNotifyTime": "通知時間", + "discordNotifyTimeDesc": "Discord 機器人傳送週期性報告的頻率。選擇預設間隔,或選擇「自訂」以輸入 crontab 運算式。", + "discordNotifyBackup": "資料庫備份", + "discordNotifyBackupDesc": "傳送帶有報告的資料庫備份檔案", + "discordEventBusNotifyDesc": "選擇觸發 Discord 通知的事件" }, "xray": { "save": "儲存", @@ -2390,6 +2413,81 @@ "chooseInbound": "選擇一個入站" } }, + "discord": { + "test": { + "title": "3x-ui Discord 通知測試", + "body": "這是一則測試通知,確認 Discord 通知已正確設定。" + }, + "footer": "3x-ui 面板", + "fields": { + "panelVersion": "面板版本", + "xrayCore": "Xray 核心", + "systemLoad": "系統負載", + "networkTraffic": "網路流量", + "totalUsed": "已用總量", + "quota": "配額", + "error": "錯誤", + "delay": "延遲", + "outbound": "出站", + "node": "節點", + "threshold": "閾值", + "reason": "原因", + "time": "時間", + "source": "來源" + }, + "values": { + "uptime": "{{ .Days }}天 {{ .Hours }}小時", + "traffic": "↑{{ .Up }} ↓{{ .Down }}(總計:{{ .Total }})", + "counts": "總計:{{ .Total }} | 即將耗盡:{{ .Depleting }} | 已停用:{{ .Disabled }}", + "inbound": "協定:`{{ .Protocol }}` | 連接埠:`{{ .Port }}` | 用戶端:`{{ .Clients }}` | 流量:`↑{{ .Up }} ↓{{ .Down }}` | 狀態:`{{ .State }}`" + }, + "alerts": { + "outboundDown": "出站已中斷", + "outboundUp": "出站已恢復", + "nodeDown": "節點已離線", + "nodeUp": "節點已恢復", + "xrayCrash": "Xray 核心當機", + "cpuHigh": "CPU 超出閾值", + "memoryHigh": "記憶體超出閾值", + "loginSuccess": "登入成功", + "loginFailed": "登入失敗" + }, + "report": { + "title": "📊 3x-ui 狀態報告", + "summary": "**{{ .Host }}** 的伺服器與代理定期狀態報告", + "footer": "3x-ui 排程報告 • 排程:{{ .RunTime }}" + }, + "commands": { + "helpTitle": "🤖 3x-ui Discord 機器人指令", + "helpDescription": "用於監控和管理 3x-ui 伺服器的可用指令:", + "helpStatus": "顯示系統負載、記憶體、CPU、連線數和線上使用者", + "helpReport": "產生完整狀態報告(如已設定則附帶資料庫備份)", + "helpBackup": "立即傳送資料庫備份檔案", + "helpUsage": "查詢用戶端的流量使用、配額和到期時間", + "helpInbounds": "列出所有已設定的入站及其連接埠和用戶端統計", + "helpRestart": "重新啟動 Xray 核心", + "helpHelp": "顯示此可用指令清單", + "statusTitle": "⚡ 3x-ui 伺服器狀態", + "statusDescription": "**{{ .Host }}** 的目前運作指標", + "backupTitle": "🗄️ 資料庫備份", + "backupDescription": "3x-ui 備份產生於 `{{ .Time }}`", + "backupUnavailable": "❌ 備份服務無法使用", + "backupFailed": "❌ 讀取資料庫備份失敗:{{ .Error }}", + "usageHint": "⚠️ 用法:`!usage ` 或 `/usage `", + "usageTitle": "👤 用戶端用量:{{ .Email }}", + "usageDescription": "入站:**{{ .Remark }}**(連接埠 {{ .Port }})", + "clientNotFound": "⚠️ 在任何已設定的入站中均未找到用戶端 `{{ .Email }}`。", + "inboundsUnavailable": "❌ 入站服務無法使用", + "inboundsFailed": "❌ 載入入站失敗:{{ .Error }}", + "inboundsTitle": "🔌 已設定的入站", + "inboundsDescription": "入站總數:**{{ .Count }}**", + "noInbounds": "ℹ️ 尚未設定任何入站。", + "xrayUnavailable": "❌ Xray 服務無法使用", + "restarting": "🔄 正在重新啟動 Xray 核心...", + "restartFailed": "❌ 重新啟動 Xray 失敗:{{ .Error }}", + "restartSuccess": "✅ Xray 核心已成功重新啟動。" + } + }, "email": { "labelStatus": "狀態", "labelOutbound": "出站", diff --git a/internal/web/web.go b/internal/web/web.go index 702985d7d..d726039af 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -31,6 +31,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/network" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/discord" "github.com/mhsanaei/3x-ui/v3/internal/web/service/email" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" @@ -122,11 +123,14 @@ type Server struct { xrayService service.XrayService settingService service.SettingService tgbotService tgbot.Tgbot + discordService *discord.DiscordService + discordGateway *discord.GatewayClient wsHub *websocket.Hub - bus *eventbus.Bus - cron *cron.Cron + bus *eventbus.Bus + cron *cron.Cron + discordNotifyEntryID cron.EntryID ctx context.Context cancel context.CancelFunc @@ -409,6 +413,25 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) { _, _ = s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob()) } + // Discord-bot-dependent jobs: periodic stats report + database backup. + isDiscordEnabled, err := s.settingService.GetDiscordBotEnable() + if (err == nil) && isDiscordEnabled { + runtime, err := s.settingService.GetDiscordRunTime() + if err != nil { + logger.Warningf("Add NewDiscordNotifyJob: failed to load runtime: %v; using default @daily", err) + runtime = "@daily" + } else if strings.TrimSpace(runtime) == "" { + logger.Warning("Add NewDiscordNotifyJob runtime is empty, using default @daily") + runtime = "@daily" + } + logger.Infof("Discord notify enabled, run at %s", runtime) + if entryID, err := s.cron.AddJob(runtime, job.NewDiscordNotifyJob(s.discordService)); err != nil { + logger.Warningf("Add NewDiscordNotifyJob: failed to schedule runtime %q: %v", runtime, err) + } else { + s.discordNotifyEntryID = entryID + } + } + // CPU monitor publishes cpu.high events; register it whenever any notifier // (Telegram or Email) wants them, independent of the Telegram bot being on. if s.cpuAlarmWanted() { @@ -456,6 +479,13 @@ func (s *Server) cpuAlarmWanted() bool { return true } } + if on, _ := s.settingService.GetDiscordBotEnable(); on { + events, _ := s.settingService.GetDiscordEnabledEvents() + cpu, _ := s.settingService.GetDiscordCpu() + if wants(events, cpu) { + return true + } + } return false } @@ -486,6 +516,13 @@ func (s *Server) memoryAlarmWanted() bool { return true } } + if on, _ := s.settingService.GetDiscordBotEnable(); on { + events, _ := s.settingService.GetDiscordEnabledEvents() + mem, _ := s.settingService.GetDiscordMemory() + if wants(events, mem) { + return true + } + } return false } @@ -641,6 +678,48 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) { // Wire email service to controller for test endpoint controller.SetEmailService(emailService) + // Register discord subscriber (always — it checks discordBotEnable at runtime) + s.discordService = discord.NewDiscordService(s.settingService) + discordSub := discord.NewSubscriber(s.settingService, s.discordService) + s.bus.Subscribe("discord-notifier", discordSub.HandleEvent) + + // Wire discord service to controller for test endpoint + controller.SetDiscordService(s.discordService) + + serverService := &service.ServerService{} + inboundService := &service.InboundService{} + s.discordGateway = discord.NewGatewayClient(s.discordService, s.settingService, serverService, inboundService, &s.xrayService) + + // Wire reload discord callback for settings updates + controller.SetReloadDiscordFunc(func() { + if s.discordNotifyEntryID != 0 { + s.cron.Remove(s.discordNotifyEntryID) + s.discordNotifyEntryID = 0 + } + enabled, err := s.settingService.GetDiscordBotEnable() + if err != nil || !enabled { + if s.discordGateway != nil && s.discordGateway.IsRunning() { + s.discordGateway.Stop() + } + return + } + runtime, err := s.settingService.GetDiscordRunTime() + if err != nil || strings.TrimSpace(runtime) == "" { + runtime = "@daily" + } + entryID, err := s.cron.AddJob(runtime, job.NewDiscordNotifyJob(s.discordService)) + if err != nil { + logger.Warningf("Reload Discord notify: failed to schedule runtime %q: %v", runtime, err) + } else { + s.discordNotifyEntryID = entryID + logger.Infof("Discord notify rescheduled, run at %s", runtime) + } + + if s.discordGateway != nil && !s.discordGateway.IsRunning() { + _ = s.discordGateway.Start(s.ctx) + } + }) + // Wire Telegram test function to controller controller.SetTestTgFunc(func() error { if !s.tgbotService.IsRunning() { @@ -687,6 +766,11 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) { } } + isDiscordEnabled, err := s.settingService.GetDiscordBotEnable() + if (err == nil) && isDiscordEnabled && s.discordGateway != nil { + _ = s.discordGateway.Start(s.ctx) + } + return nil } @@ -723,6 +807,9 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error { if stopTgBot && s.tgbotService.IsRunning() { s.tgbotService.Stop() } + if s.discordGateway != nil && s.discordGateway.IsRunning() { + s.discordGateway.Stop() + } // Gracefully stop WebSocket hub if s.wsHub != nil { s.wsHub.Stop()