13 Komitmen cba8f0672f ... d0ad773edf

Pembuat SHA1 Pesan Tanggal
  Alireza Arezoumandan d0ad773edf fix(clients): preserve traffic reset schedule when toggling enable (#6502) 8 jam lalu
  ilyusha d600de2c2e feat(geodata): add standard source presets (#6504) 10 jam lalu
  BlindMaster24 ff1a6c3caf fix(sub): drop external Clash shadowsocks nodes the panel cannot express (#6508) 10 jam lalu
  BlindMaster24 8fc4fc0bf8 fix(link): rebuild shadowsocks tcp/http obfuscation on import (#6505) 10 jam lalu
  BlindMaster24 f3dba07e13 fix(link): read the vmess certificate checks on import (#6507) 10 jam lalu
  BlindMaster24 2fcd28c1bc refactor(tgbot): make the add-client expiry presets say what they do (#6503) 10 jam lalu
  BlindMaster24 1691c9ca2a fix(tgbot): keep the add-client draft with the chat that owns it (#6499) 10 jam lalu
  BlindMaster24 e98be4f72a fix(tgbot): render a disabled start-after-first-use client as days (#6500) 10 jam lalu
  BlindMaster24 d45a09d634 fix(discord): page the inbounds reply within Discord's embed caps (#6496) 10 jam lalu
  BlindMaster24 5c34baa8df fix(discord): drop the gateway connection when heartbeats go unanswered (#6497) 10 jam lalu
  BlindMaster24 cc60cefe02 fix(discord): report a start-after-first-use client as days, not unlimited (#6498) 10 jam lalu
  mrchatam 768bbd2a29 feat(settings): add setting for Reality scan candidates (#6471) 15 jam lalu
  Egor bf7ce2daaa feat(discord): add Discord notification bot service (#6486) 16 jam lalu
93 mengubah file dengan 8852 tambahan dan 1153 penghapusan
  1. 1 0
      .gitignore
  2. 1 0
      docs/content/docs/en/config/panel.mdx
  3. 121 0
      docs/content/docs/en/operations/discord-bot.mdx
  4. 1 0
      docs/content/docs/en/operations/meta.json
  5. 4 2
      docs/content/docs/en/reference/api/clients.mdx
  6. 8 6
      docs/content/docs/en/reference/api/server.mdx
  7. 8 1
      docs/content/docs/en/reference/api/settings.mdx
  8. 1 0
      docs/content/docs/fa/operations/meta.json
  9. 1 0
      docs/content/docs/ru/config/panel.mdx
  10. 121 0
      docs/content/docs/ru/operations/discord-bot.mdx
  11. 1 0
      docs/content/docs/ru/operations/meta.json
  12. 1 0
      docs/content/docs/zh/operations/meta.json
  13. 136 2
      docs/public/openapi.json
  14. 136 2
      frontend/public/openapi.json
  15. 8 0
      frontend/src/components/command-palette/CommandPalette.tsx
  16. 89 0
      frontend/src/components/ui/notifications/DiscordNotifications.stories.tsx
  17. 123 0
      frontend/src/components/ui/notifications/DiscordNotifications.tsx
  18. 23 0
      frontend/src/generated/examples.ts
  19. 100 0
      frontend/src/generated/schemas.ts
  20. 23 0
      frontend/src/generated/types.ts
  21. 23 0
      frontend/src/generated/zod.ts
  22. 2 0
      frontend/src/hooks/useClients.ts
  23. 6 0
      frontend/src/layouts/AppSidebar.tsx
  24. 27 0
      frontend/src/lib/xray/outbound-link-parser.ts
  25. 14 0
      frontend/src/models/setting.ts
  26. 8 2
      frontend/src/pages/api-docs/endpoints.ts
  27. 17 2
      frontend/src/pages/index/GeodataSection.tsx
  28. 201 0
      frontend/src/pages/settings/DiscordTab.tsx
  29. 19 0
      frontend/src/pages/settings/GeneralTab.tsx
  30. 151 0
      frontend/src/pages/settings/NotifyTimeField.tsx
  31. 4 0
      frontend/src/pages/settings/SettingsPage.tsx
  32. 3 147
      frontend/src/pages/settings/TelegramTab.tsx
  33. 12 0
      frontend/src/schemas/setting.ts
  34. 1 0
      frontend/src/schemas/xray.ts
  35. 52 0
      frontend/src/test/client-toggle-traffic-reset.test.tsx
  36. 79 0
      frontend/src/test/geodata-section.test.tsx
  37. 49 0
      frontend/src/test/outbound-link-parser.test.ts
  38. 4 6
      internal/sub/clash_external.go
  39. 118 0
      internal/sub/clash_external_quota_test.go
  40. 75 0
      internal/sub/clash_external_shadowsocks_test.go
  41. 3 2
      internal/sub/clash_service.go
  42. 49 0
      internal/sub/shadowsocks_plugin_import_test.go
  43. 60 0
      internal/sub/vmess_tls_import_test.go
  44. 56 0
      internal/util/link/outbound.go
  45. 44 0
      internal/util/link/outbound_test.go
  46. 2 3
      internal/web/controller/server.go
  47. 52 7
      internal/web/controller/setting.go
  48. 77 0
      internal/web/controller/setting_test.go
  49. 1 0
      internal/web/controller/xray_setting.go
  50. 30 17
      internal/web/entity/entity.go
  51. 47 0
      internal/web/job/discord_notify_job.go
  52. 28 4
      internal/web/locale/locale.go
  53. 296 0
      internal/web/service/discord/discord.go
  54. 355 0
      internal/web/service/discord/discord_test.go
  55. 754 0
      internal/web/service/discord/gateway.go
  56. 111 0
      internal/web/service/discord/gateway_delayed_expiry_test.go
  57. 64 0
      internal/web/service/discord/gateway_heartbeat_ack_test.go
  58. 218 0
      internal/web/service/discord/gateway_inbounds_limits_test.go
  59. 531 0
      internal/web/service/discord/gateway_test.go
  60. 99 0
      internal/web/service/discord/locale_test.go
  61. 241 0
      internal/web/service/discord/report.go
  62. 231 0
      internal/web/service/discord/report_test.go
  63. 357 0
      internal/web/service/discord/subscriber.go
  64. 510 0
      internal/web/service/discord/subscriber_test.go
  65. 24 3
      internal/web/service/reality_scan.go
  66. 16 0
      internal/web/service/reality_scan_test.go
  67. 16 0
      internal/web/service/server.go
  68. 21 0
      internal/web/service/server_geofile_test.go
  69. 117 7
      internal/web/service/setting.go
  70. 1 0
      internal/web/service/setting_factory_defaults_test.go
  71. 29 3
      internal/web/service/setting_security_test.go
  72. 56 18
      internal/web/service/tgbot/tgbot.go
  73. 61 0
      internal/web/service/tgbot/tgbot_add_client_expiry_test.go
  74. 36 34
      internal/web/service/tgbot/tgbot_client.go
  75. 178 0
      internal/web/service/tgbot/tgbot_client_draft_per_chat_test.go
  76. 58 0
      internal/web/service/tgbot/tgbot_client_expiry_test.go
  77. 25 62
      internal/web/service/tgbot/tgbot_draft_render_test.go
  78. 3 3
      internal/web/service/tgbot/tgbot_inbound.go
  79. 97 94
      internal/web/service/tgbot/tgbot_router.go
  80. 174 73
      internal/web/translation/ar-EG.json
  81. 114 13
      internal/web/translation/en-US.json
  82. 172 71
      internal/web/translation/es-ES.json
  83. 117 16
      internal/web/translation/fa-IR.json
  84. 176 75
      internal/web/translation/id-ID.json
  85. 177 76
      internal/web/translation/ja-JP.json
  86. 173 72
      internal/web/translation/pt-BR.json
  87. 116 15
      internal/web/translation/ru-RU.json
  88. 173 72
      internal/web/translation/tr-TR.json
  89. 174 73
      internal/web/translation/uk-UA.json
  90. 171 70
      internal/web/translation/vi-VN.json
  91. 116 15
      internal/web/translation/zh-CN.json
  92. 184 83
      internal/web/translation/zh-TW.json
  93. 89 2
      internal/web/web.go

+ 1 - 0
.gitignore

@@ -24,6 +24,7 @@ node_modules/
 
 # Ignore compiled binaries
 main
+3x-ui
 
 # Ignore OS specific files
 .DS_Store

+ 1 - 0
docs/content/docs/en/config/panel.mdx

@@ -67,6 +67,7 @@ These have their own settings groups and pages:
 
 <Cards>
   <Card title="Telegram bot" href="/docs/operations/telegram-bot" description="Token, chat IDs, alerts, and reports." />
+  <Card title="Discord bot" href="/docs/operations/discord-bot" description="Token, channel ID, and event alerts." />
   <Card title="Subscription" href="/docs/config/subscription" description="Subscription server, formats, and paths." />
   <Card title="Security" href="/docs/operations/security" description="2FA, IP limits, and hardening." />
 </Cards>

+ 121 - 0
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.
+
+<Callout type="info">
+  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.
+</Callout>
+
+## Set it up
+
+<Steps>
+
+<Step>
+### 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`).
+</Step>
+
+<Step>
+### 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.
+</Step>
+
+<Step>
+### 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.
+</Step>
+
+<Step>
+### 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.
+</Step>
+
+</Steps>
+
+## 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 <email>` | 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 |
+
+<Callout type="warn">
+  Login alerts report the attempted username and client IP address. Passwords are never logged or transmitted.
+</Callout>
+
+## 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.

+ 1 - 0
docs/content/docs/en/operations/meta.json

@@ -7,6 +7,7 @@
     "outbounds-routing",
     "backup-restore",
     "telegram-bot",
+    "discord-bot",
     "security"
   ]
 }

+ 4 - 2
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.

+ 8 - 6
docs/content/docs/en/reference/api/server.mdx

@@ -168,9 +168,10 @@ _openapi:
       title: Probe/discover REALITY targets and return each verdict ranked by
         feasibility then latency. Each comma-separated token may be a domain
         (validated with SNI), a bare IP, or a CIDR range (discovered without SNI
-        by reading the certificate domain). When empty, a built-in seed list is
-        probed.
-      url: '#probediscover-reality-targets-and-return-each-verdict-ranked-by-feasibility-then-latency-each-comma-separated-token-may-be-a-domain-validated-with-sni-a-bare-ip-or-a-cidr-range-discovered-without-sni-by-reading-the-certificate-domain-when-empty-a-built-in-seed-list-is-probed'
+        by reading the certificate domain). When empty, the
+        realityScanCandidates setting is probed (the built-in seed list if that
+        setting is empty).
+      url: '#probediscover-reality-targets-and-return-each-verdict-ranked-by-feasibility-then-latency-each-comma-separated-token-may-be-a-domain-validated-with-sni-a-bare-ip-or-a-cidr-range-discovered-without-sni-by-reading-the-certificate-domain-when-empty-the-realityscancandidates-setting-is-probed-the-built-in-seed-list-if-that-setting-is-empty'
     - depth: 2
       title: Fetch the fully aggregated inbound_client_ips database table. Used by
         nodes to sync recently active IPs across the cluster.
@@ -304,9 +305,10 @@ _openapi:
       - content: Probe/discover REALITY targets and return each verdict ranked by
           feasibility then latency. Each comma-separated token may be a domain
           (validated with SNI), a bare IP, or a CIDR range (discovered without
-          SNI by reading the certificate domain). When empty, a built-in seed
-          list is probed.
-        id: probediscover-reality-targets-and-return-each-verdict-ranked-by-feasibility-then-latency-each-comma-separated-token-may-be-a-domain-validated-with-sni-a-bare-ip-or-a-cidr-range-discovered-without-sni-by-reading-the-certificate-domain-when-empty-a-built-in-seed-list-is-probed
+          SNI by reading the certificate domain). When empty, the
+          realityScanCandidates setting is probed (the built-in seed list if
+          that setting is empty).
+        id: probediscover-reality-targets-and-return-each-verdict-ranked-by-feasibility-then-latency-each-comma-separated-token-may-be-a-domain-validated-with-sni-a-bare-ip-or-a-cidr-range-discovered-without-sni-by-reading-the-certificate-domain-when-empty-the-realityscancandidates-setting-is-probed-the-built-in-seed-list-if-that-setting-is-empty
       - content: Fetch the fully aggregated inbound_client_ips database table. Used by
           nodes to sync recently active IPs across the cluster.
         id: fetch-the-fully-aggregated-inbound_client_ips-database-table-used-by-nodes-to-sync-recently-active-ips-across-the-cluster

+ 8 - 1
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}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/testDiscord","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
     </>
   );
 }

+ 1 - 0
docs/content/docs/fa/operations/meta.json

@@ -7,6 +7,7 @@
     "outbounds-routing",
     "backup-restore",
     "telegram-bot",
+    "discord-bot",
     "security"
   ]
 }

+ 1 - 0
docs/content/docs/ru/config/panel.mdx

@@ -67,6 +67,7 @@ icon: SlidersHorizontal
 
 <Cards>
   <Card title="Бот Telegram" href="/docs/operations/telegram-bot" description="Токен, идентификаторы чатов, оповещения и отчёты." />
+  <Card title="Discord-бот" href="/docs/operations/discord-bot" description="Токен, ID канала и оповещения о событиях." />
   <Card title="Подписка" href="/docs/config/subscription" description="Сервер подписок, форматы и пути." />
   <Card title="Безопасность" href="/docs/operations/security" description="2FA, ограничения по IP и усиление защиты." />
 </Cards>

+ 121 - 0
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.
+
+<Callout type="info">
+  Уведомления и периодические отчёты отправляются через исходящие HTTPS-запросы к REST API Discord v10. Интерактивные команды бота работают через постоянное защищённое WebSocket-соединение с Discord Gateway.
+</Callout>
+
+## Настройка
+
+<Steps>
+
+<Step>
+### Создайте приложение и бота в 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`).
+</Step>
+
+<Step>
+### Пригласите бота на свой сервер 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. Скопируйте полученную ссылку внизу страницы, откройте её в браузере и добавьте бота на нужный сервер.
+</Step>
+
+<Step>
+### Скопируйте ID канала (Channel ID)
+
+1. В клиенте Discord включите режим разработчика: **Настройки пользователя** $\rightarrow$ **Расширенные** $\rightarrow$ **Режим разработчика** (Developer Mode).
+2. Нажмите правой кнопкой мыши по каналу, куда должны приходить уведомления и команды, и выберите **Копировать ID канала**.
+3. Убедитесь, что у бота есть права на просмотр и отправку сообщений в этот канал.
+</Step>
+
+<Step>
+### Настройте панель 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. Нажмите **Сохранить** для применения настроек.
+</Step>
+
+</Steps>
+
+## Команды бота
+
+Когда бот включён, он принимает текстовые команды в настроенном канале (поддерживаются префиксы `!` и `/`). Выполнять их могут только пользователи из списка **ID администраторов**; сообщения остальных игнорируются, а при пустом списке команды отключены. `!backup` и плановые резервные копии публикуют базу данных в канал, поэтому выбирайте канал, доступный только администраторам:
+
+| Команда | Описание |
+| ------- | -------- |
+| `!status` | Вывести нагрузку системы, память, процессор, число соединений и активных клиентов. |
+| `!report` | Немедленно сгенерировать и отправить подробный отчёт о состоянии сервера. |
+| `!backup` | Отправить файл резервной копии базы данных (`x-ui.db`) и `config.json`. |
+| `!usage <email>` | Запросить статистику трафика (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 и логина) |
+
+<Callout type="warn">
+  Оповещения о входе содержат только введённое имя пользователя и IP-адрес. Пароли никогда не логируются и не передаются.
+</Callout>
+
+## Параметры конфигурации
+
+| Параметр | По умолчанию | Описание |
+| -------- | ------------ | -------- |
+| `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`). Запросы бота будут автоматически направляться через этот прокси.

+ 1 - 0
docs/content/docs/ru/operations/meta.json

@@ -7,6 +7,7 @@
     "outbounds-routing",
     "backup-restore",
     "telegram-bot",
+    "discord-bot",
     "security"
   ]
 }

+ 1 - 0
docs/content/docs/zh/operations/meta.json

@@ -7,6 +7,7 @@
     "outbounds-routing",
     "backup-restore",
     "telegram-bot",
+    "discord-bot",
     "security"
   ]
 }

+ 136 - 2
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"
@@ -128,6 +162,9 @@
           "panelOutbound": {
             "type": "string"
           },
+          "realityScanCandidates": {
+            "type": "string"
+          },
           "remarkTemplate": {
             "type": "string"
           },
@@ -465,6 +502,16 @@
         },
         "required": [
           "datepicker",
+          "discordAdminIds",
+          "discordBotBackup",
+          "discordBotEnable",
+          "discordBotToken",
+          "discordChannelId",
+          "discordCpu",
+          "discordEnabledEvents",
+          "discordLang",
+          "discordMemory",
+          "discordRunTime",
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
@@ -494,6 +541,7 @@
           "outboundDownThreshold",
           "pageSize",
           "panelOutbound",
+          "realityScanCandidates",
           "remarkTemplate",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
@@ -606,6 +654,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 +704,9 @@
           "hasApiToken": {
             "type": "boolean"
           },
+          "hasDiscordBotToken": {
+            "type": "boolean"
+          },
           "hasLdapPassword": {
             "type": "boolean"
           },
@@ -724,6 +809,9 @@
           "panelOutbound": {
             "type": "string"
           },
+          "realityScanCandidates": {
+            "type": "string"
+          },
           "remarkTemplate": {
             "type": "string"
           },
@@ -1061,11 +1149,22 @@
         },
         "required": [
           "datepicker",
+          "discordAdminIds",
+          "discordBotBackup",
+          "discordBotEnable",
+          "discordBotToken",
+          "discordChannelId",
+          "discordCpu",
+          "discordEnabledEvents",
+          "discordLang",
+          "discordMemory",
+          "discordRunTime",
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
           "happLinkEnable",
           "hasApiToken",
+          "hasDiscordBotToken",
           "hasLdapPassword",
           "hasNordSecret",
           "hasSmtpPassword",
@@ -1097,6 +1196,7 @@
           "outboundDownThreshold",
           "pageSize",
           "panelOutbound",
+          "realityScanCandidates",
           "remarkTemplate",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
@@ -7308,7 +7408,7 @@
         "tags": [
           "Server"
         ],
-        "summary": "Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.",
+        "summary": "Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, the realityScanCandidates setting is probed (the built-in seed list if that setting is empty).",
         "operationId": "post_panel_api_server_scanRealityTargets",
         "requestBody": {
           "required": false,
@@ -7319,7 +7419,7 @@
                 "properties": {
                   "targets": {
                     "type": "string",
-                    "description": "Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, a built-in seed list is probed."
+                    "description": "Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, the realityScanCandidates setting is probed (the built-in seed list if that setting is empty)."
                   }
                 }
               }
@@ -12382,6 +12482,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": [

+ 136 - 2
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"
@@ -128,6 +162,9 @@
           "panelOutbound": {
             "type": "string"
           },
+          "realityScanCandidates": {
+            "type": "string"
+          },
           "remarkTemplate": {
             "type": "string"
           },
@@ -465,6 +502,16 @@
         },
         "required": [
           "datepicker",
+          "discordAdminIds",
+          "discordBotBackup",
+          "discordBotEnable",
+          "discordBotToken",
+          "discordChannelId",
+          "discordCpu",
+          "discordEnabledEvents",
+          "discordLang",
+          "discordMemory",
+          "discordRunTime",
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
@@ -494,6 +541,7 @@
           "outboundDownThreshold",
           "pageSize",
           "panelOutbound",
+          "realityScanCandidates",
           "remarkTemplate",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
@@ -606,6 +654,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 +704,9 @@
           "hasApiToken": {
             "type": "boolean"
           },
+          "hasDiscordBotToken": {
+            "type": "boolean"
+          },
           "hasLdapPassword": {
             "type": "boolean"
           },
@@ -724,6 +809,9 @@
           "panelOutbound": {
             "type": "string"
           },
+          "realityScanCandidates": {
+            "type": "string"
+          },
           "remarkTemplate": {
             "type": "string"
           },
@@ -1061,11 +1149,22 @@
         },
         "required": [
           "datepicker",
+          "discordAdminIds",
+          "discordBotBackup",
+          "discordBotEnable",
+          "discordBotToken",
+          "discordChannelId",
+          "discordCpu",
+          "discordEnabledEvents",
+          "discordLang",
+          "discordMemory",
+          "discordRunTime",
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
           "happLinkEnable",
           "hasApiToken",
+          "hasDiscordBotToken",
           "hasLdapPassword",
           "hasNordSecret",
           "hasSmtpPassword",
@@ -1097,6 +1196,7 @@
           "outboundDownThreshold",
           "pageSize",
           "panelOutbound",
+          "realityScanCandidates",
           "remarkTemplate",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
@@ -7308,7 +7408,7 @@
         "tags": [
           "Server"
         ],
-        "summary": "Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.",
+        "summary": "Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, the realityScanCandidates setting is probed (the built-in seed list if that setting is empty).",
         "operationId": "post_panel_api_server_scanRealityTargets",
         "requestBody": {
           "required": false,
@@ -7319,7 +7419,7 @@
                 "properties": {
                   "targets": {
                     "type": "string",
-                    "description": "Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, a built-in seed list is probed."
+                    "description": "Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, the realityScanCandidates setting is probed (the built-in seed list if that setting is empty)."
                   }
                 }
               }
@@ -12382,6 +12482,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": [

+ 8 - 0
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: <MailOutlined />,
       },
+      {
+        path: '/settings#discord',
+        title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`,
+        subtitle: t('pages.settings.discordSettings'),
+        keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'],
+        icon: <DiscordOutlined />,
+      },
       {
         path: '/settings#subscription',
         title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,

+ 89 - 0
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<typeof DiscordNotifications>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+function Demo({ initial }: { initial: AllSetting }) {
+  const [settings, setSettings] = useState(initial);
+  return (
+    <DiscordNotifications
+      allSetting={settings}
+      updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
+    />
+  );
+}
+
+const placeholderArgs = {
+  allSetting: new AllSetting(),
+  updateSetting: () => undefined,
+};
+
+export const NothingSelected: Story = {
+  args: placeholderArgs,
+  render: () => <Demo initial={new AllSetting()} />,
+};
+
+export const TypicalMonitoring: Story = {
+  args: placeholderArgs,
+  render: () => (
+    <Demo
+      initial={
+        new AllSetting({
+          discordBotEnable: true,
+          discordChannelId: '123456789012345678',
+          discordEnabledEvents: 'xray.crash,node.down,cpu.high,memory.high,login.attempt',
+          discordCpu: 85,
+          discordMemory: 90,
+        })
+      }
+    />
+  ),
+};
+
+export const EverythingEnabled: Story = {
+  args: placeholderArgs,
+  render: () => (
+    <Demo
+      initial={
+        new AllSetting({
+          discordBotEnable: true,
+          discordChannelId: '123456789012345678',
+          discordEnabledEvents:
+            'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
+          discordCpu: 70,
+          discordMemory: 75,
+        })
+      }
+    />
+  ),
+};

+ 123 - 0
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: <CloudServerOutlined />,
+    title: 'eventGroupOutbound',
+    events: [
+      { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
+      { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
+    ],
+  },
+  {
+    icon: <ThunderboltOutlined />,
+    title: 'eventGroupXray',
+    events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
+  },
+  {
+    icon: <DesktopOutlined />,
+    title: 'eventGroupNode',
+    events: [
+      { key: 'node.down', label: 'eventNodeDown', settingKey: '' },
+      { key: 'node.up', label: 'eventNodeUp', settingKey: '' },
+    ],
+  },
+  {
+    icon: <DashboardOutlined />,
+    title: 'eventGroupSystem',
+    events: [
+      {
+        key: 'cpu.high',
+        label: 'eventCPUHigh',
+        settingKey: 'discordCpu',
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber
+            size="small"
+            min={0}
+            max={100}
+            value={value}
+            onChange={onChange}
+            aria-label={ariaLabel}
+            style={{ width: 80 }}
+          />
+        ),
+      },
+      {
+        key: 'memory.high',
+        label: 'eventMemoryHigh',
+        settingKey: 'discordMemory',
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber
+            size="small"
+            min={0}
+            max={100}
+            value={value}
+            onChange={onChange}
+            aria-label={ariaLabel}
+            style={{ width: 80 }}
+          />
+        ),
+      },
+    ],
+  },
+  {
+    icon: <SafetyOutlined />,
+    title: 'eventGroupSecurity',
+    events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
+  },
+];
+
+interface Props {
+  allSetting: AllSetting;
+  updateSetting: (patch: Partial<AllSetting>) => 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 (
+    <NotificationLayout>
+      {GROUPS.map((group, i) => (
+        <NotificationGroup
+          key={i}
+          config={group}
+          selected={selected}
+          onToggle={toggle}
+          onToggleAll={toggleAll}
+          allSetting={allSetting}
+          updateSetting={updateSetting}
+        />
+      ))}
+    </NotificationLayout>
+  );
+}

+ 23 - 0
frontend/src/generated/examples.ts

@@ -2,6 +2,16 @@
 export const EXAMPLES: Record<string, unknown> = {
   "AllSetting": {
     "datepicker": "",
+    "discordAdminIds": "",
+    "discordBotBackup": false,
+    "discordBotEnable": false,
+    "discordBotToken": "",
+    "discordChannelId": "",
+    "discordCpu": 0,
+    "discordEnabledEvents": "",
+    "discordLang": "",
+    "discordMemory": 0,
+    "discordRunTime": "",
     "expireDiff": 0,
     "externalTrafficInformEnable": false,
     "externalTrafficInformURI": "",
@@ -31,6 +41,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "outboundDownThreshold": 1,
     "pageSize": 0,
     "panelOutbound": "",
+    "realityScanCandidates": "",
     "remarkTemplate": "",
     "restartXrayOnClientDisable": false,
     "sessionMaxAge": 1,
@@ -138,11 +149,22 @@ export const EXAMPLES: Record<string, unknown> = {
   },
   "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,
@@ -174,6 +196,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "outboundDownThreshold": 1,
     "pageSize": 0,
     "panelOutbound": "",
+    "realityScanCandidates": "",
     "remarkTemplate": "",
     "restartXrayOnClientDisable": false,
     "sessionMaxAge": 1,

+ 100 - 0
frontend/src/generated/schemas.ts

@@ -5,6 +5,40 @@ export const SCHEMAS: Record<string, unknown> = {
       "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"
@@ -102,6 +136,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "panelOutbound": {
         "type": "string"
       },
+      "realityScanCandidates": {
+        "type": "string"
+      },
       "remarkTemplate": {
         "type": "string"
       },
@@ -439,6 +476,16 @@ export const SCHEMAS: Record<string, unknown> = {
     },
     "required": [
       "datepicker",
+      "discordAdminIds",
+      "discordBotBackup",
+      "discordBotEnable",
+      "discordBotToken",
+      "discordChannelId",
+      "discordCpu",
+      "discordEnabledEvents",
+      "discordLang",
+      "discordMemory",
+      "discordRunTime",
       "expireDiff",
       "externalTrafficInformEnable",
       "externalTrafficInformURI",
@@ -468,6 +515,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "outboundDownThreshold",
       "pageSize",
       "panelOutbound",
+      "realityScanCandidates",
       "remarkTemplate",
       "restartXrayOnClientDisable",
       "sessionMaxAge",
@@ -580,6 +628,40 @@ export const SCHEMAS: Record<string, unknown> = {
       "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 +678,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "hasApiToken": {
         "type": "boolean"
       },
+      "hasDiscordBotToken": {
+        "type": "boolean"
+      },
       "hasLdapPassword": {
         "type": "boolean"
       },
@@ -698,6 +783,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "panelOutbound": {
         "type": "string"
       },
+      "realityScanCandidates": {
+        "type": "string"
+      },
       "remarkTemplate": {
         "type": "string"
       },
@@ -1035,11 +1123,22 @@ export const SCHEMAS: Record<string, unknown> = {
     },
     "required": [
       "datepicker",
+      "discordAdminIds",
+      "discordBotBackup",
+      "discordBotEnable",
+      "discordBotToken",
+      "discordChannelId",
+      "discordCpu",
+      "discordEnabledEvents",
+      "discordLang",
+      "discordMemory",
+      "discordRunTime",
       "expireDiff",
       "externalTrafficInformEnable",
       "externalTrafficInformURI",
       "happLinkEnable",
       "hasApiToken",
+      "hasDiscordBotToken",
       "hasLdapPassword",
       "hasNordSecret",
       "hasSmtpPassword",
@@ -1071,6 +1170,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "outboundDownThreshold",
       "pageSize",
       "panelOutbound",
+      "realityScanCandidates",
       "remarkTemplate",
       "restartXrayOnClientDisable",
       "sessionMaxAge",

+ 23 - 0
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;
@@ -38,6 +48,7 @@ export interface AllSetting {
   outboundDownThreshold: number;
   pageSize: number;
   panelOutbound: string;
+  realityScanCandidates: string;
   remarkTemplate: string;
   restartXrayOnClientDisable: boolean;
   sessionMaxAge: number;
@@ -146,11 +157,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;
@@ -182,6 +204,7 @@ export interface AllSettingView {
   outboundDownThreshold: number;
   pageSize: number;
   panelOutbound: string;
+  realityScanCandidates: string;
   remarkTemplate: string;
   restartXrayOnClientDisable: boolean;
   sessionMaxAge: number;

+ 23 - 0
frontend/src/generated/zod.ts

@@ -23,6 +23,16 @@ export type transportBits = z.infer<typeof transportBitsSchema>;
 
 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(),
@@ -52,6 +62,7 @@ export const AllSettingSchema = z.object({
   outboundDownThreshold: z.number().int().min(1).max(100),
   pageSize: z.number().int().min(0).max(1000),
   panelOutbound: z.string(),
+  realityScanCandidates: z.string(),
   remarkTemplate: z.string(),
   restartXrayOnClientDisable: z.boolean(),
   sessionMaxAge: z.number().int().min(1).max(525600),
@@ -161,11 +172,22 @@ export type AllSetting = z.infer<typeof AllSettingSchema>;
 
 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(),
@@ -197,6 +219,7 @@ export const AllSettingViewSchema = z.object({
   outboundDownThreshold: z.number().int().min(1).max(100),
   pageSize: z.number().int().min(0).max(1000),
   panelOutbound: z.string(),
+  realityScanCandidates: z.string(),
   remarkTemplate: z.string(),
   restartXrayOnClientDisable: z.boolean(),
   sessionMaxAge: z.number().int().min(1).max(525600),

+ 2 - 0
frontend/src/hooks/useClients.ts

@@ -697,6 +697,8 @@ export function useClients(options: UseClientsOptions = {}) {
         reset: Number(base.reset) || 0,
         resetDay: Number(base.resetDay) || 0,
         resetMax: Number(base.resetMax) || 0,
+        trafficReset: base.trafficReset || 'never',
+        trafficResetDay: Number(base.trafficResetDay) || 1,
         group: base.group || '',
         comment: base.comment || '',
         enable: !!enable,

+ 6 - 0
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: <MailOutlined />, label: t('pages.settings.emailSettings') },
+      {
+        key: '/settings#discord',
+        icon: <DiscordOutlined />,
+        label: t('pages.settings.discordSettings'),
+      },
       {
         key: '/settings#subscription',
         icon: <CloudServerOutlined />,

+ 27 - 0
frontend/src/lib/xray/outbound-link-parser.ts

@@ -388,6 +388,27 @@ function sanitizeFinalMaskQuicParams(parsed: Record<string, unknown>): void {
   }
 }
 
+// The panel exports tcp/http obfuscation as the SIP002 obfs-local plugin only,
+// so the header it stands for has to be rebuilt before the transport is applied.
+function applyObfsLocalPluginParams(params: URLSearchParams): void {
+  if (params.get('headerType') || params.get('type') === 'http') return;
+  const parts = (params.get('plugin') ?? '').split(';');
+  if (parts[0] !== 'obfs-local') return;
+  let obfs = '';
+  let host = '';
+  for (const part of parts.slice(1)) {
+    const eq = part.indexOf('=');
+    if (eq < 0) continue;
+    const key = part.slice(0, eq);
+    if (key === 'obfs') obfs = part.slice(eq + 1);
+    else if (key === 'obfs-host') host = part.slice(eq + 1);
+  }
+  if (obfs !== 'http') return;
+  params.set('type', 'tcp');
+  params.set('headerType', 'http');
+  if (host) params.set('host', host);
+}
+
 function applySecurityParams(stream: Raw, params: URLSearchParams): void {
   if (stream.security === 'tls') {
     const tls = stream.tlsSettings as Raw;
@@ -458,6 +479,11 @@ export function parseVmessLink(link: string): Raw | null {
       tls.serverName = json.sni ?? '';
       tls.fingerprint = json.fp ?? '';
       if (json.alpn) tls.alpn = (json.alpn as string).split(',');
+      // The vmess object names the certificate checks the url-param protocols
+      // pass through applySecurityParams, under the same short names.
+      if (typeof json.ech === 'string') tls.echConfigList = json.ech;
+      if (typeof json.vcn === 'string') tls.verifyPeerCertByName = json.vcn;
+      if (typeof json.pcs === 'string') tls.pinnedPeerCertSha256 = json.pcs;
     }
 
     const port = Number(json.port) || 443;
@@ -609,6 +635,7 @@ export function parseShadowsocksLink(link: string): Raw | null {
   const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
   const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
   const params = new URLSearchParams(rawQuery);
+  applyObfsLocalPluginParams(params);
   const network = params.get('type') ?? 'tcp';
   const security = (params.get('security') ?? 'none') as string;
   const stream = buildStream(network, security);

+ 14 - 0
frontend/src/models/setting.ts

@@ -9,6 +9,8 @@ export class AllSetting {
   webBasePath = '/';
   sessionMaxAge = 360;
   trustedProxyCIDRs = '127.0.0.1/32,::1/128';
+  realityScanCandidates =
+    'www.cloudflare.com:443,www.microsoft.com:443,www.amazon.com:443,aws.amazon.com:443,www.samsung.com:443,www.nvidia.com:443,www.amd.com:443,www.intel.com:443,www.sony.com:443,dl.google.com:443';
   ipLimitAllowlist = '';
   panelOutbound = '';
   pageSize = 25;
@@ -147,6 +149,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) {

+ 8 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -914,14 +914,14 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/server/scanRealityTargets',
         summary:
-          'Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.',
+          'Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, the realityScanCandidates setting is probed (the built-in seed list if that setting is empty).',
         params: [
           {
             name: 'targets',
             in: 'body (form)',
             type: 'string',
             optional: true,
-            desc: 'Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, a built-in seed list is probed.',
+            desc: 'Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, the realityScanCandidates setting is probed (the built-in seed list if that setting is empty).',
           },
         ],
         body: 'targets=104.16.0.0/24,www.apple.com:443',
@@ -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',

+ 17 - 2
frontend/src/pages/index/GeodataSection.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
 import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
 
+import { XrayConfigPayloadSchema } from '@/schemas/xray';
 import { HttpUtil } from '@/utils';
 
 interface GeodataAssetRow {
@@ -37,6 +38,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
   const [cron, setCron] = useState(DEFAULT_CRON);
   const [outbound, setOutbound] = useState<string | undefined>(undefined);
   const [rows, setRows] = useState<GeodataAssetRow[]>([]);
+  const [standardSources, setStandardSources] = useState<GeodataAssetRow[]>([]);
   const [outboundTags, setOutboundTags] = useState<string[]>([]);
   const [template, setTemplate] = useState<Record<string, unknown> | null>(null);
   const outboundTestUrlRef = useRef('');
@@ -45,8 +47,10 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
     try {
       const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
       if (!msg?.success || typeof msg.obj !== 'string') return;
-      const payload = JSON.parse(msg.obj) as Record<string, unknown>;
-      const next = (payload.xraySetting || {}) as Record<string, unknown>;
+      const parsed = XrayConfigPayloadSchema.safeParse(JSON.parse(msg.obj));
+      if (!parsed.success) return;
+      const payload = parsed.data;
+      const next = payload.xraySetting as Record<string, unknown>;
       setTemplate(next);
       outboundTestUrlRef.current =
         typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
@@ -62,6 +66,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
       setOutbound(
         typeof geodata.outbound === 'string' && geodata.outbound ? geodata.outbound : undefined,
       );
+      setStandardSources(payload.geodataSources ?? []);
 
       // Download outbound candidates: template outbounds + subscription outbounds.
       // Skip blackhole outbounds — routing a download through one just drops it.
@@ -106,6 +111,13 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
     );
   }
 
+  function addStandardSources() {
+    setRows((prev) => {
+      const files = new Set(prev.map((row) => row.file));
+      return [...prev, ...standardSources.filter((source) => !files.has(source.file))];
+    });
+  }
+
   function save() {
     if (!template) return;
     const assets = rows
@@ -217,6 +229,9 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
             >
               {t('pages.index.geodataAddFile')}
             </Button>
+            <Button onClick={addStandardSources} disabled={standardSources.length === 0}>
+              {t('pages.index.geodataUseStandardSources')}
+            </Button>
             <Button type="primary" onClick={save} disabled={loading || !template}>
               {t('pages.index.geodataSaveRestart')}
             </Button>

+ 201 - 0
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<AllSetting>) => 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<DiscordTestResult | null>(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: (
+            <>
+              <span role="img" aria-label={l.name}>
+                {l.icon}
+              </span>
+              &nbsp;&nbsp;<span>{l.name}</span>
+            </>
+          ),
+        }),
+      ),
+    [],
+  );
+
+  return (
+    <Tabs
+      defaultActiveKey="1"
+      items={[
+        {
+          key: '1',
+          label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
+          children: (
+            <>
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.discordBotEnable')}
+                description={t('pages.settings.discordBotEnableDesc')}
+              >
+                <Switch
+                  checked={allSetting.discordBotEnable}
+                  onChange={(v) => updateSetting({ discordBotEnable: v })}
+                />
+              </SettingListItem>
+
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.discordBotToken')}
+                description={
+                  allSetting.hasDiscordBotToken && !allSetting.clearDiscordBotToken
+                    ? t('pages.settings.discordTokenConfigured')
+                    : t('pages.settings.discordBotTokenDesc')
+                }
+              >
+                <SecretInput
+                  value={allSetting.discordBotToken}
+                  configured={allSetting.hasDiscordBotToken}
+                  clearArmed={allSetting.clearDiscordBotToken}
+                  placeholder={t('pages.settings.discordTokenPlaceholder')}
+                  onChange={(v) => updateSetting({ discordBotToken: v })}
+                  onClearArmedChange={(armed) => updateSetting({ clearDiscordBotToken: armed })}
+                />
+              </SettingListItem>
+
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.discordChannelId')}
+                description={t('pages.settings.discordChannelIdDesc')}
+              >
+                <Input
+                  value={allSetting.discordChannelId}
+                  placeholder="e.g. 123456789012345678"
+                  onChange={(e) => updateSetting({ discordChannelId: e.target.value })}
+                  style={{ width: '100%' }}
+                />
+              </SettingListItem>
+
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.discordAdminIds')}
+                description={t('pages.settings.discordAdminIdsDesc')}
+              >
+                <Input
+                  value={allSetting.discordAdminIds}
+                  placeholder="e.g. 123456789012345678"
+                  onChange={(e) => updateSetting({ discordAdminIds: e.target.value })}
+                  style={{ width: '100%' }}
+                />
+              </SettingListItem>
+
+              <SettingListItem paddings="small" title={t('pages.settings.discordBotLanguage')}>
+                <Select
+                  value={allSetting.discordLang}
+                  onChange={(v) => updateSetting({ discordLang: v })}
+                  style={{ width: '100%' }}
+                  options={langOptions}
+                />
+              </SettingListItem>
+
+              <Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
+                <Button
+                  type="primary"
+                  icon={<SendOutlined />}
+                  loading={testLoading}
+                  disabled={!allSetting.discordBotEnable}
+                  onClick={handleTestDiscord}
+                >
+                  {t('pages.settings.testDiscord')}
+                </Button>
+                {testResult && (
+                  <Alert
+                    type={testResult.success ? 'success' : 'error'}
+                    title={testResult.msg}
+                    showIcon
+                    closable={{ onClose: () => setTestResult(null) }}
+                  />
+                )}
+              </Space>
+            </>
+          ),
+        },
+        {
+          key: '2',
+          label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
+          children: (
+            <>
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.discordNotifyTime')}
+                description={t('pages.settings.discordNotifyTimeDesc')}
+              >
+                <NotifyTimeField
+                  value={allSetting.discordRunTime}
+                  onChange={(v) => updateSetting({ discordRunTime: v })}
+                  ariaLabel={t('pages.settings.discordNotifyTime')}
+                />
+              </SettingListItem>
+
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.discordNotifyBackup')}
+                description={t('pages.settings.discordNotifyBackupDesc')}
+              >
+                <Switch
+                  checked={allSetting.discordBotBackup}
+                  onChange={(v) => updateSetting({ discordBotBackup: v })}
+                />
+              </SettingListItem>
+
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.discordEventBusNotify')}
+                description={t('pages.settings.discordEventBusNotifyDesc')}
+              >
+                <DiscordNotifications allSetting={allSetting} updateSetting={updateSetting} />
+              </SettingListItem>
+            </>
+          ),
+        },
+      ]}
+    />
+  );
+}

+ 19 - 0
frontend/src/pages/settings/GeneralTab.tsx

@@ -259,6 +259,25 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
                 />
               </SettingListItem>
 
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.realityScanCandidates')}
+                description={t('pages.settings.realityScanCandidatesDesc')}
+                badge={
+                  <DefaultSettingTag
+                    settingKey="realityScanCandidates"
+                    value={allSetting.realityScanCandidates}
+                  />
+                }
+              >
+                <Input.TextArea
+                  rows={3}
+                  value={allSetting.realityScanCandidates}
+                  placeholder="www.cloudflare.com:443,www.microsoft.com:443"
+                  onChange={(e) => updateSetting({ realityScanCandidates: e.target.value })}
+                />
+              </SettingListItem>
+
               <SettingListItem
                 paddings="small"
                 title={t('pages.settings.ipLimitAllowlist')}

+ 151 - 0
frontend/src/pages/settings/NotifyTimeField.tsx

@@ -0,0 +1,151 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Input, InputNumber, Select, Space } from 'antd';
+import { onNumber } from '@/utils/onNumber';
+
+// The notification schedule is fed straight to robfig/cron's AddJob (see
+// web.go startTask), which accepts @every <duration>, 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.
+export type Unit = 's' | 'm' | 'h';
+export type Macro = '@hourly' | '@daily' | '@weekly' | '@monthly';
+export type Mode = 'every' | Macro | 'custom';
+const MACROS: Macro[] = ['@hourly', '@daily', '@weekly', '@monthly'];
+const EVERY_RE = /^@every\s+(\d+)\s*([smh])$/i;
+
+export interface RunTime {
+  mode: Mode;
+  num: number;
+  unit: Unit;
+  custom: string;
+}
+
+export 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 };
+}
+
+export 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).
+export 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;
+  }
+}
+
+export function NotifyTimeField({
+  value,
+  onChange,
+  ariaLabel,
+}: {
+  value: string;
+  onChange: (v: string) => void;
+  ariaLabel?: string;
+}) {
+  const { t } = useTranslation();
+  const [state, setState] = useState<RunTime>(() => parseRunTime(value));
+
+  function update(patch: Partial<RunTime>) {
+    const next = { ...state, ...patch };
+    setState(next);
+    onChange(composeRunTime(next));
+  }
+
+  function onModeChange(mode: Mode) {
+    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 (
+    <Space orientation="vertical" size="small" style={{ width: '100%' }}>
+      <Select<Mode>
+        style={{ width: '100%' }}
+        value={state.mode}
+        options={modeOptions}
+        onChange={onModeChange}
+        aria-label={ariaLabel || t('pages.settings.telegramNotifyTime')}
+      />
+      {state.mode === 'every' && (
+        <Space.Compact style={{ width: '100%' }}>
+          <InputNumber
+            min={1}
+            precision={0}
+            style={{ width: '50%' }}
+            value={state.num}
+            onChange={onNumber((v) => update({ num: Math.max(1, v) }))}
+            aria-label={t('pages.settings.notifyTime.interval')}
+          />
+          <Select<Unit>
+            style={{ width: '50%' }}
+            value={state.unit}
+            options={unitOptions}
+            onChange={(unit) => update({ unit })}
+            aria-label={t('pages.settings.notifyTime.unit')}
+          />
+        </Space.Compact>
+      )}
+      {state.mode === 'custom' && (
+        <Input
+          value={state.custom}
+          placeholder="0 30 8 * * *"
+          onChange={(e) => update({ custom: e.target.value })}
+          aria-label={t('pages.settings.notifyTime.custom')}
+        />
+      )}
+    </Space>
+  );
+}

+ 4 - 0
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 <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'email':
         return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
+      case 'discord':
+        return <DiscordTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'subscription':
         return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'subscription-formats':

+ 3 - 147
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<AllSetting>) => void;
 }
 
-// The notification schedule is fed straight to robfig/cron's AddJob (see
-// web.go startTask), which accepts @every <duration>, 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<RunTime>(() => parseRunTime(value));
-
-  function update(patch: Partial<RunTime>) {
-    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 (
-    <Space orientation="vertical" size="small" style={{ width: '100%' }}>
-      <Select<Mode>
-        style={{ width: '100%' }}
-        value={state.mode}
-        options={modeOptions}
-        onChange={onModeChange}
-        aria-label={t('pages.settings.telegramNotifyTime')}
-      />
-      {state.mode === 'every' && (
-        <Space.Compact style={{ width: '100%' }}>
-          <InputNumber
-            min={1}
-            precision={0}
-            style={{ width: '50%' }}
-            value={state.num}
-            onChange={onNumber((v) => update({ num: Math.max(1, v) }))}
-            aria-label={t('pages.settings.notifyTime.interval')}
-          />
-          <Select<Unit>
-            style={{ width: '50%' }}
-            value={state.unit}
-            options={unitOptions}
-            onChange={(unit) => update({ unit })}
-            aria-label={t('pages.settings.notifyTime.unit')}
-          />
-        </Space.Compact>
-      )}
-      {state.mode === 'custom' && (
-        <Input
-          value={state.custom}
-          placeholder="0 30 8 * * *"
-          onChange={(e) => update({ custom: e.target.value })}
-          aria-label={t('pages.settings.notifyTime.custom')}
-        />
-      )}
-    </Space>
-  );
-}
-
 export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();

+ 12 - 0
frontend/src/schemas/setting.ts

@@ -14,6 +14,7 @@ export const AllSettingSchema = z
     webBasePath: absolutePath.optional(),
     sessionMaxAge: z.number().int().min(1).max(525600).optional(),
     trustedProxyCIDRs: z.string().optional(),
+    realityScanCandidates: z.string().optional(),
     ipLimitAllowlist: z.string().optional(),
     panelOutbound: z.string().optional(),
     pageSize: z.number().int().min(0).max(1000).optional(),
@@ -132,6 +133,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();
 

+ 1 - 0
frontend/src/schemas/xray.ts

@@ -56,6 +56,7 @@ export const XrayConfigPayloadSchema = z
     // balancers / routing rules.
     subscriptionOutbounds: z.array(z.unknown()).optional(),
     subscriptionOutboundTags: z.array(z.string()).optional(),
+    geodataSources: z.array(z.object({ url: z.string(), file: z.string() })).optional(),
   })
   .loose();
 

+ 52 - 0
frontend/src/test/client-toggle-traffic-reset.test.tsx

@@ -0,0 +1,52 @@
+import type { ReactNode } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useClients } from '@/hooks/useClients';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+describe('client enable toggle', () => {
+  it.each([false, true])(
+    'preserves the hydrated traffic reset cycle when enable=%s',
+    async (enable) => {
+      const email = '[email protected]';
+      vi.spyOn(HttpUtil, 'get').mockResolvedValue(
+        new Msg(true, '', {
+          client: { email, enable: !enable, trafficReset: 'monthly', trafficResetDay: 15 },
+          inboundIds: [],
+        }),
+      );
+      const post = vi
+        .spyOn(HttpUtil, 'post')
+        .mockImplementation(
+          async (url: string) =>
+            new Msg(true, '', url.includes('/setting/defaultSettings') ? {} : null),
+        );
+      const queryClient = makeTestQueryClient();
+      const wrapper = ({ children }: { children: ReactNode }) => (
+        <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+      );
+      const { result } = renderHook(() => useClients({ list: false }), { wrapper });
+
+      await act(async () => {
+        await result.current.setEnable(
+          { email, trafficReset: 'never', trafficResetDay: 1 },
+          enable,
+        );
+      });
+
+      expect(HttpUtil.get).toHaveBeenCalledWith('/panel/api/clients/get/scheduled%40example.com');
+      expect(post).toHaveBeenCalledWith(
+        '/panel/api/clients/update/scheduled%40example.com',
+        expect.objectContaining({ email, enable, trafficReset: 'monthly', trafficResetDay: 15 }),
+        { headers: { 'Content-Type': 'application/json' } },
+      );
+    },
+  );
+});

+ 79 - 0
frontend/src/test/geodata-section.test.tsx

@@ -0,0 +1,79 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import GeodataSection from '@/pages/index/GeodataSection';
+import { HttpUtil, Msg } from '@/utils';
+
+const STANDARD_SOURCES = [
+  {
+    url: 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat',
+    file: 'geoip.dat',
+  },
+  {
+    url: 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat',
+    file: 'geosite.dat',
+  },
+  {
+    url: 'https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat',
+    file: 'geoip_IR.dat',
+  },
+  {
+    url: 'https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat',
+    file: 'geosite_IR.dat',
+  },
+  {
+    url: 'https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat',
+    file: 'geoip_RU.dat',
+  },
+  {
+    url: 'https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat',
+    file: 'geosite_RU.dat',
+  },
+];
+
+const CUSTOM_SOURCE = {
+  url: 'https://example.com/geosite_custom.dat',
+  file: 'geosite_custom.dat',
+};
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+describe('GeodataSection', () => {
+  it('adds the standard sources without removing custom sources', async () => {
+    vi.spyOn(HttpUtil, 'post').mockResolvedValue(
+      new Msg(
+        true,
+        '',
+        JSON.stringify({
+          xraySetting: { outbounds: [], geodata: { assets: [CUSTOM_SOURCE] } },
+          geodataSources: STANDARD_SOURCES,
+        }),
+      ),
+    );
+    const user = userEvent.setup();
+
+    render(<GeodataSection active onBusy={vi.fn()} onClose={vi.fn()} />);
+
+    await screen.findByRole('button', {
+      name: 'Use standard sources',
+    });
+    await waitFor(() =>
+      expect(
+        (screen.getByRole('button', { name: 'Use standard sources' }) as HTMLButtonElement)
+          .disabled,
+      ).toBe(false),
+    );
+    await user.click(screen.getByRole('button', { name: 'Use standard sources' }));
+
+    await waitFor(() => {
+      expect(screen.getByDisplayValue(CUSTOM_SOURCE.url)).toBeTruthy();
+      for (const source of STANDARD_SOURCES) {
+        expect(screen.getByDisplayValue(source.url)).toBeTruthy();
+        expect(screen.getByDisplayValue(source.file)).toBeTruthy();
+      }
+    });
+  });
+});

+ 49 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -57,6 +57,34 @@ describe('parseVmessLink', () => {
     expect((stream.tlsSettings as Record<string, unknown>).alpn).toEqual(['h2', 'http/1.1']);
   });
 
+  // The exporter writes ech/vcn/pcs into the vmess object, so the importer has
+  // to read them instead of leaving the tls checks it seeded empty.
+  it('keeps the ech, vcn and pcs certificate checks', () => {
+    const json = {
+      v: '2',
+      ps: 'pinned-vmess',
+      add: '1.2.3.4',
+      port: 8443,
+      id: '11111111-2222-4333-8444-555555555555',
+      scy: 'auto',
+      net: 'tcp',
+      tls: 'tls',
+      sni: 'vmess.example.com',
+      fp: 'chrome',
+      ech: 'AEX+DQBB',
+      vcn: 'vcn.example.com',
+      pcs: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
+    };
+    const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
+    const out = parseVmessLink(link);
+    expect(out).not.toBeNull();
+    const stream = out?.streamSettings as Record<string, unknown>;
+    const tls = stream.tlsSettings as Record<string, unknown>;
+    expect(tls.echConfigList).toBe('AEX+DQBB');
+    expect(tls.verifyPeerCertByName).toBe('vcn.example.com');
+    expect(tls.pinnedPeerCertSha256).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=');
+  });
+
   it('returns null for non-vmess links', () => {
     expect(parseVmessLink('vless://x@y:1')).toBeNull();
   });
@@ -330,6 +358,27 @@ describe('parseShadowsocksLink', () => {
     expect(tls.alpn).toEqual(['h2', 'http/1.1']);
   });
 
+  // The panel exports tcp/http obfuscation as the SIP002 plugin only, so the
+  // importer has to rebuild the header it stands for.
+  it('rebuilds the tcp/http header from the obfs-local plugin', () => {
+    const userinfo = Base64.encode('aes-256-gcm:secretpass', true);
+    const plugin = encodeURIComponent('obfs-local;obfs=http;obfs-host=obfs.example.com');
+    const link = `ss://${userinfo}@example.com:8388?plugin=${plugin}#user`;
+    const stream = parseShadowsocksLink(link)?.streamSettings as Record<string, unknown>;
+    expect((stream.tcpSettings as Record<string, unknown>).header).toMatchObject({
+      type: 'http',
+      request: { headers: { Host: ['obfs.example.com'] } },
+    });
+  });
+
+  it('leaves a plugin without an xray header alone', () => {
+    const userinfo = Base64.encode('aes-256-gcm:secretpass', true);
+    const plugin = encodeURIComponent('obfs-local;obfs=tls');
+    const link = `ss://${userinfo}@example.com:8388?plugin=${plugin}#user`;
+    const stream = parseShadowsocksLink(link)?.streamSettings as Record<string, unknown>;
+    expect((stream.tcpSettings as Record<string, unknown>).header).toMatchObject({ type: 'none' });
+  });
+
   it('decodes URL-safe base64 userinfo (as the emitter writes it)', () => {
     const method = 'aes-256-gcm';
     const password = '>>>';

+ 4 - 6
internal/sub/clash_external.go

@@ -6,11 +6,8 @@ import (
 	"strings"
 )
 
-// clashProxyFromExternal parses a pasted share link and converts it into a
-// mihomo/Clash proxy entry named `name`. Returns nil for links Clash can't
-// represent (the entry is then skipped, mirroring how getProxies drops
-// unsupported inbound protocols). vmess/vless/trojan reuse the existing
-// applyTransport/applySecurity helpers; ss/hysteria2/wireguard map directly.
+// clashProxyFromExternal converts a pasted share link into a mihomo/Clash proxy
+// entry, or nil when Clash can't represent it — the same gate getProxies runs.
 func (s *SubClashService) clashProxyFromExternal(rawLink, name string) map[string]any {
 	ob := parseExternalLink(rawLink)
 	if ob == nil {
@@ -81,7 +78,8 @@ func (s *SubClashService) clashProxyFromExternal(rawLink, name string) map[strin
 		proxy["port"] = clashInt(server["port"])
 		proxy["cipher"] = method
 		proxy["password"] = fmt.Sprint(server["password"])
-		return proxy
+		// No early return: the shared transport/security tail is what drops an
+		// obfs node Clash cannot express, exactly as buildProxy does for inbounds.
 	case "hysteria":
 		return clashHysteriaFromExternal(settings, stream, name)
 	case "wireguard":

+ 118 - 0
internal/sub/clash_external_quota_test.go

@@ -0,0 +1,118 @@
+package sub
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// base64("aes-256-gcm:clientpw"), the SIP002 userinfo both spellings share. The
+// panel emits this node as `plugin=obfs-local;obfs=http`, and Clash has no way
+// to represent it, so both the inbound path and the link path drop it.
+const clashDroppedExternalLink = "ss://[email protected]:8443?type=tcp&headerType=http&host=test#obfs"
+
+const tcpObfsStream = `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["test"]}}}}}`
+
+func clashSubRouter(t *testing.T) *gin.Engine {
+	t.Helper()
+	oldDistFS := distFS
+	distFS = testDistFS
+	t.Cleanup(func() { distFS = oldDistFS })
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	NewSUBController(
+		router.Group("/"),
+		WithSUBJsonEnabled(true),
+		WithSUBClashEnabled(true),
+		WithSUBEncryption(false),
+	)
+	return router
+}
+
+func fetchClashSub(t *testing.T, router *gin.Engine, path string) *httptest.ResponseRecorder {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodGet, path, nil)
+	req.Host = "sub.example.com"
+	w := httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+	return w
+}
+
+func seedClashQuotaSub(t *testing.T, subID string, expiry int64) {
+	t.Helper()
+	db := database.GetDB()
+	seedSubInbound(t, subID, "A", 10001, 1, wsTLSStream)
+	if err := db.Create(&xray.ClientTraffic{Email: "A@e", Up: 11, Down: 22, Total: 1024, ExpiryTime: expiry}).Error; err != nil {
+		t.Fatalf("seed A traffic: %v", err)
+	}
+	rec := &model.ClientRecord{Email: "B@e", SubID: subID, UUID: "22222222-2222-4222-8222-222222222222", Enable: true, ExpiryTime: expiry}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("seed B client: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{Email: "B@e", Up: 100, Down: 200, Total: 2048, ExpiryTime: expiry}).Error; err != nil {
+		t.Fatalf("seed B traffic: %v", err)
+	}
+	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: clashDroppedExternalLink, SortIndex: 1}).Error; err != nil {
+		t.Fatalf("seed B external link: %v", err)
+	}
+}
+
+// B's node cannot be represented in Clash, but B still owns quota: the header
+// must keep counting B's traffic, not quietly serve A's numbers alone.
+func TestClashQuotaHeaderCountsDroppedExternalLink(t *testing.T) {
+	initSubDB(t)
+	subID := "clash-quota-drop"
+	expiry := time.Now().Add(24 * time.Hour).UnixMilli()
+	seedClashQuotaSub(t, subID, expiry)
+
+	w := fetchClashSub(t, clashSubRouter(t), "/clash/"+subID+"?view=raw")
+	if w.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
+	}
+	if strings.Contains(w.Body.String(), "198.51.100.9") {
+		t.Fatalf("unrepresentable node leaked into the profile: %s", w.Body.String())
+	}
+	wantHeader := fmt.Sprintf("upload=111; download=222; total=3072; expire=%d", expiry/1000)
+	if got := w.Header().Get("Subscription-Userinfo"); got != wantHeader {
+		t.Fatalf("Subscription-Userinfo = %q, want %q", got, wantHeader)
+	}
+}
+
+// Nothing to serve is answered the same way whether the unrepresentable node is
+// an inbound or an external link — the drop must not depend on where it came from.
+func TestClashAllUnrepresentableNodesAnswerAlike(t *testing.T) {
+	statuses := make(map[string]int, 2)
+	for _, source := range []string{"inbound", "external-link"} {
+		t.Run(source, func(t *testing.T) {
+			initSubDB(t)
+			if source == "inbound" {
+				seedSubInbound(t, "clash-parity", "obfs", 10001, 1, tcpObfsStream)
+			} else {
+				rec := &model.ClientRecord{Email: "B@e", SubID: "clash-parity", UUID: "22222222-2222-4222-8222-222222222222", Enable: true}
+				if err := database.GetDB().Create(rec).Error; err != nil {
+					t.Fatalf("seed client: %v", err)
+				}
+				if err := database.GetDB().Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: clashDroppedExternalLink, SortIndex: 1}).Error; err != nil {
+					t.Fatalf("seed external link: %v", err)
+				}
+			}
+			w := fetchClashSub(t, clashSubRouter(t), "/clash/clash-parity?view=raw")
+			if w.Body.Len() != 0 {
+				t.Fatalf("body = %q, want empty", w.Body.String())
+			}
+			statuses[source] = w.Code
+		})
+	}
+	if statuses["inbound"] != statuses["external-link"] {
+		t.Fatalf("status differs by node source: %v", statuses)
+	}
+}

+ 75 - 0
internal/sub/clash_external_shadowsocks_test.go

@@ -0,0 +1,75 @@
+package sub
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Clash has no shadowsocks tcp/http header, so the inbound path drops the node
+// (applyTransport returns false) and the external-link path must drop it too,
+// instead of handing mihomo a proxy that connects with the wrong obfuscation.
+func TestClashExternalShadowsocksMatchesInboundPath(t *testing.T) {
+	const settings = `{"method":"aes-256-gcm","password":"inboundpw","clients":[{"password":"clientpw","email":"user"}]}`
+	// base64("aes-256-gcm:clientpw") — the SIP002 userinfo of the links below.
+	const userinfo = "YWVzLTI1Ni1nY206Y2xpZW50cHc"
+
+	tests := []struct {
+		name        string
+		stream      string
+		link        string
+		wantDropped bool
+	}{
+		{
+			name:   "plain tcp",
+			stream: `{"network":"tcp","security":"none"}`,
+			link:   "ss://" + userinfo + "@203.0.113.1:8443?type=tcp#ss",
+		},
+		{
+			name:        "tcp http header",
+			stream:      `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["test"]}}}}}`,
+			link:        "ss://" + userinfo + "@203.0.113.1:8443?type=tcp&headerType=http&host=test#ss",
+			wantDropped: true,
+		},
+		{
+			name:   "tls",
+			stream: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"ss.sni"}}`,
+			link:   "ss://" + userinfo + "@203.0.113.1:8443?type=tcp&security=tls&sni=ss.sni#ss",
+		},
+	}
+
+	svc := NewSubClashService(false, "", &SubService{})
+	client := model.Client{Password: "clientpw", Email: "user"}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			inbound := &model.Inbound{
+				Listen:         "203.0.113.1",
+				Port:           8443,
+				Protocol:       model.Shadowsocks,
+				Remark:         "ss",
+				Settings:       settings,
+				StreamSettings: tc.stream,
+			}
+
+			fromInbound := svc.buildProxy(svc.SubService, inbound, client, svc.streamData(tc.stream), nil)
+			fromLink := svc.clashProxyFromExternal(tc.link, "external")
+
+			if tc.wantDropped {
+				if fromInbound != nil || fromLink != nil {
+					t.Fatalf("not representable in clash, want both dropped: inbound %#v, link %#v", fromInbound, fromLink)
+				}
+				return
+			}
+			if fromInbound == nil || fromLink == nil {
+				t.Fatalf("want a proxy from both paths: inbound %#v, link %#v", fromInbound, fromLink)
+			}
+			delete(fromInbound, "name")
+			delete(fromLink, "name")
+			if !reflect.DeepEqual(fromInbound, fromLink) {
+				t.Fatalf("inbound %#v != link %#v", fromInbound, fromLink)
+			}
+		})
+	}
+}

+ 3 - 2
internal/sub/clash_service.go

@@ -78,8 +78,10 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
 		if ext.Enable {
 			hasEnabledClient = true
 		}
+		// Count the client even when no proxy comes out of this link, so the
+		// quota header does not shrink because a node is unrepresentable in Clash.
+		seenEmails[ext.Email] = struct{}{}
 		if !ext.Active {
-			seenEmails[ext.Email] = struct{}{}
 			hasInactiveExternal = true
 			continue
 		}
@@ -89,7 +91,6 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
 				name = ext.Email
 			}
 			if proxy := s.clashProxyFromExternal(el.Link, name); proxy != nil {
-				seenEmails[ext.Email] = struct{}{}
 				proxies = append(proxies, proxy)
 			}
 		}

+ 49 - 0
internal/sub/shadowsocks_plugin_import_test.go

@@ -0,0 +1,49 @@
+package sub
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
+)
+
+// The panel exports shadowsocks tcp/http obfuscation as the SIP002 plugin, so
+// importing that same link has to rebuild the header it stands for.
+func TestShadowsocksHTTPObfsSurvivesExportImport(t *testing.T) {
+	in := &model.Inbound{
+		Id: 940001, Listen: "203.0.113.1", Port: 8388, Protocol: model.Shadowsocks,
+		Settings:       `{"method":"aes-256-gcm","password":"serverpass","clients":[{"email":"user","password":"clientpass"}]}`,
+		StreamSettings: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["obfs.example.com"]}}}}}`,
+	}
+	exported := (&SubService{}).genShadowsocksLink(in, "user")
+	if !strings.Contains(exported, "plugin=obfs-local%3Bobfs%3Dhttp") {
+		t.Fatalf("export did not use the SIP002 plugin form: %q", exported)
+	}
+
+	parsed, err := link.ParseLink(exported)
+	if err != nil {
+		t.Fatalf("ParseLink(%q): %v", exported, err)
+	}
+	streamJSON, err := json.Marshal(parsed.Outbound["streamSettings"])
+	if err != nil {
+		t.Fatalf("marshal stream: %v", err)
+	}
+	var stream map[string]any
+	if err := json.Unmarshal(streamJSON, &stream); err != nil {
+		t.Fatalf("stream json: %v", err)
+	}
+
+	tcp, _ := stream["tcpSettings"].(map[string]any)
+	header, _ := tcp["header"].(map[string]any)
+	if header == nil || header["type"] != "http" {
+		t.Fatalf("import dropped the tcp/http obfuscation: %s", streamJSON)
+	}
+	request, _ := header["request"].(map[string]any)
+	headers, _ := request["headers"].(map[string]any)
+	hosts, _ := headers["Host"].([]any)
+	if len(hosts) == 0 || hosts[0] != "obfs.example.com" {
+		t.Fatalf("import dropped the obfs host: %s", streamJSON)
+	}
+}

+ 60 - 0
internal/sub/vmess_tls_import_test.go

@@ -0,0 +1,60 @@
+package sub
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
+)
+
+// The vmess object carries the certificate checks the panel exported, so
+// importing that same link has to rebuild them instead of dropping them.
+func TestVmessTLSVerifyFieldsSurviveExportImport(t *testing.T) {
+	in := &model.Inbound{
+		Id: 940002, Listen: "203.0.113.1", Port: 8443, Protocol: model.VMESS,
+		Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}]}`,
+		StreamSettings: `{"network":"tcp","security":"tls","tcpSettings":{"header":{"type":"none"}},` +
+			`"tlsSettings":{"serverName":"vmess.example.com","alpn":["h2"],"settings":{` +
+			`"fingerprint":"chrome","echConfigList":"AEX+DQBB","verifyPeerCertByName":"vcn.example.com",` +
+			`"pinnedPeerCertSha256":["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]}}}`,
+	}
+	exported := (&SubService{}).genVmessLink(in, "user")
+	if exported == "" {
+		t.Fatal("genVmessLink produced nothing")
+	}
+
+	parsed, err := link.ParseLink(exported)
+	if err != nil {
+		t.Fatalf("ParseLink: %v", err)
+	}
+	raw, err := json.Marshal(parsed.Outbound["streamSettings"])
+	if err != nil {
+		t.Fatalf("marshal stream: %v", err)
+	}
+	var stream map[string]any
+	if err := json.Unmarshal(raw, &stream); err != nil {
+		t.Fatalf("stream json: %v", err)
+	}
+	tlsSettings, _ := stream["tlsSettings"].(map[string]any)
+	if tlsSettings == nil {
+		t.Fatalf("no tlsSettings: %s", raw)
+	}
+
+	// The core reads these joined strings in its config, as applySecurity writes them.
+	for field, want := range map[string]string{
+		"serverName":           "vmess.example.com",
+		"fingerprint":          "chrome",
+		"echConfigList":        "AEX+DQBB",
+		"verifyPeerCertByName": "vcn.example.com",
+		"pinnedPeerCertSha256": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+	} {
+		if tlsSettings[field] != want {
+			t.Errorf("tlsSettings[%q] = %v, want %q", field, tlsSettings[field], want)
+		}
+	}
+	alpn, _ := tlsSettings["alpn"].([]any)
+	if len(alpn) != 1 || alpn[0] != "h2" {
+		t.Errorf("alpn = %v, want [h2]", tlsSettings["alpn"])
+	}
+}

+ 56 - 0
internal/util/link/outbound.go

@@ -205,6 +205,11 @@ func parseVmess(link string) (*ParseResult, error) {
 		if alpn := getString(j, "alpn", ""); alpn != "" {
 			tls["alpn"] = splitComma(alpn)
 		}
+		// The vmess object names the certificate checks v2rayN does the same way
+		// the url-param protocols name them in applySecurity.
+		tls["echConfigList"] = getString(j, "ech", "")
+		tls["verifyPeerCertByName"] = getString(j, "vcn", "")
+		tls["pinnedPeerCertSha256"] = getString(j, "pcs", "")
 	}
 
 	port := num(j["port"])
@@ -401,6 +406,9 @@ func parseShadowsocks(link string) (*ParseResult, error) {
 		method, pass = splitMethodPass(userInfo)
 	}
 	identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
+	// The panel and v2rayN express shadowsocks tcp/http obfuscation only as the
+	// SIP002 plugin, so it has to become the header it stands for.
+	applyObfsLocalPlugin(params, rawQuery)
 	network := params.Get("type")
 	if network == "" {
 		network = "tcp"
@@ -434,6 +442,54 @@ func splitMethodPass(userInfo string) (string, string) {
 	return before, after
 }
 
+// applyObfsLocalPlugin maps a SIP002 obfs-local=http plugin onto the tcp/http
+// response header it stands for; the other plugin values have no Xray header.
+func applyObfsLocalPlugin(p url.Values, rawQuery string) {
+	if p.Get("headerType") != "" || p.Get("type") == "http" {
+		return
+	}
+	plugin := p.Get("plugin")
+	if plugin == "" {
+		plugin = rawQueryPlugin(rawQuery)
+	}
+	parts := strings.Split(plugin, ";")
+	if len(parts) == 0 || parts[0] != "obfs-local" {
+		return
+	}
+	obfs, host := "", ""
+	for _, part := range parts[1:] {
+		if k, v, ok := strings.Cut(part, "="); ok {
+			switch k {
+			case "obfs":
+				obfs = v
+			case "obfs-host":
+				host = v
+			}
+		}
+	}
+	if obfs != "http" {
+		return
+	}
+	p.Set("type", "tcp")
+	p.Set("headerType", "http")
+	if host != "" {
+		p.Set("host", host)
+	}
+}
+
+// rawQueryPlugin reads the plugin parameter straight out of the query string for
+// the pair stdlib discards: a value holding an unencoded semicolon never parses.
+func rawQueryPlugin(rawQuery string) string {
+	for _, segment := range strings.Split(rawQuery, "&") {
+		if key, value, ok := strings.Cut(segment, "="); ok && key == "plugin" {
+			if decoded, err := url.QueryUnescape(value); err == nil {
+				return decoded
+			}
+		}
+	}
+	return ""
+}
+
 // --- hysteria2 ---
 
 func parseHysteria2(link string) (*ParseResult, error) {

+ 44 - 0
internal/util/link/outbound_test.go

@@ -2,6 +2,7 @@ package link
 
 import (
 	"encoding/base64"
+	"encoding/json"
 	"net/url"
 	"strings"
 	"testing"
@@ -503,3 +504,46 @@ func TestSlugAndSuggest(t *testing.T) {
 		t.Errorf("unicode suggest tag got %q", got)
 	}
 }
+
+// The obfs-local plugin the panel exports carries the only description of
+// shadowsocks tcp/http obfuscation, so it has to become that header.
+func TestParseShadowsocksObfsLocalPlugin(t *testing.T) {
+	user := base64.RawURLEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
+	const httpObfs = "obfs-local;obfs=http;obfs-host=obfs.example.com"
+	for _, tc := range []struct {
+		name, query, wantHeader, wantHost string
+	}{
+		{"http obfs becomes the tcp header", "plugin=" + url.QueryEscape(httpObfs), "http", "obfs.example.com"},
+		{"unencoded separators map the same way", "plugin=" + httpObfs, "http", "obfs.example.com"},
+		{"tls obfs has no xray header", "plugin=" + url.QueryEscape("obfs-local;obfs=tls"), "none", ""},
+		{"an unrelated plugin is left alone", "plugin=v2ray-plugin", "none", ""},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			res, err := ParseLink("ss://" + user + "@1.2.3.4:8388/?" + tc.query + "#node")
+			if err != nil {
+				t.Fatalf("parse ss: %v", err)
+			}
+			raw, err := json.Marshal(res.Outbound["streamSettings"])
+			if err != nil {
+				t.Fatalf("marshal stream: %v", err)
+			}
+			var stream map[string]any
+			_ = json.Unmarshal(raw, &stream)
+			tcp, _ := stream["tcpSettings"].(map[string]any)
+			header, _ := tcp["header"].(map[string]any)
+			if header == nil || header["type"] != tc.wantHeader {
+				t.Fatalf("header = %v, want type %q", header, tc.wantHeader)
+			}
+			request, _ := header["request"].(map[string]any)
+			headers, _ := request["headers"].(map[string]any)
+			hosts, _ := headers["Host"].([]any)
+			got := ""
+			if len(hosts) > 0 {
+				got, _ = hosts[0].(string)
+			}
+			if got != tc.wantHost {
+				t.Errorf("host = %q, want %q", got, tc.wantHost)
+			}
+		})
+	}
+}

+ 2 - 3
internal/web/controller/server.go

@@ -485,9 +485,8 @@ func (a *ServerController) scanRealityTarget(c *gin.Context) {
 	jsonObj(c, res, nil)
 }
 
-// scanRealityTargets probes a batch of candidate REALITY targets (the supplied
-// comma-separated list, or the built-in seed set when empty) and returns each
-// verdict ranked by feasibility then latency.
+// scanRealityTargets probes the supplied comma-separated targets, or the
+// realityScanCandidates setting when empty, ranked by feasibility then latency.
 func (a *ServerController) scanRealityTargets(c *gin.Context) {
 	res, err := a.serverService.ScanRealityTargets(c.PostForm("targets"))
 	if err != nil {

+ 52 - 7
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)
+}

+ 77 - 0
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())
+	}
+}

+ 1 - 0
internal/web/controller/xray_setting.go

@@ -116,6 +116,7 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
 		"inboundTags":       json.RawMessage(inboundTags),
 		"clientReverseTags": json.RawMessage(clientReverseTags),
 		"outboundTestUrl":   outboundTestUrl,
+		"geodataSources":    service.StandardGeodataSources(),
 	}
 
 	// Surface subscription outbounds (and their tags) so the frontend can:

+ 30 - 17
internal/web/entity/entity.go

@@ -19,16 +19,17 @@ type Msg struct {
 }
 
 type AllSetting struct {
-	WebListen         string `json:"webListen" form:"webListen"`
-	WebDomain         string `json:"webDomain" form:"webDomain"`
-	WebPort           int    `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"`
-	WebCertFile       string `json:"webCertFile" form:"webCertFile"`
-	WebKeyFile        string `json:"webKeyFile" form:"webKeyFile"`
-	WebBasePath       string `json:"webBasePath" form:"webBasePath"`
-	SessionMaxAge     int    `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"`
-	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
-	IpLimitAllowlist  string `json:"ipLimitAllowlist" form:"ipLimitAllowlist"`
-	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`
+	WebListen             string `json:"webListen" form:"webListen"`
+	WebDomain             string `json:"webDomain" form:"webDomain"`
+	WebPort               int    `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"`
+	WebCertFile           string `json:"webCertFile" form:"webCertFile"`
+	WebKeyFile            string `json:"webKeyFile" form:"webKeyFile"`
+	WebBasePath           string `json:"webBasePath" form:"webBasePath"`
+	SessionMaxAge         int    `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"`
+	TrustedProxyCIDRs     string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
+	RealityScanCandidates string `json:"realityScanCandidates" form:"realityScanCandidates"`
+	IpLimitAllowlist      string `json:"ipLimitAllowlist" form:"ipLimitAllowlist"`
+	PanelOutbound         string `json:"panelOutbound" form:"panelOutbound"`
 
 	PageSize                   int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
 	ExpireDiff                 int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
@@ -65,6 +66,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 +180,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 {

+ 47 - 0
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)
+	}
+}

+ 28 - 4
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
 			}

+ 296 - 0
internal/web/service/discord/discord.go

@@ -0,0 +1,296 @@
+package discord
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"os"
+	"strings"
+	"time"
+	"unicode/utf16"
+
+	"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
+}
+
+// discordCharLen counts what Discord's caps count: a rune outside the BMP is
+// two units there, so a rune count understates an emoji-bearing field.
+func discordCharLen(s string) int {
+	return len(utf16.Encode([]rune(s)))
+}
+
+// RateLimitedError is a 429, carrying the wait Discord asks for so a caller
+// sending several messages can back off instead of losing the rest of them.
+type RateLimitedError struct {
+	RetryAfter time.Duration
+}
+
+func (e *RateLimitedError) Error() string {
+	return fmt.Sprintf("discord rate limited (429): retry after %s", e.RetryAfter)
+}
+
+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:
+		var limited struct {
+			RetryAfter float64 `json:"retry_after"`
+		}
+		_ = json.Unmarshal(respBody, &limited)
+		return &RateLimitedError{RetryAfter: time.Duration(limited.RetryAfter * float64(time.Second))}
+	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)
+}

+ 355 - 0
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"]))
+	}
+}

+ 754 - 0
internal/web/service/discord/gateway.go

@@ -0,0 +1,754 @@
+package discord
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"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
+
+	// Discord rejects a whole message past these caps: 25 fields per embed, ten
+	// embeds, 6000 counted characters.
+	discordEmbedFieldLimit  = 25
+	discordEmbedsPerMsg     = 10
+	discordMessageCharLimit = 6000
+
+	// How long a 429 may ask a paged reply to wait before it gives up on the
+	// page: longer than this and the operator is staring at a dead command.
+	discordRateLimitWait = 5 * time.Second
+)
+
+// 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)
+
+	// Discord answers every heartbeat with op 11; a half-open socket keeps taking
+	// writes and never answers, so a missing ACK means this one must be dropped.
+	var acked atomic.Bool
+	acked.Store(true)
+
+	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:
+				if !acked.Swap(false) {
+					logger.Warning("Discord heartbeats went unanswered; dropping the zombied gateway connection")
+					_ = conn.Close()
+					return
+				}
+				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:
+			acked.Store(true)
+		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 <email>", 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")
+				switch {
+				case client.ExpiryTime > 0:
+					expireStr = time.Unix(client.ExpiryTime/1000, 0).Format("2006-01-02 15:04:05")
+				// Start After First Use stores the duration negated, so such a client is
+				// not unlimited: it starts counting down on its first connection.
+				case client.ExpiryTime < 0:
+					expireStr = fmt.Sprintf("%d %s", client.ExpiryTime/-86400000, tr("tgbot.days"))
+				}
+
+				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, cleanField(fmt.Sprintf("📍 %s", in.Remark), val, false))
+	}
+
+	title := tr("discord.commands.inboundsTitle")
+	description := tr("discord.commands.inboundsDescription", "Count=="+strconv.Itoa(len(inbounds)))
+	footer := tr("discord.footer")
+	overhead := discordCharLen(title) + discordCharLen(description) + discordCharLen(footer)
+	now := time.Now().UTC().Format(time.RFC3339)
+
+	for _, group := range splitInboundFields(fields, overhead) {
+		embeds := make([]Embed, 0, len(group)/discordEmbedFieldLimit+1)
+		for start := 0; start < len(group); start += discordEmbedFieldLimit {
+			embed := Embed{
+				Color:     ColorBlue,
+				Timestamp: now,
+				Fields:    group[start:min(start+discordEmbedFieldLimit, len(group))],
+			}
+			// The header leads the reply only once per message; later embeds of a
+			// paged panel would otherwise repeat it for every 25 inbounds.
+			if len(embeds) == 0 {
+				embed.Title = title
+				embed.Description = description
+				embed.Footer = &EmbedFooter{Text: footer}
+			}
+			embeds = append(embeds, embed)
+		}
+
+		payload := MessagePayload{Embeds: embeds}
+		err := g.discordService.SendMessage(ctx, payload)
+		var limited *RateLimitedError
+		if errors.As(err, &limited) && limited.RetryAfter > 0 && limited.RetryAfter <= discordRateLimitWait {
+			select {
+			case <-ctx.Done():
+				return
+			case <-time.After(limited.RetryAfter):
+			}
+			err = g.discordService.SendMessage(ctx, payload)
+		}
+		// One page Discord refused must not take the pages behind it down: the
+		// operator is better served by a partial list than by nothing at all.
+		if err != nil {
+			logger.Warning("Discord inbounds command: send failed: ", err)
+		}
+	}
+}
+
+// splitInboundFields packs fields into groups that each fit one Discord message,
+// within the character count Discord counts across its embeds and its embed cap.
+func splitInboundFields(fields []EmbedField, overhead int) [][]EmbedField {
+	groups := make([][]EmbedField, 0, 1)
+	group := make([]EmbedField, 0, discordEmbedFieldLimit)
+	chars := overhead
+	for _, field := range fields {
+		size := discordCharLen(field.Name) + discordCharLen(field.Value)
+		if len(group) > 0 && (len(group) >= discordEmbedFieldLimit*discordEmbedsPerMsg || chars+size > discordMessageCharLimit) {
+			groups = append(groups, group)
+			group = make([]EmbedField, 0, discordEmbedFieldLimit)
+			chars = overhead
+		}
+		group = append(group, field)
+		chars += size
+	}
+	if len(group) > 0 {
+		groups = append(groups, group)
+	}
+	return groups
+}
+
+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")})
+	}
+}

+ 111 - 0
internal/web/service/discord/gateway_delayed_expiry_test.go

@@ -0,0 +1,111 @@
+package discord
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"sync"
+	"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"
+)
+
+// expiryField is the position of the expiry field in the usage embed.
+const expiryField = 5
+
+func runUsageCommand(t *testing.T, settingService service.SettingService, inbounds []*model.Inbound, email string) MessagePayload {
+	t.Helper()
+	var mu sync.Mutex
+	var sent []MessagePayload
+	restServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var payload MessagePayload
+		_ = json.NewDecoder(r.Body).Decode(&payload)
+		mu.Lock()
+		sent = append(sent, payload)
+		mu.Unlock()
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`{"id": "msg-1"}`))
+	}))
+	t.Cleanup(restServer.Close)
+
+	svc := NewDiscordService(settingService)
+	svc.SetBaseURL(restServer.URL)
+	svc.SetHTTPClient(restServer.Client())
+
+	gw := NewGatewayClient(svc, settingService, &mockServerProvider{}, &mockInboundProvider{inbounds: inbounds}, &mockXrayRestart{})
+	gw.handleMessage(context.Background(), MessageCreateData{
+		ID:        "m1",
+		ChannelID: "ch-1",
+		Content:   "!usage " + email,
+		Author: struct {
+			ID       string `json:"id"`
+			Username string `json:"username"`
+			Bot      bool   `json:"bot"`
+		}{ID: "u1", Username: "Alice"},
+	})
+
+	mu.Lock()
+	defer mu.Unlock()
+	if len(sent) == 0 {
+		t.Fatalf("!usage %s sent nothing", email)
+	}
+	return sent[0]
+}
+
+func usageExpiryValue(t *testing.T, payload MessagePayload) string {
+	t.Helper()
+	if len(payload.Embeds) != 1 {
+		t.Fatalf("expected one embed, got %d", len(payload.Embeds))
+	}
+	fields := payload.Embeds[0].Fields
+	if len(fields) <= expiryField {
+		t.Fatalf("usage embed has %d fields, want at least %d", len(fields), expiryField+1)
+	}
+	return fields[expiryField].Value
+}
+
+func TestUsageExpiryForDelayedStart(t *testing.T) {
+	settingService := setupTestDB(t)
+	_ = settingService.SetDiscordBotToken("test-bot-token")
+	_ = settingService.SetDiscordChannelId("ch-1")
+	_ = settingService.SetDiscordAdminIds("u1")
+
+	const email = "delayed@test"
+
+	t.Run("a start-after-first-use client counts down in days", func(t *testing.T) {
+		// -2592000000 ms is what the panel stores for "Start After First Use: 30 days".
+		inbounds := []*model.Inbound{{
+			Id:          1,
+			Remark:      "delayed",
+			Port:        443,
+			Protocol:    "vless",
+			Enable:      true,
+			ClientStats: []xray.ClientTraffic{{Email: email, Enable: true, ExpiryTime: -2592000000}},
+		}}
+
+		got := usageExpiryValue(t, runUsageCommand(t, settingService, inbounds, email))
+		if got != "30 Days" {
+			t.Errorf("delayed start shows %q, want %q", got, "30 Days")
+		}
+	})
+
+	t.Run("an absolute deadline still shows as a date", func(t *testing.T) {
+		const deadline = int64(4102444800000) // 2100-01-01 UTC, in ms
+		inbounds := []*model.Inbound{{
+			Id:          2,
+			Remark:      "deadline",
+			Port:        8443,
+			Protocol:    "vless",
+			Enable:      true,
+			ClientStats: []xray.ClientTraffic{{Email: email, Enable: true, ExpiryTime: deadline}},
+		}}
+
+		got := usageExpiryValue(t, runUsageCommand(t, settingService, inbounds, email))
+		if got == "Unlimited" || got == "30 Days" {
+			t.Errorf("absolute deadline shows %q, want a formatted date", got)
+		}
+	})
+}

+ 64 - 0
internal/web/service/discord/gateway_heartbeat_ack_test.go

@@ -0,0 +1,64 @@
+package discord
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/gorilla/websocket"
+)
+
+func TestGatewayDropsConnectionWhenHeartbeatsGoUnanswered(t *testing.T) {
+	settingService := setupTestDB(t)
+	_ = settingService.SetDiscordBotEnable(true)
+	_ = settingService.SetDiscordBotToken("test-gw-token")
+
+	upgrader := websocket.Upgrader{}
+	clientClosed := make(chan struct{})
+	var once sync.Once
+	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()
+
+		// Hello with a short interval and not one op 11 after it: the socket stays
+		// open and reads fine, which is what a zombied connection looks like.
+		hello := GatewayPayload{Op: opHello}
+		hello.D, _ = json.Marshal(HelloData{HeartbeatInterval: 50})
+		if err := conn.WriteJSON(hello); err != nil {
+			return
+		}
+		for {
+			var payload GatewayPayload
+			if err := conn.ReadJSON(&payload); err != nil {
+				once.Do(func() { close(clientClosed) })
+				return
+			}
+		}
+	}))
+	defer wsServer.Close()
+
+	discordSvc := NewDiscordService(settingService)
+	gw := NewGatewayClient(discordSvc, settingService, &mockServerProvider{}, &mockInboundProvider{}, &mockXrayRestart{})
+	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 <-clientClosed:
+	case <-time.After(3 * time.Second):
+		t.Fatal("gateway held on to a connection whose heartbeats were never acknowledged")
+	}
+}

+ 218 - 0
internal/web/service/discord/gateway_inbounds_limits_test.go

@@ -0,0 +1,218 @@
+package discord
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync"
+	"testing"
+	"unicode/utf16"
+
+	"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"
+)
+
+// Discord's published caps, pinned here and not read from the package so the
+// assertions still redden if those caps are ever loosened.
+const (
+	discordFieldNameLimit   = 256
+	discordFieldValueLimit  = 1024
+	discordEmbedFieldCap    = 25
+	discordEmbedsPerMessage = 10
+	discordMessageCharCap   = 6000
+)
+
+// utf16Units counts the way Discord counts: its caps follow JavaScript string
+// length, where an astral rune is two units.
+func utf16Units(s string) int {
+	return len(utf16.Encode([]rune(s)))
+}
+
+func inboundFixtures(count int) []*model.Inbound {
+	inbounds := make([]*model.Inbound, 0, count)
+	for i := range count {
+		inbounds = append(inbounds, &model.Inbound{
+			Id:       i + 1,
+			Remark:   fmt.Sprintf("inbound-%d", i),
+			Port:     10000 + i,
+			Protocol: "vless",
+			Enable:   i%2 == 0,
+			ClientStats: []xray.ClientTraffic{
+				{Email: fmt.Sprintf("client-%d@test", i), Enable: true},
+			},
+		})
+	}
+	return inbounds
+}
+
+// assertsDiscordLimits checks every message against the caps Discord enforces and
+// returns how many inbound fields the reply carried in total.
+func assertsDiscordLimits(t *testing.T, msgs []MessagePayload) int {
+	t.Helper()
+	fields := 0
+	for i, msg := range msgs {
+		if len(msg.Embeds) > discordEmbedsPerMessage {
+			t.Errorf("message %d carries %d embeds, Discord accepts %d", i, len(msg.Embeds), discordEmbedsPerMessage)
+		}
+		chars := 0
+		for _, embed := range msg.Embeds {
+			footer := ""
+			if embed.Footer != nil {
+				footer = embed.Footer.Text
+			}
+			chars += utf16Units(embed.Title) + utf16Units(embed.Description) + utf16Units(footer)
+			if len(embed.Fields) > discordEmbedFieldCap {
+				t.Errorf("message %d has an embed with %d fields, Discord accepts %d", i, len(embed.Fields), discordEmbedFieldCap)
+			}
+			for _, field := range embed.Fields {
+				fields++
+				chars += utf16Units(field.Name) + utf16Units(field.Value)
+				if n := utf16Units(field.Name); n > discordFieldNameLimit {
+					t.Errorf("field name is %d units, Discord accepts %d", n, discordFieldNameLimit)
+				}
+				if n := utf16Units(field.Value); n > discordFieldValueLimit {
+					t.Errorf("field value is %d units, Discord accepts %d", n, discordFieldValueLimit)
+				}
+			}
+		}
+		if chars > discordMessageCharCap {
+			t.Errorf("message %d carries %d units, Discord accepts %d", i, chars, discordMessageCharCap)
+		}
+	}
+	return fields
+}
+
+// runInboundsCommandWith drives !inbounds against a channel that answers each
+// POST through respond, reporting what Discord accepted and the POST count.
+func runInboundsCommandWith(t *testing.T, settingService service.SettingService, inbounds []*model.Inbound, respond func(post int) (int, string)) ([]MessagePayload, int) {
+	t.Helper()
+	var mu sync.Mutex
+	var sent []MessagePayload
+	posts := 0
+	restServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var payload MessagePayload
+		_ = json.NewDecoder(r.Body).Decode(&payload)
+		mu.Lock()
+		posts++
+		post := posts
+		mu.Unlock()
+
+		status, body := respond(post)
+		if status == http.StatusOK {
+			mu.Lock()
+			sent = append(sent, payload)
+			mu.Unlock()
+		}
+		w.WriteHeader(status)
+		_, _ = w.Write([]byte(body))
+	}))
+	t.Cleanup(restServer.Close)
+
+	svc := NewDiscordService(settingService)
+	svc.SetBaseURL(restServer.URL)
+	svc.SetHTTPClient(restServer.Client())
+
+	gw := NewGatewayClient(svc, settingService, &mockServerProvider{}, &mockInboundProvider{inbounds: inbounds}, &mockXrayRestart{})
+	gw.handleMessage(context.Background(), MessageCreateData{
+		ID:        "m1",
+		ChannelID: "ch-1",
+		Content:   "!inbounds",
+		Author: struct {
+			ID       string `json:"id"`
+			Username string `json:"username"`
+			Bot      bool   `json:"bot"`
+		}{ID: "u1", Username: "Alice"},
+	})
+
+	mu.Lock()
+	defer mu.Unlock()
+	return append([]MessagePayload(nil), sent...), posts
+}
+
+func runInboundsCommand(t *testing.T, settingService service.SettingService, inbounds []*model.Inbound) []MessagePayload {
+	t.Helper()
+	msgs, _ := runInboundsCommandWith(t, settingService, inbounds, func(int) (int, string) {
+		return http.StatusOK, `{"id": "msg-1"}`
+	})
+	return msgs
+}
+
+func TestInboundsCommandStaysWithinDiscordLimits(t *testing.T) {
+	settingService := setupTestDB(t)
+	_ = settingService.SetDiscordBotToken("test-bot-token")
+	_ = settingService.SetDiscordChannelId("ch-1")
+	_ = settingService.SetDiscordAdminIds("u1")
+
+	t.Run("a large panel is paged instead of rejected", func(t *testing.T) {
+		const count = 300
+		msgs := runInboundsCommand(t, settingService, inboundFixtures(count))
+
+		if got := assertsDiscordLimits(t, msgs); got != count {
+			t.Errorf("reply listed %d inbounds, want %d", got, count)
+		}
+		if len(msgs) < 2 {
+			t.Errorf("%d inbounds reached Discord in %d message(s), want the reply paged", count, len(msgs))
+		}
+	})
+
+	t.Run("a remark past the field name cap is truncated, not dropped", func(t *testing.T) {
+		inbounds := inboundFixtures(3)
+		inbounds[0].Remark = strings.Repeat("r", 400)
+		msgs := runInboundsCommand(t, settingService, inbounds)
+
+		if got := assertsDiscordLimits(t, msgs); got != len(inbounds) {
+			t.Fatalf("reply listed %d inbounds, want %d", got, len(inbounds))
+		}
+		if len(msgs) != 1 {
+			t.Fatalf("expected one message for %d inbounds, got %d", len(inbounds), len(msgs))
+		}
+		name := msgs[0].Embeds[0].Fields[0].Name
+		if units := utf16Units(name); units != discordFieldNameLimit {
+			t.Errorf("truncated name is %d units, want %d: %q", units, discordFieldNameLimit, name)
+		}
+		if !strings.HasPrefix(name, "📍 "+strings.Repeat("r", 100)) {
+			t.Errorf("truncated name lost the remark: %q", name)
+		}
+	})
+
+	t.Run("an astral remark is cut to the cap, which runes would overshoot", func(t *testing.T) {
+		inbounds := inboundFixtures(40)
+		for _, in := range inbounds {
+			in.Remark = strings.Repeat("🚀", 300)
+		}
+		msgs := runInboundsCommand(t, settingService, inbounds)
+
+		if got := assertsDiscordLimits(t, msgs); got != len(inbounds) {
+			t.Errorf("reply listed %d inbounds, want %d", got, len(inbounds))
+		}
+	})
+}
+
+func TestInboundsCommandRetriesARateLimitedPage(t *testing.T) {
+	settingService := setupTestDB(t)
+	_ = settingService.SetDiscordBotToken("test-bot-token")
+	_ = settingService.SetDiscordChannelId("ch-1")
+	_ = settingService.SetDiscordAdminIds("u1")
+
+	const count = 300
+	msgs, posts := runInboundsCommandWith(t, settingService, inboundFixtures(count), func(post int) (int, string) {
+		if post == 1 {
+			return http.StatusTooManyRequests, `{"message": "You are being rate limited.", "retry_after": 0.05}`
+		}
+		return http.StatusOK, `{"id": "msg-1"}`
+	})
+
+	if got := assertsDiscordLimits(t, msgs); got != count {
+		t.Errorf("reply listed %d inbounds after the retry, want %d", got, count)
+	}
+	if len(msgs) < 2 {
+		t.Errorf("rate limited page left %d message(s), want the rest of the reply", len(msgs))
+	}
+	if posts != len(msgs)+1 {
+		t.Errorf("posted %d times for %d messages, want one retry of the limited page", posts, len(msgs))
+	}
+}

+ 531 - 0
internal/web/service/discord/gateway_test.go

@@ -0,0 +1,531 @@
+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 [email protected]",
+			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:  "[email protected]",
+						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: [email protected]") {
+				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()
+		// 10ms, not 1ms: Discord answers every heartbeat and the client now drops a
+		// socket it hears nothing back on, so the ACK needs room to arrive.
+		_ = conn.WriteJSON(GatewayPayload{Op: opHello, D: []byte(`{"heartbeat_interval": 10}`)})
+
+		// Two server goroutines write, so they share one writer: gorilla panics on
+		// concurrent writes, and this test is about the CLIENT's two writers.
+		var writeMu sync.Mutex
+		writeJSON := func(v any) error {
+			writeMu.Lock()
+			defer writeMu.Unlock()
+			return conn.WriteJSON(v)
+		}
+
+		readErr := make(chan error, 1)
+		go func() {
+			for {
+				var payload GatewayPayload
+				if err := conn.ReadJSON(&payload); err != nil {
+					readErr <- err
+					return
+				}
+				// Discord answers every heartbeat; without this the zombie check
+				// closes the socket a millisecond into the flood below.
+				if payload.Op == opHeartbeat {
+					if err := writeJSON(GatewayPayload{Op: opHeartbeatACK}); err != nil {
+						readErr <- err
+						return
+					}
+				}
+			}
+		}()
+		// Op 1 from the server makes the read loop write while the ticker writes too.
+		for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); {
+			if err := writeJSON(GatewayPayload{Op: opHeartbeat}); err != nil {
+				break
+			}
+			// Leave the client room to drain the flood and answer: a saturated
+			// socket delays the ACK this test now depends on.
+			time.Sleep(time.Millisecond)
+		}
+		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)
+			}
+		})
+	}
+}

+ 99 - 0
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)
+			}
+		})
+	}
+}

+ 241 - 0
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, ", ")
+}

+ 231 - 0
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: "[email protected]", Enable: true, Up: 100, Down: 200},
+					{Email: "[email protected]", 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")
+	}
+}

+ 357 - 0
internal/web/service/discord/subscriber.go

@@ -0,0 +1,357 @@
+package discord
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+	"time"
+	"unicode/utf16"
+
+	"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
+}
+
+// truncateUnits cuts s to maxUnits of the length Discord measures its field
+// name, value and footer caps by: runes alone overrun them on astral text.
+func truncateUnits(s string, maxUnits int) string {
+	if discordCharLen(s) <= maxUnits {
+		return s
+	}
+	suffix := "..."
+	budget := maxUnits
+	if maxUnits <= len(suffix) {
+		suffix = ""
+	} else {
+		budget -= len(suffix)
+	}
+	var b strings.Builder
+	units := 0
+	for _, r := range s {
+		size := utf16.RuneLen(r)
+		if size < 1 {
+			size = 1
+		}
+		if units+size > budget {
+			break
+		}
+		b.WriteRune(r)
+		units += size
+	}
+	return b.String() + suffix
+}
+
+func cleanField(name, value string, inline bool) EmbedField {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		name = "-"
+	} else {
+		name = truncateUnits(name, 256)
+	}
+	value = strings.TrimSpace(value)
+	if value == "" {
+		value = "-"
+	} else {
+		value = truncateUnits(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: truncateUnits("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
+}

+ 510 - 0
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)
+	}
+}

+ 24 - 3
internal/web/service/reality_scan.go

@@ -39,6 +39,10 @@ var defaultRealityScanCandidates = []string{
 	"dl.google.com:443",
 }
 
+// DefaultRealityScanCandidatesCSV is the shipped default for the
+// realityScanCandidates setting (comma-separated host:port list).
+var DefaultRealityScanCandidatesCSV = strings.Join(defaultRealityScanCandidates, ",")
+
 type RealityScanResult struct {
 	Target   string `json:"target" example:"www.cloudflare.com:443"`
 	Host     string `json:"host" example:"www.cloudflare.com"`
@@ -331,15 +335,32 @@ func (s *ServerService) ScanRealityTarget(target string, sni string, xver int, a
 	return s.probeRealityAddr(host, port, sni, realityScanTimeout, xver, allowPrivate), nil
 }
 
-func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
+func parseRealityScanCandidateCSV(csv string) []string {
 	var tokens []string
-	for raw := range strings.SplitSeq(targetsCSV, ",") {
+	for raw := range strings.SplitSeq(csv, ",") {
 		if t := strings.TrimSpace(raw); t != "" {
 			tokens = append(tokens, t)
 		}
 	}
+	return tokens
+}
+
+// realityScanCandidateTokens returns the operator-configured candidate list,
+// falling back to the shipped defaults when the setting is empty or unreadable.
+func (s *ServerService) realityScanCandidateTokens() []string {
+	csv, err := s.settingService.GetRealityScanCandidates()
+	if err != nil {
+		logger.Warning("reality scan: reading candidates setting failed:", err)
+	} else if tokens := parseRealityScanCandidateCSV(csv); len(tokens) > 0 {
+		return tokens
+	}
+	return append([]string(nil), defaultRealityScanCandidates...)
+}
+
+func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
+	tokens := parseRealityScanCandidateCSV(targetsCSV)
 	if len(tokens) == 0 {
-		tokens = append(tokens, defaultRealityScanCandidates...)
+		tokens = s.realityScanCandidateTokens()
 	}
 
 	var tasks []realityProbeTask

+ 16 - 0
internal/web/service/reality_scan_test.go

@@ -164,3 +164,19 @@ func TestWriteProxyProtocolV2Signature(t *testing.T) {
 		t.Fatalf("v2 family/protocol byte = 0x%02x, want 0x11 (TCP over IPv4)", hdr[13])
 	}
 }
+
+func TestParseRealityScanCandidateCSV(t *testing.T) {
+	got := parseRealityScanCandidateCSV(" a.com:443 , ,b.com:8443 ")
+	want := []string{"a.com:443", "b.com:8443"}
+	if len(got) != len(want) {
+		t.Fatalf("got %v, want %v", got, want)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Fatalf("got %v, want %v", got, want)
+		}
+	}
+	if tokens := parseRealityScanCandidateCSV("  , "); len(tokens) != 0 {
+		t.Fatalf("empty CSV should yield no tokens, got %v", tokens)
+	}
+}

+ 16 - 0
internal/web/service/server.go

@@ -2200,6 +2200,22 @@ var geofileAllowlist = map[string]geofileEntry{
 	"geosite_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat", "geosite.dat", "geosite_RU.dat"},
 }
 
+// GeodataSource identifies a file Xray downloads through its geodata configuration.
+type GeodataSource struct {
+	URL  string `json:"url"`
+	File string `json:"file"`
+}
+
+// StandardGeodataSources derives the panel presets from the geofile update allowlist.
+func StandardGeodataSources() []GeodataSource {
+	sources := make([]GeodataSource, 0, len(geofileAllowlist))
+	for _, entry := range geofileAllowlist {
+		sources = append(sources, GeodataSource{URL: entry.latestURL(), File: entry.FileName})
+	}
+	slices.SortFunc(sources, func(a, b GeodataSource) int { return strings.Compare(a.File, b.File) })
+	return sources
+}
+
 func (entry geofileEntry) latestURL() string {
 	return entry.Repo + "/releases/latest/download/" + entry.Asset
 }

+ 21 - 0
internal/web/service/server_geofile_test.go

@@ -392,3 +392,24 @@ func TestUpdateGeofileRejectsNameOutsideAllowlist(t *testing.T) {
 		t.Fatalf("error = %q, want it to name the allowlist", err)
 	}
 }
+
+func TestStandardGeodataSources(t *testing.T) {
+	want := []GeodataSource{
+		{URL: "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", File: "geoip.dat"},
+		{URL: "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", File: "geoip_IR.dat"},
+		{URL: "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", File: "geoip_RU.dat"},
+		{URL: "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", File: "geosite.dat"},
+		{URL: "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", File: "geosite_IR.dat"},
+		{URL: "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", File: "geosite_RU.dat"},
+	}
+
+	got := StandardGeodataSources()
+	if len(got) != len(want) {
+		t.Fatalf("sources = %d entries, want %d", len(got), len(want))
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Errorf("source %d = %+v, want %+v", i, got[i], want[i])
+		}
+	}
+}

+ 117 - 7
internal/web/service/setting.go

@@ -67,6 +67,7 @@ var defaultValueMap = map[string]string{
 	"webBasePath":                 normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
 	"sessionMaxAge":               "360",
 	"trustedProxyCIDRs":           DefaultTrustedProxyCIDRs,
+	"realityScanCandidates":       DefaultRealityScanCandidatesCSV,
 	"ipLimitAllowlist":            "",
 	"pageSize":                    "25",
 	"expireDiff":                  "0",
@@ -206,6 +207,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 +312,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 +321,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
 	view.TwoFactorToken = ""
 	view.LdapPassword = ""
 	view.SmtpPassword = ""
+	view.DiscordBotToken = ""
 	return view, nil
 }
 
@@ -691,6 +706,10 @@ func (s *SettingService) GetTrustedProxyCIDRs() (string, error) {
 	return s.getString("trustedProxyCIDRs")
 }
 
+func (s *SettingService) GetRealityScanCandidates() (string, error) {
+	return s.getString("realityScanCandidates")
+}
+
 func (s *SettingService) GetRemarkTemplate() (string, error) {
 	return s.getString("remarkTemplate")
 }
@@ -1320,6 +1339,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 +1436,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 +1566,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 +1780,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,
 }
 
 /*

+ 1 - 0
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 {

+ 29 - 3
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) {

+ 56 - 18
internal/web/service/tgbot/tgbot.go

@@ -63,26 +63,63 @@ var (
 		timestamp time.Time
 		mutex     sync.RWMutex
 	}
-
-	// clients data to adding new client. receiver_inbound_IDs is the set of
-	// inbounds the new client will be attached to; receiver_inbound_ID mirrors
-	// the primary pick for the legacy attach-picker entry point. Per-protocol
-	// secrets (UUID, password, flow, method) are filled per-inbound on submit
-	// by ClientService.fillProtocolDefaults, so the bot only tracks universal
-	// client fields here.
-	receiver_inbound_ID  int
-	receiver_inbound_IDs []int
-	client_Email         string
-	client_LimitIP       int
-	client_TotalGB       int64
-	client_ExpiryTime    int64
-	client_Enable        bool
-	client_TgID          string
-	client_SubID         string
-	client_Comment       string
-	client_Reset         int
 )
 
+// clientDraft is one chat's add-client wizard state. Per-protocol secrets are
+// filled per-inbound on submit, so only the universal fields live here.
+type clientDraft struct {
+	sync.Mutex
+	receiverInboundID  int
+	receiverInboundIDs []int
+	email              string
+	limitIP            int
+	totalGB            int64
+	expiryTime         int64
+	enable             bool
+	tgID               string
+	subID              string
+	comment            string
+	reset              int
+}
+
+// clientDrafts keys a draft by chat: the steps arrive on the worker pool, so a
+// single draft let two admins fill in one client between them.
+type clientDrafts struct {
+	mu     sync.Mutex
+	drafts map[int64]*clientDraft
+}
+
+var addClientDrafts = &clientDrafts{drafts: make(map[int64]*clientDraft)}
+
+func (s *clientDrafts) forChat(chatID int64) *clientDraft {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	draft, ok := s.drafts[chatID]
+	if !ok {
+		draft = &clientDraft{}
+		s.drafts[chatID] = draft
+	}
+	return draft
+}
+
+func (s *clientDrafts) reset(chatID int64) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	delete(s.drafts, chatID)
+}
+
+// isAddClientStep reports whether callback data belongs to the add-client
+// wizard, the only flow that reads or writes a draft.
+func isAddClientStep(data string) bool {
+	return strings.HasPrefix(data, "add_client")
+}
+
+func (s *clientDrafts) resetAll() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.drafts = make(map[int64]*clientDraft)
+}
+
 // userStateStore guards the per-chat conversation states. The Telegram command
 // and callback handlers run on a worker-pool goroutine while the message handler
 // runs on the dispatch goroutine, so a bare map would be a concurrent-map-write
@@ -482,6 +519,7 @@ func StopBot() {
 	tgBotMutex.Unlock()
 
 	userStateMgr.reset()
+	addClientDrafts.resetAll()
 
 	if handler != nil {
 		_ = handler.Stop()

+ 61 - 0
internal/web/service/tgbot/tgbot_add_client_expiry_test.go

@@ -0,0 +1,61 @@
+package tgbot
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mymmrac/telego"
+	"github.com/nicksnyder/go-i18n/v2/i18n"
+)
+
+// Pins the decision the dead accumulate branch hid: a preset tap chooses the term
+// instead of adding to it, and 0 is Unlimited. The custom keypad shares this case.
+func TestAddClientExpiryPresetReplacesTheTerm(t *testing.T) {
+	const chatID = int64(7505)
+	draftLocalizer(t,
+		&i18n.Message{ID: "tgbot.days", Other: "Days"},
+		&i18n.Message{ID: "tgbot.unlimited", Other: "Unlimited"},
+	)
+	url, textsFor := draftTexts(t)
+	swapTestBot(t, url)
+
+	// A fresh draft attaches no inbound, so the card never looks one up by remark.
+	draft := addClientDrafts.forChat(chatID)
+	origRunning := isRunning
+	t.Cleanup(func() {
+		addClientDrafts.reset(chatID)
+		isRunning = origRunning
+	})
+	isRunning = true
+
+	tb := &Tgbot{}
+	for _, tc := range []struct {
+		days string
+		want string
+	}{
+		{"30", "Expire: 30 Days"},  // not 37: a second tap replaces the first
+		{"90", "Expire: 90 Days"},  // not 97, which accumulating would show
+		{"0", "Expire: Unlimited"}, // the Unlimited button clears the term
+	} {
+		t.Run(tc.days, func(t *testing.T) {
+			// A term left by an earlier preset; every row has to fail on its own
+			// under the accumulate semantics this change rejected.
+			draft.expiryTime = -7 * 86400000
+
+			tb.answerCallback(&telego.CallbackQuery{
+				ID:      "q1",
+				From:    telego.User{ID: 1},
+				Data:    "add_client_reset_exp_c " + tc.days,
+				Message: &telego.Message{MessageID: 7, Chat: telego.Chat{ID: chatID}},
+			}, true) // admin
+
+			sent := textsFor(chatID)
+			if len(sent) == 0 {
+				t.Fatalf("add_client_reset_exp_c %s rendered no card", tc.days)
+			}
+			if got := sent[len(sent)-1]; !strings.Contains(got, tc.want) {
+				t.Errorf("card after add_client_reset_exp_c %s = %q, want it to contain %q", tc.days, got, tc.want)
+			}
+		})
+	}
+}

+ 36 - 34
internal/web/service/tgbot/tgbot_client.go

@@ -30,52 +30,52 @@ import (
 // shown in the multi-inbound add flow. Per-protocol secrets (UUID, password,
 // flow, method) are generated by fillProtocolDefaults on submit, so the bot
 // never has to track them per inbound itself.
-func (t *Tgbot) BuildClientDraftMessage() string {
+func (t *Tgbot) BuildClientDraftMessage(draft *clientDraft) string {
 	now := time.Now().UnixMilli()
 
 	expiry := ""
 	switch {
-	case client_ExpiryTime == 0:
+	case draft.expiryTime == 0:
 		expiry = t.I18nBot("tgbot.unlimited")
-	case client_ExpiryTime < 0:
-		expiry = fmt.Sprintf("%d %s", client_ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
+	case draft.expiryTime < 0:
+		expiry = fmt.Sprintf("%d %s", draft.expiryTime/-86400000, t.I18nBot("tgbot.days"))
 	default:
-		diff := client_ExpiryTime - now
+		diff := draft.expiryTime - now
 		if diff > 172800000 {
-			expiry = time.UnixMilli(client_ExpiryTime).Format("2006-01-02 15:04:05")
+			expiry = time.UnixMilli(draft.expiryTime).Format("2006-01-02 15:04:05")
 		} else {
 			expiry = fmt.Sprintf("%d %s", diff/3600000, t.I18nBot("tgbot.hours"))
 		}
 	}
 
 	traffic := "♾️ Unlimited(Reset)"
-	if client_TotalGB > 0 {
-		traffic = common.FormatTraffic(client_TotalGB)
+	if draft.totalGB > 0 {
+		traffic = common.FormatTraffic(draft.totalGB)
 	}
 
 	ipLimit := "♾️ Unlimited(Reset)"
-	if client_LimitIP > 0 {
-		ipLimit = fmt.Sprint(client_LimitIP)
+	if draft.limitIP > 0 {
+		ipLimit = fmt.Sprint(draft.limitIP)
 	}
 
-	attached := t.describeAttachedInbounds(receiver_inbound_IDs)
+	attached := t.describeAttachedInbounds(draft.receiverInboundIDs)
 	if attached == "" {
 		attached = "—"
 	}
 
-	comment := client_Comment
+	comment := draft.comment
 	if comment == "" {
 		comment = "—"
 	}
 
-	tgID := client_TgID
+	tgID := draft.tgID
 	if tgID == "" {
 		tgID = "—"
 	}
 
 	var b strings.Builder
 	b.WriteString("📝 <b>New client draft</b>\r\n")
-	fmt.Fprintf(&b, "📧 Email: <code>%s</code>\r\n", html.EscapeString(client_Email))
+	fmt.Fprintf(&b, "📧 Email: <code>%s</code>\r\n", html.EscapeString(draft.email))
 	fmt.Fprintf(&b, "🔗 Attached: %s\r\n", html.EscapeString(attached))
 	fmt.Fprintf(&b, "📊 Traffic: %s\r\n", traffic)
 	fmt.Fprintf(&b, "📅 Expire: %s\r\n", expiry)
@@ -111,25 +111,25 @@ func (t *Tgbot) describeAttachedInbounds(ids []int) string {
 // the full set of attached inbound ids. Per-inbound fillProtocolDefaults on
 // the panel generates UUID/password/auth per protocol, so the bot only
 // supplies the universal fields it actually collected.
-func (t *Tgbot) SubmitAddClient() (bool, error) {
-	inboundIDs := receiver_inbound_IDs
-	if len(inboundIDs) == 0 && receiver_inbound_ID > 0 {
-		inboundIDs = []int{receiver_inbound_ID}
+func (t *Tgbot) SubmitAddClient(draft *clientDraft) (bool, error) {
+	inboundIDs := draft.receiverInboundIDs
+	if len(inboundIDs) == 0 && draft.receiverInboundID > 0 {
+		inboundIDs = []int{draft.receiverInboundID}
 	}
 	if len(inboundIDs) == 0 {
 		return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
 	}
 
-	tgIDInt, _ := strconv.ParseInt(client_TgID, 10, 64)
+	tgIDInt, _ := strconv.ParseInt(draft.tgID, 10, 64)
 	client := model.Client{
-		Email:      client_Email,
-		Enable:     client_Enable,
-		LimitIP:    client_LimitIP,
-		TotalGB:    client_TotalGB,
-		ExpiryTime: client_ExpiryTime,
-		SubID:      client_SubID,
-		Comment:    client_Comment,
-		Reset:      client_Reset,
+		Email:      draft.email,
+		Enable:     draft.enable,
+		LimitIP:    draft.limitIP,
+		TotalGB:    draft.totalGB,
+		ExpiryTime: draft.expiryTime,
+		SubID:      draft.subID,
+		Comment:    draft.comment,
+		Reset:      draft.reset,
 		TgID:       tgIDInt,
 	}
 
@@ -442,6 +442,11 @@ func (t *Tgbot) clientInfoMsg(
 	diff := traffic.ExpiryTime/1000 - now
 	if traffic.ExpiryTime == 0 {
 		expiryTime = t.I18nBot("tgbot.unlimited")
+	} else if traffic.ExpiryTime < 0 {
+		// A negative expiry counts days from first use, not a date; the disabled
+		// branch below would otherwise render it as a 1969 timestamp.
+		expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
+		flag = true
 	} else if diff > 172800 || !traffic.Enable {
 		expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05")
 		if diff > 0 {
@@ -460,9 +465,6 @@ func (t *Tgbot) clientInfoMsg(
 			}
 			expiryTime += fmt.Sprintf(" (%s)", remainingTime)
 		}
-	} else if traffic.ExpiryTime < 0 {
-		expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
-		flag = true
 	} else {
 		expiryTime = fmt.Sprintf("%d %s", diff/3600, t.I18nBot("tgbot.hours"))
 		flag = true
@@ -759,8 +761,8 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
 // client-first multi-inbound add flow. Per-protocol secrets (UUID, password,
 // flow, method) are generated by fillProtocolDefaults on submit, so the bot
 // only exposes the universal client fields here.
-func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
-	attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(receiver_inbound_IDs))
+func (t *Tgbot) getCommonClientButtons(draft *clientDraft) [][]telego.InlineKeyboardButton {
+	attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(draft.receiverInboundIDs))
 	return [][]telego.InlineKeyboardButton{
 		tu.InlineKeyboardRow(
 			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
@@ -788,8 +790,8 @@ func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
 }
 
 // addClient renders the draft message + shared client-first keyboard.
-func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
-	inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons()...)
+func (t *Tgbot) addClient(chatId int64, draft *clientDraft, msg string, messageID ...int) {
+	inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons(draft)...)
 	if len(messageID) > 0 {
 		t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
 	} else {

+ 178 - 0
internal/web/service/tgbot/tgbot_client_draft_per_chat_test.go

@@ -0,0 +1,178 @@
+package tgbot
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+
+	"github.com/mymmrac/telego"
+)
+
+// draftTexts serves the methods the add-client wizard touches and records the
+// text of every sendMessage and editMessageText per chat.
+func draftTexts(t *testing.T) (string, func(int64) []string) {
+	t.Helper()
+	var mu sync.Mutex
+	texts := map[int64][]string{}
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		result := any(true)
+		if r.URL.Path == "/bot"+testBotToken+"/sendMessage" || r.URL.Path == "/bot"+testBotToken+"/editMessageText" {
+			var payload struct {
+				ChatID any    `json:"chat_id"`
+				Text   string `json:"text"`
+			}
+			_ = json.Unmarshal(body, &payload)
+			chatID := int64(0)
+			switch v := payload.ChatID.(type) {
+			case float64:
+				chatID = int64(v)
+			}
+			mu.Lock()
+			texts[chatID] = append(texts[chatID], payload.Text)
+			mu.Unlock()
+			result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": chatID, "type": "private"}}
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
+	}))
+	t.Cleanup(srv.Close)
+
+	return srv.URL, func(chatID int64) []string {
+		mu.Lock()
+		defer mu.Unlock()
+		return append([]string(nil), texts[chatID]...)
+	}
+}
+
+// cardEmail reads the email off a rendered draft card, which is the field the
+// wizard assigns when the flow starts.
+func cardEmail(t *testing.T, card string) string {
+	t.Helper()
+	const marker = "Email: <code>"
+	start := strings.Index(card, marker)
+	if start < 0 {
+		t.Fatalf("not a draft card: %q", card)
+	}
+	rest := card[start+len(marker):]
+	end := strings.Index(rest, "</code>")
+	if end < 0 {
+		t.Fatalf("card has an unterminated email: %q", card)
+	}
+	return rest[:end]
+}
+
+func lastDraftCard(t *testing.T, texts []string) string {
+	t.Helper()
+	for i := len(texts) - 1; i >= 0; i-- {
+		if strings.Contains(texts[i], "Email: <code>") {
+			return texts[i]
+		}
+	}
+	t.Fatal("no draft card reached the chat")
+	return ""
+}
+
+// Regression test: one package-level draft per bot meant an admin's new client
+// was filled in by another chat's steps.
+func TestAddClientDraftIsPerChat(t *testing.T) {
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	const (
+		chatA = int64(7101)
+		chatB = int64(7202)
+	)
+	url, textsFor := draftTexts(t)
+	swapTestBot(t, url)
+	origRunning := isRunning
+	t.Cleanup(func() { isRunning = origRunning })
+	isRunning = true
+
+	callback := func(chatID int64, data string) {
+		t.Helper()
+		(&Tgbot{}).answerCallback(&telego.CallbackQuery{
+			ID:      "q1",
+			From:    telego.User{ID: 1},
+			Data:    data,
+			Message: &telego.Message{MessageID: 7, Chat: telego.Chat{ID: chatID}},
+		}, true)
+	}
+
+	// Both admins start a client; each card carries the email the wizard just
+	// generated for that chat.
+	callback(chatA, "add_client_to 1")
+	callback(chatB, "add_client_to 2")
+	emailA := cardEmail(t, lastDraftCard(t, textsFor(chatA)))
+	emailB := cardEmail(t, lastDraftCard(t, textsFor(chatB)))
+	if emailA == "" || emailA == emailB {
+		t.Fatalf("drafts start with the same email %q, want one per chat", emailA)
+	}
+
+	// Chat A renders its card again, with chat B's wizard already past its start.
+	callback(chatA, "add_client_default_traffic_exp")
+
+	if got := cardEmail(t, lastDraftCard(t, textsFor(chatA))); got != emailA {
+		t.Errorf("chat A's card shows email %q, want its own %q from chat B's draft", got, emailA)
+	}
+	if got := cardEmail(t, lastDraftCard(t, textsFor(chatB))); got != emailB {
+		t.Errorf("chat B's card shows email %q, want %q", got, emailB)
+	}
+}
+
+// Regression test: the draft's lock and map were reached before the admin gate, so
+// a report tap queued behind a wizard and any chat a tap came from got stored.
+func TestNonWizardCallbackTakesNoDraftLock(t *testing.T) {
+	const (
+		heldChat  = int64(7303)
+		spareChat = int64(7404)
+	)
+	decliningServer(t)
+
+	held := addClientDrafts.forChat(heldChat)
+	held.Lock()
+	defer held.Unlock()
+
+	tap := func(chatID int64, isAdmin bool, data string) {
+		(&Tgbot{}).answerCallback(&telego.CallbackQuery{
+			ID:      "q1",
+			From:    telego.User{ID: 1},
+			Data:    data,
+			Message: &telego.Message{Chat: telego.Chat{ID: chatID}},
+		}, isAdmin)
+	}
+	returns := func(what string, tap func()) {
+		t.Helper()
+		done := make(chan struct{})
+		go func() {
+			defer close(done)
+			tap()
+		}()
+		select {
+		case <-done:
+		case <-time.After(2 * time.Second):
+			t.Fatalf("%s waited on the draft lock it never reads", what)
+		}
+	}
+
+	returns("an admin report tap", func() { tap(heldChat, true, "no_such_admin_action 5") })
+	returns("a non-admin wizard tap", func() { tap(heldChat, false, "add_client_to 1") })
+	tap(spareChat, false, "add_client_to 1")
+
+	addClientDrafts.mu.Lock()
+	_, stored := addClientDrafts.drafts[spareChat]
+	addClientDrafts.mu.Unlock()
+	if stored {
+		t.Errorf("draft stored for chat %d, want none until its wizard starts", spareChat)
+	}
+}

+ 58 - 0
internal/web/service/tgbot/tgbot_client_expiry_test.go

@@ -0,0 +1,58 @@
+package tgbot
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"github.com/nicksnyder/go-i18n/v2/i18n"
+	"golang.org/x/text/language"
+)
+
+// clientInfoLocalizer renders the lines clientInfoMsg prints with the templates
+// the translation files carry; without it I18n returns the bare keys.
+func clientInfoLocalizer(t *testing.T) {
+	t.Helper()
+	bundle := i18n.NewBundle(language.MustParse("en-US"))
+	bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
+	_ = bundle.AddMessages(language.MustParse("en-US"),
+		&i18n.Message{ID: "tgbot.messages.email", Other: "Email: {{ .Email }}\r\n"},
+		&i18n.Message{ID: "tgbot.days", Other: "Days"},
+		&i18n.Message{ID: "tgbot.messages.expireIn", Other: "Expire In: {{ .Time }}\r\n"},
+		&i18n.Message{ID: "tgbot.messages.expire", Other: "Expire Date: {{ .Time }}\r\n"},
+		&i18n.Message{ID: "tgbot.wentWrong", Other: "went wrong"},
+	)
+	orig := locale.LocalizerBot
+	t.Cleanup(func() { locale.LocalizerBot = orig })
+	locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
+}
+
+// Regression test: a start-after-first-use client is stored as a negative duration,
+// and a disabled one rendered it as a 1969 date.
+func TestClientInfoShowsStartAfterFirstUseWhenDisabled(t *testing.T) {
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	clientInfoLocalizer(t)
+
+	traffic := &xray.ClientTraffic{
+		Email:      "[email protected]",
+		Enable:     false,
+		ExpiryTime: -30 * 24 * 60 * 60000,
+	}
+
+	out := (&Tgbot{}).clientInfoMsg(traffic, false, false, false, true, false, false)
+
+	if strings.Contains(out, "1969") {
+		t.Errorf("client info = %q, want the days left, not a 1969 date", out)
+	}
+	if !strings.Contains(out, "Expire In: 30 Days") {
+		t.Errorf("client info = %q, want it to contain %q", out, "Expire In: 30 Days")
+	}
+}

+ 25 - 62
internal/web/service/tgbot/tgbot_draft_render_test.go

@@ -3,11 +3,7 @@ package tgbot
 import (
 	"encoding/json"
 	"html"
-	"io"
-	"net/http"
-	"net/http/httptest"
 	"strings"
-	"sync"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
@@ -17,25 +13,23 @@ import (
 	"golang.org/x/text/language"
 )
 
+// clientDraftTestChatID is a chat id no other test drives, so the draft this
+// test fills cannot leak into them.
+const clientDraftTestChatID = -9001
+
 // Regression test: the draft is sent with ParseMode HTML, so Markdown markers
 // were rendered literally and an unescaped value could break the whole message.
 func TestClientDraftMessageRendersHTML(t *testing.T) {
-	origEmail, origComment, origTgID := client_Email, client_Comment, client_TgID
-	origTotalGB, origLimitIP, origExpiry := client_TotalGB, client_LimitIP, client_ExpiryTime
-	origInboundIDs := receiver_inbound_IDs
-	t.Cleanup(func() {
-		client_Email, client_Comment, client_TgID = origEmail, origComment, origTgID
-		client_TotalGB, client_LimitIP, client_ExpiryTime = origTotalGB, origLimitIP, origExpiry
-		receiver_inbound_IDs = origInboundIDs
-	})
+	draft := addClientDrafts.forChat(clientDraftTestChatID)
+	t.Cleanup(func() { addClientDrafts.reset(clientDraftTestChatID) })
 
-	client_Email = "[email protected]"
-	client_Comment = "<b>promo</b> & <10 GB>"
-	client_TgID = "42"
-	client_TotalGB, client_LimitIP, client_ExpiryTime = 0, 0, 0
-	receiver_inbound_IDs = nil
+	draft.email = "[email protected]"
+	draft.comment = "<b>promo</b> & <10 GB>"
+	draft.tgID = "42"
+	draft.totalGB, draft.limitIP, draft.expiryTime = 0, 0, 0
+	draft.receiverInboundIDs = nil
 
-	out := (&Tgbot{}).BuildClientDraftMessage()
+	out := (&Tgbot{}).BuildClientDraftMessage(draft)
 
 	if !strings.Contains(out, "<b>New client draft</b>") {
 		t.Errorf("draft title is not HTML markup: %q", out)
@@ -46,68 +40,37 @@ func TestClientDraftMessageRendersHTML(t *testing.T) {
 	if strings.Contains(out, "<b>promo</b>") {
 		t.Errorf("raw comment markup reached the message: %q", out)
 	}
-	if !strings.Contains(out, html.EscapeString(client_Comment)) {
+	if !strings.Contains(out, html.EscapeString(draft.comment)) {
 		t.Errorf("comment is not HTML-escaped: %q", out)
 	}
 }
 
-// botPromptLocalizer renders the two prompts the callback tests drive, with the
+// draftLocalizer registers only the messages a wizard test drives, with the
 // templates the translation files carry; without it I18n returns the bare key.
-func botPromptLocalizer(t *testing.T) {
+func draftLocalizer(t *testing.T, msgs ...*i18n.Message) {
 	t.Helper()
 	bundle := i18n.NewBundle(language.MustParse("en-US"))
 	bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
-	_ = bundle.AddMessages(language.MustParse("en-US"),
-		&i18n.Message{ID: "tgbot.messages.email_prompt", Other: "📧 Default Email: {{ .ClientEmail }}\n\nEnter your email."},
-		&i18n.Message{ID: "tgbot.messages.comment_prompt", Other: "💬 Default Comment: {{ .ClientComment }}\n\nEnter your comment."},
-	)
+	_ = bundle.AddMessages(language.MustParse("en-US"), msgs...)
 	orig := locale.LocalizerBot
 	t.Cleanup(func() { locale.LocalizerBot = orig })
 	locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
 }
 
-// promptTexts serves the methods these prompts touch and returns the text of
-// every sendMessage, so a test can check what Telegram would actually parse.
-func promptTexts(t *testing.T) (string, func() []string) {
-	t.Helper()
-	var mu sync.Mutex
-	var texts []string
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		body, _ := io.ReadAll(r.Body)
-		result := any(true)
-		if r.URL.Path == "/bot"+testBotToken+"/sendMessage" {
-			var payload struct {
-				Text string `json:"text"`
-			}
-			_ = json.Unmarshal(body, &payload)
-			mu.Lock()
-			texts = append(texts, payload.Text)
-			mu.Unlock()
-			result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}}
-		}
-		w.Header().Set("Content-Type", "application/json")
-		_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
-	}))
-	t.Cleanup(srv.Close)
-
-	return srv.URL, func() []string {
-		mu.Lock()
-		defer mu.Unlock()
-		return append([]string(nil), texts...)
-	}
-}
-
 // Regression test: the wizard's own prompts are HTML-parsed as well, so the
 // draft value they echo has to be escaped exactly like the draft card.
 func TestAddClientPromptsEscapeDraftValues(t *testing.T) {
-	botPromptLocalizer(t)
-	url, texts := promptTexts(t)
+	draftLocalizer(t,
+		&i18n.Message{ID: "tgbot.messages.email_prompt", Other: "📧 Default Email: {{ .ClientEmail }}\n\nEnter your email."},
+		&i18n.Message{ID: "tgbot.messages.comment_prompt", Other: "💬 Default Comment: {{ .ClientComment }}\n\nEnter your comment."},
+	)
+	url, texts := draftTexts(t)
 	swapTestBot(t, url)
 
-	origEmail, origComment := client_Email, client_Comment
+	draft := addClientDrafts.forChat(1)
 	origRunning := isRunning
 	t.Cleanup(func() {
-		client_Email, client_Comment = origEmail, origComment
+		addClientDrafts.reset(1)
 		isRunning = origRunning
 	})
 	isRunning = true
@@ -123,7 +86,7 @@ func TestAddClientPromptsEscapeDraftValues(t *testing.T) {
 	tb := &Tgbot{}
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			client_Email, client_Comment = tc.value, tc.value
+			draft.email, draft.comment = tc.value, tc.value
 
 			tb.answerCallback(&telego.CallbackQuery{
 				ID:      "q1",
@@ -132,7 +95,7 @@ func TestAddClientPromptsEscapeDraftValues(t *testing.T) {
 				Message: &telego.Message{Chat: telego.Chat{ID: 1}},
 			}, true) // admin
 
-			sent := texts()
+			sent := texts(1)
 			if len(sent) == 0 {
 				t.Fatalf("no prompt was sent for %s", tc.data)
 			}

+ 3 - 3
internal/web/service/tgbot/tgbot_inbound.go

@@ -185,7 +185,7 @@ func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
 // current selection state for the inbound; tapping fires
 // add_client_toggle_attach <id> which flips it and re-renders. A final
 // "Done" button (add_client_attach_done) returns to the field-edit screen.
-func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error) {
+func (t *Tgbot) getInboundsAttachPicker(draft *clientDraft) (*telego.InlineKeyboardMarkup, error) {
 	inbounds, err := t.inboundService.GetAllInbounds()
 	if err != nil {
 		logger.Warning("GetAllInbounds run failed:", err)
@@ -201,8 +201,8 @@ func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error)
 		model.AmneziaWG: true,
 		model.HTTP:      true,
 	}
-	selected := make(map[int]bool, len(receiver_inbound_IDs))
-	for _, id := range receiver_inbound_IDs {
+	selected := make(map[int]bool, len(draft.receiverInboundIDs))
+	for _, id := range draft.receiverInboundIDs {
 		selected[id] = true
 	}
 	var buttons []telego.InlineKeyboardButton

+ 97 - 94
internal/web/service/tgbot/tgbot_router.go

@@ -114,16 +114,20 @@ func (t *Tgbot) OnReceive() {
 			defer recoverBotPanic()
 			userStateMgr.maybePrune(time.Hour)
 			if userState, exists := userStateMgr.get(message.Chat.ID); exists {
+				// Only a wizard step touches the draft, so only it takes the lock.
+				draft := addClientDrafts.forChat(message.Chat.ID)
+				draft.Lock()
+				defer draft.Unlock()
 				switch userState {
 				case "awaiting_email":
-					if client_Email == strings.TrimSpace(message.Text) {
+					if draft.email == strings.TrimSpace(message.Text) {
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
 						userStateMgr.clear(message.Chat.ID)
 						return nil
 					}
 
-					client_Email = strings.TrimSpace(message.Text)
-					if t.isSingleWord(client_Email) {
+					draft.email = strings.TrimSpace(message.Text)
+					if t.isSingleWord(draft.email) {
 						userStateMgr.set(message.Chat.ID, "awaiting_email")
 
 						cancel_btn_markup := tu.InlineKeyboard(
@@ -136,26 +140,26 @@ func (t *Tgbot) OnReceive() {
 					} else {
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove())
 						userStateMgr.clear(message.Chat.ID)
-						t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
+						t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft))
 					}
 				case "awaiting_comment":
-					if client_Comment == strings.TrimSpace(message.Text) {
+					if draft.comment == strings.TrimSpace(message.Text) {
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
 						userStateMgr.clear(message.Chat.ID)
 						return nil
 					}
 
-					client_Comment = strings.TrimSpace(message.Text)
+					draft.comment = strings.TrimSpace(message.Text)
 					t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove())
 					userStateMgr.clear(message.Chat.ID)
-					t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
+					t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft))
 				case "awaiting_tg_id":
 					input := strings.TrimSpace(message.Text)
 					if input == "" || input == "-" || strings.EqualFold(input, "none") {
-						client_TgID = ""
+						draft.tgID = ""
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
 						userStateMgr.clear(message.Chat.ID)
-						t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
+						t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft))
 						return nil
 					}
 					if _, err := strconv.ParseInt(input, 10, 64); err != nil {
@@ -167,10 +171,10 @@ func (t *Tgbot) OnReceive() {
 						t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
 						return nil
 					}
-					client_TgID = input
+					draft.tgID = input
 					t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.userSaved"), 3, tu.ReplyKeyboardRemove())
 					userStateMgr.clear(message.Chat.ID)
-					t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
+					t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft))
 				}
 			} else {
 				if message.UsersShared != nil {
@@ -312,6 +316,15 @@ func isCommandForBot(text string, username string) bool {
 func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool) {
 	chatId := callbackQuery.Message.GetChat().ID
 
+	// Only an admin's wizard callbacks touch a draft, so only they take its lock:
+	// a report tap must not wait on a slot, a rejected chat must not be stored.
+	var draft *clientDraft
+	if isAdmin && isAddClientStep(callbackQuery.Data) {
+		draft = addClientDrafts.forChat(chatId)
+		draft.Lock()
+		defer draft.Unlock()
+	}
+
 	if isAdmin {
 		// get query from hash storage
 		decodedQuery, err := t.decodeQuery(callbackQuery.Data)
@@ -472,11 +485,11 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				t.searchClient(chatId, email, callbackQuery.Message.GetMessageID())
 			case "add_client_limit_traffic_c":
 				limitTraffic, _ := strconv.ParseInt(dataArray[1], 10, 64)
-				client_TotalGB = limitTraffic * 1024 * 1024 * 1024
+				draft.totalGB = limitTraffic * 1024 * 1024 * 1024
 				messageId := callbackQuery.Message.GetMessageID()
-				message_text := t.BuildClientDraftMessage()
+				message_text := t.BuildClientDraftMessage(draft)
 
-				t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
+				t.addClient(callbackQuery.Message.GetChat().ID, draft, message_text, messageId)
 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
 			case "add_client_limit_traffic_in":
 				if len(dataArray) >= 2 {
@@ -599,24 +612,15 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
 				t.searchClient(chatId, email, callbackQuery.Message.GetMessageID())
 			case "add_client_reset_exp_c":
-				client_ExpiryTime = 0
+				// The wizard's presets and its custom keypad land in this one case, so a
+				// second tap replaces the term it set; 0 is the Unlimited button.
 				days, _ := strconv.ParseInt(dataArray[1], 10, 64)
-				var date int64
-				if client_ExpiryTime > 0 {
-					if client_ExpiryTime-time.Now().Unix()*1000 < 0 {
-						date = -(days * 24 * 60 * 60000)
-					} else {
-						date = client_ExpiryTime + days*24*60*60000
-					}
-				} else {
-					date = client_ExpiryTime - days*24*60*60000
-				}
-				client_ExpiryTime = date
+				draft.expiryTime = -days * 24 * 60 * 60000
 
 				messageId := callbackQuery.Message.GetMessageID()
-				message_text := t.BuildClientDraftMessage()
+				message_text := t.BuildClientDraftMessage(draft)
 
-				t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
+				t.addClient(callbackQuery.Message.GetChat().ID, draft, message_text, messageId)
 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
 			case "add_client_reset_exp_in":
 				if len(dataArray) >= 2 {
@@ -717,13 +721,13 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 			case "add_client_ip_limit_c":
 				if len(dataArray) == 2 {
 					count, _ := strconv.Atoi(dataArray[1])
-					client_LimitIP = count
+					draft.limitIP = count
 				}
 
 				messageId := callbackQuery.Message.GetMessageID()
-				message_text := t.BuildClientDraftMessage()
+				message_text := t.BuildClientDraftMessage(draft)
 
-				t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
+				t.addClient(callbackQuery.Message.GetChat().ID, draft, message_text, messageId)
 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
 			case "add_client_ip_limit_in":
 				if len(dataArray) >= 2 {
@@ -843,15 +847,15 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				}
 				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clients)
 			case "add_client_to":
-				client_Email = t.randomLowerAndNum(8)
-				client_LimitIP = 0
-				client_TotalGB = 0
-				client_ExpiryTime = 0
-				client_Enable = true
-				client_TgID = ""
-				client_SubID = t.randomLowerAndNum(16)
-				client_Comment = ""
-				client_Reset = 0
+				draft.email = t.randomLowerAndNum(8)
+				draft.limitIP = 0
+				draft.totalGB = 0
+				draft.expiryTime = 0
+				draft.enable = true
+				draft.tgID = ""
+				draft.subID = t.randomLowerAndNum(16)
+				draft.comment = ""
+				draft.reset = 0
 
 				inboundId := dataArray[1]
 				inboundIdInt, err := strconv.Atoi(inboundId)
@@ -859,9 +863,9 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
 					return
 				}
-				receiver_inbound_ID = inboundIdInt
-				receiver_inbound_IDs = []int{inboundIdInt}
-				t.addClient(callbackQuery.Message.GetChat().ID, t.BuildClientDraftMessage())
+				draft.receiverInboundID = inboundIdInt
+				draft.receiverInboundIDs = []int{inboundIdInt}
+				t.addClient(callbackQuery.Message.GetChat().ID, draft, t.BuildClientDraftMessage(draft))
 			case "add_client_toggle_attach":
 				inboundIdStr := dataArray[1]
 				inboundIdInt, err := strconv.Atoi(inboundIdStr)
@@ -870,18 +874,18 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 					return
 				}
 				found := -1
-				for i, id := range receiver_inbound_IDs {
+				for i, id := range draft.receiverInboundIDs {
 					if id == inboundIdInt {
 						found = i
 						break
 					}
 				}
 				if found >= 0 {
-					receiver_inbound_IDs = append(receiver_inbound_IDs[:found], receiver_inbound_IDs[found+1:]...)
+					draft.receiverInboundIDs = append(draft.receiverInboundIDs[:found], draft.receiverInboundIDs[found+1:]...)
 				} else {
-					receiver_inbound_IDs = append(receiver_inbound_IDs, inboundIdInt)
+					draft.receiverInboundIDs = append(draft.receiverInboundIDs, inboundIdInt)
 				}
-				picker, err := t.getInboundsAttachPicker()
+				picker, err := t.getInboundsAttachPicker(draft)
 				if err != nil {
 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
 					return
@@ -1043,15 +1047,15 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 		t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands"))
 		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpAdminCommands"))
 	case "add_client":
-		client_Email = t.randomLowerAndNum(8)
-		client_LimitIP = 0
-		client_TotalGB = 0
-		client_ExpiryTime = 0
-		client_Enable = true
-		client_TgID = ""
-		client_SubID = t.randomLowerAndNum(16)
-		client_Comment = ""
-		client_Reset = 0
+		draft.email = t.randomLowerAndNum(8)
+		draft.limitIP = 0
+		draft.totalGB = 0
+		draft.expiryTime = 0
+		draft.enable = true
+		draft.tgID = ""
+		draft.subID = t.randomLowerAndNum(16)
+		draft.comment = ""
+		draft.reset = 0
 
 		inbounds, err := t.getInboundsAddClient()
 		if err != nil {
@@ -1068,7 +1072,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
 			),
 		)
-		prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+html.EscapeString(client_Email))
+		prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+html.EscapeString(draft.email))
 		t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
 	case "add_client_ch_default_comment":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
@@ -1078,7 +1082,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
 			),
 		)
-		prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+html.EscapeString(client_Comment))
+		prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+html.EscapeString(draft.comment))
 		t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
 	case "add_client_ch_default_tg_id":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
@@ -1088,7 +1092,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
 			),
 		)
-		current := client_TgID
+		current := draft.tgID
 		if current == "" {
 			current = "—"
 		}
@@ -1133,21 +1137,23 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 0")),
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("add_client_reset_exp_in 0")),
 			),
+			// No "Add" verb: these replace the term the draft carries, unlike the
+			// renewal keyboard, whose reset_exp_c handler really does add to it.
 			tu.InlineKeyboardRow(
-				tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 7 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 7")),
-				tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 10 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 10")),
+				tu.InlineKeyboardButton("7 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 7")),
+				tu.InlineKeyboardButton("10 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 10")),
 			),
 			tu.InlineKeyboardRow(
-				tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 14 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 14")),
-				tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 20 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 20")),
+				tu.InlineKeyboardButton("14 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 14")),
+				tu.InlineKeyboardButton("20 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 20")),
 			),
 			tu.InlineKeyboardRow(
-				tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 1 "+t.I18nBot("tgbot.month")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 30")),
-				tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 3 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 90")),
+				tu.InlineKeyboardButton("1 "+t.I18nBot("tgbot.month")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 30")),
+				tu.InlineKeyboardButton("3 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 90")),
 			),
 			tu.InlineKeyboardRow(
-				tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 6 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 180")),
-				tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 12 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 365")),
+				tu.InlineKeyboardButton("6 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 180")),
+				tu.InlineKeyboardButton("12 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 365")),
 			),
 		)
 		t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
@@ -1184,68 +1190,65 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 		t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
 		userStateMgr.clear(chatId)
-		t.addClient(chatId, t.BuildClientDraftMessage())
+		t.addClient(chatId, draft, t.BuildClientDraftMessage(draft))
 	case "add_client_cancel":
 		userStateMgr.clear(chatId)
-		receiver_inbound_ID = 0
-		receiver_inbound_IDs = nil
+		addClientDrafts.reset(chatId)
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 		t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.cancel"), 3, tu.ReplyKeyboardRemove())
 	case "add_client_default_traffic_exp":
 		messageId := callbackQuery.Message.GetMessageID()
-		message_text := t.BuildClientDraftMessage()
-		t.addClient(chatId, message_text, messageId)
-		t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email))
+		message_text := t.BuildClientDraftMessage(draft)
+		t.addClient(chatId, draft, message_text, messageId)
+		t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+draft.email))
 	case "add_client_default_ip_limit":
 		messageId := callbackQuery.Message.GetMessageID()
-		message_text := t.BuildClientDraftMessage()
-		t.addClient(chatId, message_text, messageId)
-		t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email))
+		message_text := t.BuildClientDraftMessage(draft)
+		t.addClient(chatId, draft, message_text, messageId)
+		t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+draft.email))
 	case "add_client_attach_more":
-		picker, err := t.getInboundsAttachPicker()
+		picker, err := t.getInboundsAttachPicker(draft)
 		if err != nil {
 			t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
 			return
 		}
 		t.SendMsgToTgbot(chatId, "Pick inbound(s) to attach:", picker)
 	case "add_client_attach_done":
-		if receiver_inbound_ID == 0 && len(receiver_inbound_IDs) > 0 {
-			receiver_inbound_ID = receiver_inbound_IDs[0]
+		if draft.receiverInboundID == 0 && len(draft.receiverInboundIDs) > 0 {
+			draft.receiverInboundID = draft.receiverInboundIDs[0]
 		}
-		if receiver_inbound_ID == 0 {
+		if draft.receiverInboundID == 0 {
 			t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getInboundsFailed"))
 			return
 		}
-		message_text := t.BuildClientDraftMessage()
+		message_text := t.BuildClientDraftMessage(draft)
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
-		t.addClient(chatId, message_text)
+		t.addClient(chatId, draft, message_text)
 	case "add_client_submit_disable":
-		client_Enable = false
-		_, err := t.SubmitAddClient()
+		draft.enable = false
+		_, err := t.SubmitAddClient(draft)
 		if err != nil {
 			errorMessage := fmt.Sprintf("%v", err)
 			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.error_add_client", "error=="+errorMessage), tu.ReplyKeyboardRemove())
 		} else {
 			t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
-			t.sendClientIndividualLinks(chatId, client_Email)
-			t.sendClientQRLinks(chatId, client_Email)
-			receiver_inbound_ID = 0
-			receiver_inbound_IDs = nil
+			t.sendClientIndividualLinks(chatId, draft.email)
+			t.sendClientQRLinks(chatId, draft.email)
+			addClientDrafts.reset(chatId)
 		}
 	case "add_client_submit_enable":
-		client_Enable = true
-		_, err := t.SubmitAddClient()
+		draft.enable = true
+		_, err := t.SubmitAddClient(draft)
 		if err != nil {
 			errorMessage := fmt.Sprintf("%v", err)
 			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.error_add_client", "error=="+errorMessage), tu.ReplyKeyboardRemove())
 		} else {
 			t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
-			t.sendClientIndividualLinks(chatId, client_Email)
-			t.sendClientQRLinks(chatId, client_Email)
-			receiver_inbound_ID = 0
-			receiver_inbound_IDs = nil
+			t.sendClientIndividualLinks(chatId, draft.email)
+			t.sendClientQRLinks(chatId, draft.email)
+			addClientDrafts.reset(chatId)
 		}
 	case "reset_all_traffics_cancel":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())

+ 174 - 73
internal/web/translation/ar-EG.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "التنزيل عبر outbound (اختياري)",
       "geodataFile": "اسم الملف",
       "geodataAddFile": "إضافة ملف",
+      "geodataUseStandardSources": "استخدام المصادر القياسية",
       "geodataSaveRestart": "حفظ وإعادة تشغيل Xray",
       "geodataConfirmTitle": "حفظ إعدادات geodata؟",
       "geodataConfirmContent": "سيتم تحديث قالب إعدادات Xray وإعادة تشغيل Xray.",
@@ -1253,6 +1254,8 @@
       "noisesSett": "إعدادات التشويش",
       "trustedProxyCidrs": "CIDR وكلاء موثوقين",
       "trustedProxyCidrsDesc": "IPs/CIDRs مفصولة بفواصل يُسمح لها بتعيين ترويسات host، proto و client IP المعاد توجيهها.",
+      "realityScanCandidates": "مرشحو فحص Reality",
+      "realityScanCandidatesDesc": "قائمة أهداف host:port (أو CIDR) مفصولة بفواصل تُستخدم كقائمة افتراضية عند تشغيل «البحث عن الأهداف» ببحث فارغ. خصّصها للأهداف التي تستخدمها كثيرًا.",
       "ldap": {
         "enable": "تفعيل مزامنة LDAP",
         "host": "مضيف LDAP",
@@ -1496,63 +1499,63 @@
       "subExpiredTemplateDesc": "قالب التكوين الوهمي عند انتهاء صلاحية اشتراك المستخدم.",
       "subTrafficDepletedTemplate": "قالب نفاد البيانات",
       "subTrafficDepletedTemplateDesc": "قالب التكوين الوهمي عند استهلاك حصة بيانات المستخدم بالكامل.",
-      "subHappAutoDetect": "Happ Header Auto-Detection",
-      "subHappAutoDetectDesc": "Automatically inject Happ routing and headers when client User-Agent indicates Happ.",
-      "subHappProviderId": "Provider ID",
-      "subHappProviderIdDesc": "Unique provider identifier for Happ client management, remote configuration binding, and migration.",
-      "subHappNewUrl": "New Subscription URL",
-      "subHappNewUrlDesc": "Target URL for automatic client migration. When set, Happ clients will migrate to this subscription link.",
-      "subHappFallbackUrl": "Fallback Subscription URL",
-      "subHappFallbackUrlDesc": "Backup subscription address used by Happ if the primary subscription URL becomes unreachable.",
-      "subHappSubInfoText": "Banner Announcement Text",
-      "subHappSubInfoTextDesc": "Custom announcement banner displayed at the top of the Happ client (max 200 characters).",
-      "subHappSubInfoColor": "Banner Accent Color",
-      "subHappSubInfoColorDesc": "Color theme style for the announcement banner.",
-      "subHappSubInfoButtonText": "Banner Button Text",
-      "subHappSubInfoButtonTextDesc": "Button label displayed inside the announcement banner (max 25 characters).",
-      "subHappSubInfoButtonLink": "Banner Button Link",
-      "subHappSubInfoButtonLinkDesc": "Target URL opened when the user clicks the banner action button.",
-      "subHappSubExpire": "Expired Subscription Banner",
-      "subHappSubExpireDesc": "Show an expired subscription banner in Happ when the user's traffic or validity has ended.",
-      "subHappSubExpireButtonLink": "Renewal Link",
-      "subHappSubExpireButtonLinkDesc": "Target URL opened when the user clicks the renewal button on an expired subscription.",
-      "subHappNotificationExpire": "Expiration Notifications",
-      "subHappNotificationExpireDesc": "Instruct Happ to alert the user in advance before their subscription expires.",
-      "subHappNoLimit": "Bypass Rule Limit",
-      "subHappNoLimitDesc": "Allow routing rule count to exceed default mobile platform limits without truncation.",
-      "subHappAlwaysHwid": "Enforce Hardware ID (HWID)",
-      "subHappAlwaysHwidDesc": "Bind subscription sessions to client device hardware identifier for enhanced security.",
-      "subHappTunMode": "TUN Mode",
-      "subHappTunModeDesc": "TUN virtual network interface routing mode: default, system, or strict.",
-      "subHappTunType": "TUN Engine",
-      "subHappTunTypeDesc": "Underlying network stack implementation for TUN: system, gVisor, or mixed.",
-      "subHappExcludeRoutes": "Exclude CIDR Routes",
-      "subHappExcludeRoutesDesc": "Comma-separated IP CIDRs (e.g. 192.168.0.0/16, 10.0.0.0/8) to bypass the VPN tunnel.",
-      "subHappExcludeApns": "Exclude Apple APNs",
-      "subHappExcludeApnsDesc": "Bypass Apple Push Notification services to maintain reliable background notifications on iOS.",
-      "subHappColorProfile": "Client Color Theme",
-      "subHappColorProfileDesc": "Client color theme profile: default, violet, turquoise, cyberpunk, or custom JSON theme.",
-      "subHappPingType": "Latency Ping Method",
-      "subHappPingTypeDesc": "Measurement protocol used by Happ for node latency testing: icmp, tcp, or http.",
-      "subHappAutoConnect": "Auto-Connect on Launch",
-      "subHappAutoConnectDesc": "Instruct Happ to automatically connect to VPN when the application starts.",
-      "subHappAutoConnectType": "Auto-Connect Target",
-      "subHappAutoConnectTypeDesc": "Connection target strategy for auto-connect: fastest node or last used node.",
-      "subHappPerAppMode": "Android Per-App Proxy Mode",
-      "subHappPerAppModeDesc": "Control Android application routing: off, include (proxy only listed apps), or exclude (bypass listed apps).",
-      "subHappPerAppList": "Android Package Names",
-      "subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. com.telegram.messenger).",
-      "subHappPresetIran": "Iran Bypass",
-      "subHappPresetChina": "China Direct",
-      "subHappPresetAdblock": "AdBlock",
-      "subHappPresetGlobal": "Full Proxy",
+      "subHappAutoDetect": "اكتشاف ترويسات Happ تلقائيًا",
+      "subHappAutoDetectDesc": "حقن توجيه Happ والترويسات تلقائيًا عندما يشير User-Agent الخاص بالعميل إلى Happ.",
+      "subHappProviderId": "معرّف المزوّد (Provider ID)",
+      "subHappProviderIdDesc": "معرّف فريد للمزوّد يُستخدم لإدارة عملاء Happ وربط الإعدادات عن بُعد والترحيل.",
+      "subHappNewUrl": "رابط الاشتراك الجديد",
+      "subHappNewUrlDesc": "عنوان URL الهدف للترحيل التلقائي للعملاء. عند تعيينه، ينتقل عملاء Happ إلى رابط الاشتراك هذا.",
+      "subHappFallbackUrl": "رابط الاشتراك الاحتياطي",
+      "subHappFallbackUrlDesc": "عنوان اشتراك احتياطي يستخدمه Happ إذا تعذّر الوصول إلى رابط الاشتراك الأساسي.",
+      "subHappSubInfoText": "نص لافتة الإعلان",
+      "subHappSubInfoTextDesc": "لافتة إعلان مخصصة تظهر أعلى عميل Happ (بحد أقصى 200 حرف).",
+      "subHappSubInfoColor": "لون تمييز اللافتة",
+      "subHappSubInfoColorDesc": "نمط ألوان لافتة الإعلان.",
+      "subHappSubInfoButtonText": "نص زر اللافتة",
+      "subHappSubInfoButtonTextDesc": "تسمية الزر المعروض داخل لافتة الإعلان (بحد أقصى 25 حرفًا).",
+      "subHappSubInfoButtonLink": "رابط زر اللافتة",
+      "subHappSubInfoButtonLinkDesc": "عنوان URL الذي يُفتح عندما ينقر المستخدم على زر الإجراء في اللافتة.",
+      "subHappSubExpire": "لافتة انتهاء الاشتراك",
+      "subHappSubExpireDesc": "إظهار لافتة انتهاء الاشتراك في Happ عند نفاد حركة مرور المستخدم أو انتهاء صلاحية اشتراكه.",
+      "subHappSubExpireButtonLink": "رابط التجديد",
+      "subHappSubExpireButtonLinkDesc": "عنوان URL الذي يُفتح عندما ينقر المستخدم على زر التجديد في اشتراك منتهي الصلاحية.",
+      "subHappNotificationExpire": "إشعارات انتهاء الصلاحية",
+      "subHappNotificationExpireDesc": "يُذكّر Happ المستخدم قبل 3 أيام من انتهاء اشتراكه.",
+      "subHappNoLimit": "وضع بلا حدود",
+      "subHappNoLimitDesc": "رفع حد ذاكرة RAM الخاص بـ xray-core في Happ لتحسين الاستقرار والأداء (تجريبي).",
+      "subHappAlwaysHwid": "فرض معرّف العتاد (HWID)",
+      "subHappAlwaysHwidDesc": "منع المستخدمين من إيقاف إرسال HWID في إعدادات Happ.",
+      "subHappTunMode": "وضع TUN",
+      "subHappTunModeDesc": "مكدس الشبكة الذي يستخدمه TUN على سطح المكتب: النظام (مكدس نظام التشغيل) أو gVisor (مكدس مساحة المستخدم).",
+      "subHappTunType": "محرك TUN",
+      "subHappTunTypeDesc": "النواة المستخدمة لاتصال TUN على سطح المكتب: sing-box أو tun2proxy أو الافتراضي (Happ TUN) أو Xray.",
+      "subHappExcludeRoutes": "استثناء مسارات CIDR",
+      "subHappExcludeRoutesDesc": "نطاقات CIDR لعناوين IP مفصولة بفواصل (مثل 192.168.0.0/16, 10.0.0.0/8) تتجاوز نفق VPN.",
+      "subHappExcludeApns": "استثناء Apple APNs",
+      "subHappExcludeApnsDesc": "تجاوز خدمات إشعارات Apple الفورية للحفاظ على موثوقية الإشعارات في الخلفية على iOS.",
+      "subHappColorProfile": "سمة ألوان العميل",
+      "subHappColorProfileDesc": "سمة ألوان مخصصة لـ iOS كسلسلة JSON، أو resetcolors لاستعادة الألوان الافتراضية.",
+      "subHappPingType": "طريقة قياس زمن الاستجابة",
+      "subHappPingTypeDesc": "كيفية قياس Happ لزمن استجابة العقد: عبر البروكسي (GET أو HEAD) أو TCP أو ICMP.",
+      "subHappAutoConnect": "الاتصال التلقائي عند التشغيل",
+      "subHappAutoConnectDesc": "يتصل Happ تلقائيًا بـ VPN عند بدء تشغيل التطبيق.",
+      "subHappAutoConnectType": "هدف الاتصال التلقائي",
+      "subHappAutoConnectTypeDesc": "الخادم المختار للاتصال التلقائي: أقل تأخير أو آخر عقدة تم استخدامها أو عقدة عشوائية.",
+      "subHappPerAppMode": "وضع البروكسي لكل تطبيق على Android",
+      "subHappPerAppModeDesc": "التحكم في توجيه تطبيقات Android: إيقاف، أو تشغيل (بروكسي للتطبيقات المحددة فقط)، أو تجاوز (استثناء التطبيقات المحددة).",
+      "subHappPerAppList": "أسماء حزم Android",
+      "subHappPerAppListDesc": "أسماء حزم تطبيقات Android المراد تضمينها أو استثناؤها، مفصولة بفواصل (مثل org.telegram.messenger).",
+      "subHappPresetIran": "تجاوز إيران",
+      "subHappPresetChina": "مباشر للصين",
+      "subHappPresetAdblock": "حظر الإعلانات (AdBlock)",
+      "subHappPresetGlobal": "بروكسي كامل",
       "subHappPresetOff": "تعطيل التوجيه (happ://routing/off)",
       "subHappColorBlue": "أزرق (قياسي / افتراضي)",
       "subHappColorGreen": "أخضر (نجاح)",
       "subHappColorRed": "أحمر (تحذير / خطر)",
       "subHappTunModeDefault": "افتراضي",
-      "subHappTunModeSystem": "النظام (حزمة نظام التشغيل القياسية)",
-      "subHappTunModeGvisor": "gVisor (حزمة مساحة المستخدم)",
+      "subHappTunModeSystem": "النظام (مكدس نظام التشغيل القياسي)",
+      "subHappTunModeGvisor": "gVisor (مكدس مساحة المستخدم)",
       "subHappTunTypeSingbox": "sing-box",
       "subHappTunTypeTun2proxy": "tun2proxy",
       "subHappTunTypeDefault": "افتراضي (Happ TUN)",
@@ -1567,28 +1570,51 @@
       "subHappPerAppOff": "إيقاف",
       "subHappPerAppOn": "تشغيل (بروكسي للتطبيقات المحددة فقط)",
       "subHappPerAppBypass": "تجاوز (استثناء التطبيقات المحددة)",
-      "subHappPresetApplied": "Happ preset applied to routing rules",
-      "subHappPresets": "Routing Presets",
-      "subHappPresetsDesc": "Pre-configured routing rule presets tailored for Happ clients.",
-      "subHappVisualBuilder": "Visual Rule Generator",
-      "subHappVisualBuilderDesc": "Create custom routing deeplink from domain and IP lists.",
-      "subHappBuildDeeplink": "Generate Deeplink",
-      "subHappModalTitle": "Happ Visual Routing Rule Generator",
-      "subHappDirectDomains": "Direct Domains (Bypass)",
-      "subHappProxyDomains": "Proxy Domains (Tunnel)",
-      "subHappBlockDomains": "Blocked Domains (Ad/Malware)",
-      "subHappDirectIPs": "Direct IPs / CIDRs",
-      "subHappProxyIPs": "Proxy IPs / CIDRs",
-      "subHappBlockIPs": "Blocked IPs / CIDRs",
-      "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappPresetApplied": "تم تطبيق إعداد Happ المسبق على قواعد التوجيه",
+      "subHappPresets": "إعدادات التوجيه المسبقة",
+      "subHappPresetsDesc": "قواعد توجيه مُعدّة مسبقًا ومخصصة لعملاء Happ.",
+      "subHappVisualBuilder": "منشئ القواعد المرئي",
+      "subHappVisualBuilderDesc": "إنشاء رابط عميق مخصص للتوجيه من قوائم النطاقات وعناوين IP.",
+      "subHappBuildDeeplink": "إنشاء رابط عميق",
+      "subHappModalTitle": "منشئ قواعد التوجيه المرئي لـ Happ",
+      "subHappDirectDomains": "نطاقات مباشرة (تجاوز)",
+      "subHappProxyDomains": "نطاقات عبر البروكسي (نفق)",
+      "subHappBlockDomains": "نطاقات محظورة (إعلانات/برمجيات خبيثة)",
+      "subHappDirectIPs": "IPs / CIDRs مباشرة",
+      "subHappProxyIPs": "IPs / CIDRs عبر البروكسي",
+      "subHappBlockIPs": "IPs / CIDRs محظورة",
+      "subHappDeeplinkGenerated": "تم إنشاء الرابط العميق وتطبيقه على قواعد التوجيه",
       "subHappGroupLinks": "روابط الاشتراك",
-      "subHappGroupRouting": "Routing & Rules",
-      "subHappGroupBanners": "Banners & Announcements",
-      "subHappGroupNetwork": "Network & TUN Engine",
-      "subHappGroupThemes": "Appearance & Theme",
-      "subHappGroupFailover": "Migration & App Management",
-      "subHappGroupAndroid": "Android Per-App Proxy"
-
+      "subHappGroupRouting": "التوجيه والقواعد",
+      "subHappGroupBanners": "اللافتات والإعلانات",
+      "subHappGroupNetwork": "الشبكة ومحرك TUN",
+      "subHappGroupThemes": "المظهر والسمة",
+      "subHappGroupFailover": "الترحيل وإدارة التطبيق",
+      "subHappGroupAndroid": "البروكسي لكل تطبيق على Android",
+      "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 +2416,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 <email>` أو `/usage <email>`",
+      "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": "الصادر",

+ 114 - 13
internal/web/translation/en-US.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "Download through outbound (optional)",
       "geodataFile": "File name",
       "geodataAddFile": "Add file",
+      "geodataUseStandardSources": "Use standard sources",
       "geodataSaveRestart": "Save & Restart Xray",
       "geodataConfirmTitle": "Save geodata settings?",
       "geodataConfirmContent": "This updates the Xray config template and restarts Xray.",
@@ -1375,6 +1376,8 @@
       "noisesSett": "Noises Settings",
       "trustedProxyCidrs": "Trusted proxy CIDRs",
       "trustedProxyCidrsDesc": "Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers.",
+      "realityScanCandidates": "Reality scan candidates",
+      "realityScanCandidatesDesc": "Comma-separated host:port targets (or CIDRs) used as the default list when Find Targets runs with an empty search. Customize this with destinations you reuse often.",
       "ldap": {
         "enable": "Enable LDAP sync",
         "host": "LDAP host",
@@ -1635,31 +1638,31 @@
       "subHappSubExpireButtonLink": "Renewal Link",
       "subHappSubExpireButtonLinkDesc": "Target URL opened when the user clicks the renewal button on an expired subscription.",
       "subHappNotificationExpire": "Expiration Notifications",
-      "subHappNotificationExpireDesc": "Instruct Happ to alert the user in advance before their subscription expires.",
-      "subHappNoLimit": "Bypass Rule Limit",
-      "subHappNoLimitDesc": "Allow routing rule count to exceed default mobile platform limits without truncation.",
+      "subHappNotificationExpireDesc": "Instruct Happ to remind the user 3 days before their subscription expires.",
+      "subHappNoLimit": "No-Limit Mode",
+      "subHappNoLimitDesc": "Raise the xray-core RAM limit in Happ for better stability and performance (beta).",
       "subHappAlwaysHwid": "Enforce Hardware ID (HWID)",
-      "subHappAlwaysHwidDesc": "Bind subscription sessions to client device hardware identifier for enhanced security.",
+      "subHappAlwaysHwidDesc": "Prevent users from turning off HWID sending in the Happ settings.",
       "subHappTunMode": "TUN Mode",
-      "subHappTunModeDesc": "TUN virtual network interface routing mode: default, system, or strict.",
+      "subHappTunModeDesc": "Network stack used by TUN on desktop: system (OS stack) or gVisor (userspace stack).",
       "subHappTunType": "TUN Engine",
-      "subHappTunTypeDesc": "Underlying network stack implementation for TUN: system, gVisor, or mixed.",
+      "subHappTunTypeDesc": "Core used for the TUN connection on desktop: sing-box, tun2proxy, default (Happ TUN), or Xray.",
       "subHappExcludeRoutes": "Exclude CIDR Routes",
       "subHappExcludeRoutesDesc": "Comma-separated IP CIDRs (e.g. 192.168.0.0/16, 10.0.0.0/8) to bypass the VPN tunnel.",
       "subHappExcludeApns": "Exclude Apple APNs",
       "subHappExcludeApnsDesc": "Bypass Apple Push Notification services to maintain reliable background notifications on iOS.",
       "subHappColorProfile": "Client Color Theme",
-      "subHappColorProfileDesc": "Client color theme profile: default, violet, turquoise, cyberpunk, or custom JSON theme.",
+      "subHappColorProfileDesc": "Custom iOS color theme as a JSON string, or resetcolors to restore the default colors.",
       "subHappPingType": "Latency Ping Method",
-      "subHappPingTypeDesc": "Measurement protocol used by Happ for node latency testing: icmp, tcp, or http.",
+      "subHappPingTypeDesc": "How Happ measures node latency: via proxy (GET or HEAD), TCP, or ICMP.",
       "subHappAutoConnect": "Auto-Connect on Launch",
       "subHappAutoConnectDesc": "Instruct Happ to automatically connect to VPN when the application starts.",
       "subHappAutoConnectType": "Auto-Connect Target",
-      "subHappAutoConnectTypeDesc": "Connection target strategy for auto-connect: fastest node or last used node.",
+      "subHappAutoConnectTypeDesc": "Server chosen for auto-connect: lowest delay, last used, or a random node.",
       "subHappPerAppMode": "Android Per-App Proxy Mode",
-      "subHappPerAppModeDesc": "Control Android application routing: off, include (proxy only listed apps), or exclude (bypass listed apps).",
+      "subHappPerAppModeDesc": "Control Android application routing: off, on (proxy only listed apps), or bypass (exclude listed apps).",
       "subHappPerAppList": "Android Package Names",
-      "subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. com.telegram.messenger).",
+      "subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. org.telegram.messenger).",
       "subHappPresetIran": "Iran Bypass",
       "subHappPresetChina": "China Direct",
       "subHappPresetAdblock": "AdBlock",
@@ -1705,8 +1708,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 +2416,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 <email>` or `/usage <email>`",
+      "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",

+ 172 - 71
internal/web/translation/es-ES.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "Descargar a través de outbound (opcional)",
       "geodataFile": "Nombre de archivo",
       "geodataAddFile": "Añadir archivo",
+      "geodataUseStandardSources": "Usar fuentes estándar",
       "geodataSaveRestart": "Guardar y reiniciar Xray",
       "geodataConfirmTitle": "¿Guardar la configuración de geodata?",
       "geodataConfirmContent": "Se actualizará la plantilla de configuración de Xray y se reiniciará Xray.",
@@ -1253,6 +1254,8 @@
       "noisesSett": "Configuración de Sonidos",
       "trustedProxyCidrs": "CIDR de proxy de confianza",
       "trustedProxyCidrsDesc": "IP/CIDR separados por coma que pueden establecer las cabeceras de host, proto e IP del cliente reenviadas.",
+      "realityScanCandidates": "Candidatos de escaneo Reality",
+      "realityScanCandidatesDesc": "Lista de destinos host:port (o CIDR) separados por comas usada por defecto cuando Buscar objetivos se ejecuta con la búsqueda vacía. Personalízala con destinos que uses a menudo.",
       "ldap": {
         "enable": "Habilitar sincronización LDAP",
         "host": "Host LDAP",
@@ -1496,56 +1499,56 @@
       "subExpiredTemplateDesc": "Plantilla para el nodo ficticio cuando la suscripción ha expirado.",
       "subTrafficDepletedTemplate": "Plantilla de tráfico agotado",
       "subTrafficDepletedTemplateDesc": "Plantilla para el nodo ficticio cuando el límite de tráfico se ha agotado.",
-      "subHappAutoDetect": "Happ Header Auto-Detection",
-      "subHappAutoDetectDesc": "Automatically inject Happ routing and headers when client User-Agent indicates Happ.",
-      "subHappProviderId": "Provider ID",
-      "subHappProviderIdDesc": "Unique provider identifier for Happ client management, remote configuration binding, and migration.",
-      "subHappNewUrl": "New Subscription URL",
-      "subHappNewUrlDesc": "Target URL for automatic client migration. When set, Happ clients will migrate to this subscription link.",
-      "subHappFallbackUrl": "Fallback Subscription URL",
-      "subHappFallbackUrlDesc": "Backup subscription address used by Happ if the primary subscription URL becomes unreachable.",
-      "subHappSubInfoText": "Banner Announcement Text",
-      "subHappSubInfoTextDesc": "Custom announcement banner displayed at the top of the Happ client (max 200 characters).",
-      "subHappSubInfoColor": "Banner Accent Color",
-      "subHappSubInfoColorDesc": "Color theme style for the announcement banner.",
-      "subHappSubInfoButtonText": "Banner Button Text",
-      "subHappSubInfoButtonTextDesc": "Button label displayed inside the announcement banner (max 25 characters).",
-      "subHappSubInfoButtonLink": "Banner Button Link",
-      "subHappSubInfoButtonLinkDesc": "Target URL opened when the user clicks the banner action button.",
-      "subHappSubExpire": "Expired Subscription Banner",
-      "subHappSubExpireDesc": "Show an expired subscription banner in Happ when the user's traffic or validity has ended.",
-      "subHappSubExpireButtonLink": "Renewal Link",
-      "subHappSubExpireButtonLinkDesc": "Target URL opened when the user clicks the renewal button on an expired subscription.",
-      "subHappNotificationExpire": "Expiration Notifications",
-      "subHappNotificationExpireDesc": "Instruct Happ to alert the user in advance before their subscription expires.",
-      "subHappNoLimit": "Bypass Rule Limit",
-      "subHappNoLimitDesc": "Allow routing rule count to exceed default mobile platform limits without truncation.",
-      "subHappAlwaysHwid": "Enforce Hardware ID (HWID)",
-      "subHappAlwaysHwidDesc": "Bind subscription sessions to client device hardware identifier for enhanced security.",
-      "subHappTunMode": "TUN Mode",
-      "subHappTunModeDesc": "TUN virtual network interface routing mode: default, system, or strict.",
-      "subHappTunType": "TUN Engine",
-      "subHappTunTypeDesc": "Underlying network stack implementation for TUN: system, gVisor, or mixed.",
-      "subHappExcludeRoutes": "Exclude CIDR Routes",
-      "subHappExcludeRoutesDesc": "Comma-separated IP CIDRs (e.g. 192.168.0.0/16, 10.0.0.0/8) to bypass the VPN tunnel.",
-      "subHappExcludeApns": "Exclude Apple APNs",
-      "subHappExcludeApnsDesc": "Bypass Apple Push Notification services to maintain reliable background notifications on iOS.",
-      "subHappColorProfile": "Client Color Theme",
-      "subHappColorProfileDesc": "Client color theme profile: default, violet, turquoise, cyberpunk, or custom JSON theme.",
-      "subHappPingType": "Latency Ping Method",
-      "subHappPingTypeDesc": "Measurement protocol used by Happ for node latency testing: icmp, tcp, or http.",
-      "subHappAutoConnect": "Auto-Connect on Launch",
-      "subHappAutoConnectDesc": "Instruct Happ to automatically connect to VPN when the application starts.",
-      "subHappAutoConnectType": "Auto-Connect Target",
-      "subHappAutoConnectTypeDesc": "Connection target strategy for auto-connect: fastest node or last used node.",
-      "subHappPerAppMode": "Android Per-App Proxy Mode",
-      "subHappPerAppModeDesc": "Control Android application routing: off, include (proxy only listed apps), or exclude (bypass listed apps).",
-      "subHappPerAppList": "Android Package Names",
-      "subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. com.telegram.messenger).",
-      "subHappPresetIran": "Iran Bypass",
-      "subHappPresetChina": "China Direct",
+      "subHappAutoDetect": "Detección automática de cabeceras de Happ",
+      "subHappAutoDetectDesc": "Inyectar automáticamente el enrutamiento y las cabeceras de Happ cuando el User-Agent del cliente indica Happ.",
+      "subHappProviderId": "ID del proveedor (Provider ID)",
+      "subHappProviderIdDesc": "Identificador único del proveedor para la gestión de clientes de Happ, la vinculación de la configuración remota y la migración.",
+      "subHappNewUrl": "Nueva URL de suscripción",
+      "subHappNewUrlDesc": "URL de destino para la migración automática de clientes. Si se establece, los clientes de Happ migrarán a este enlace de suscripción.",
+      "subHappFallbackUrl": "URL de suscripción de respaldo",
+      "subHappFallbackUrlDesc": "Dirección de suscripción de respaldo que usa Happ si la URL de suscripción principal deja de estar accesible.",
+      "subHappSubInfoText": "Texto del banner de anuncio",
+      "subHappSubInfoTextDesc": "Banner de anuncio personalizado que se muestra en la parte superior del cliente Happ (máx. 200 caracteres).",
+      "subHappSubInfoColor": "Color de énfasis del banner",
+      "subHappSubInfoColorDesc": "Estilo de color del banner de anuncio.",
+      "subHappSubInfoButtonText": "Texto del botón del banner",
+      "subHappSubInfoButtonTextDesc": "Etiqueta del botón que se muestra dentro del banner de anuncio (máx. 25 caracteres).",
+      "subHappSubInfoButtonLink": "Enlace del botón del banner",
+      "subHappSubInfoButtonLinkDesc": "URL de destino que se abre cuando el usuario hace clic en el botón de acción del banner.",
+      "subHappSubExpire": "Banner de suscripción caducada",
+      "subHappSubExpireDesc": "Mostrar un banner de suscripción caducada en Happ cuando se agote el tráfico o termine la validez del usuario.",
+      "subHappSubExpireButtonLink": "Enlace de renovación",
+      "subHappSubExpireButtonLinkDesc": "URL de destino que se abre cuando el usuario hace clic en el botón de renovación de una suscripción caducada.",
+      "subHappNotificationExpire": "Notificaciones de caducidad",
+      "subHappNotificationExpireDesc": "Indicar a Happ que avise al usuario 3 días antes de que caduque su suscripción.",
+      "subHappNoLimit": "Modo sin límite",
+      "subHappNoLimitDesc": "Aumentar el límite de RAM de xray-core en Happ para mejorar la estabilidad y el rendimiento (beta).",
+      "subHappAlwaysHwid": "Forzar ID de hardware (HWID)",
+      "subHappAlwaysHwidDesc": "Impedir que los usuarios desactiven el envío del HWID en la configuración de Happ.",
+      "subHappTunMode": "Modo TUN",
+      "subHappTunModeDesc": "Pila de red que usa TUN en escritorio: sistema (pila del SO) o gVisor (pila de espacio de usuario).",
+      "subHappTunType": "Motor TUN",
+      "subHappTunTypeDesc": "Núcleo usado para la conexión TUN en escritorio: sing-box, tun2proxy, predeterminado (Happ TUN) o Xray.",
+      "subHappExcludeRoutes": "Excluir rutas CIDR",
+      "subHappExcludeRoutesDesc": "CIDR de IP separados por comas (p. ej. 192.168.0.0/16, 10.0.0.0/8) que no pasan por el túnel VPN.",
+      "subHappExcludeApns": "Excluir APNs de Apple",
+      "subHappExcludeApnsDesc": "Omitir los servicios de notificaciones push de Apple para mantener fiables las notificaciones en segundo plano en iOS.",
+      "subHappColorProfile": "Tema de color del cliente",
+      "subHappColorProfileDesc": "Tema de color personalizado para iOS como cadena JSON, o resetcolors para restaurar los colores predeterminados.",
+      "subHappPingType": "Método de ping de latencia",
+      "subHappPingTypeDesc": "Cómo mide Happ la latencia de los nodos: vía proxy (GET o HEAD), TCP o ICMP.",
+      "subHappAutoConnect": "Conexión automática al iniciar",
+      "subHappAutoConnectDesc": "Indicar a Happ que se conecte automáticamente a la VPN al iniciarse la aplicación.",
+      "subHappAutoConnectType": "Destino de la conexión automática",
+      "subHappAutoConnectTypeDesc": "Servidor elegido para la conexión automática: menor latencia, último utilizado o un nodo aleatorio.",
+      "subHappPerAppMode": "Modo de proxy por aplicación en Android",
+      "subHappPerAppModeDesc": "Controlar el enrutamiento de aplicaciones Android: desactivado, activado (proxy solo para las apps de la lista) u omitir (excluir las apps de la lista).",
+      "subHappPerAppList": "Nombres de paquete de Android",
+      "subHappPerAppListDesc": "Nombres de paquete de aplicaciones Android separados por comas que se incluirán o excluirán (p. ej. org.telegram.messenger).",
+      "subHappPresetIran": "Irán: omitir proxy",
+      "subHappPresetChina": "China: conexión directa",
       "subHappPresetAdblock": "AdBlock",
-      "subHappPresetGlobal": "Full Proxy",
+      "subHappPresetGlobal": "Todo por proxy",
       "subHappPresetOff": "Desactivar enrutamiento (happ://routing/off)",
       "subHappColorBlue": "Azul (estándar / predeterminado)",
       "subHappColorGreen": "Verde (éxito)",
@@ -1565,30 +1568,53 @@
       "subHappAutoConnectLastUsed": "Último nodo utilizado",
       "subHappAutoConnectRandom": "Nodo aleatorio",
       "subHappPerAppOff": "Desactivado",
-      "subHappPerAppOn": "Activado (solo apps de la lista)",
+      "subHappPerAppOn": "Activado (proxy solo para apps de la lista)",
       "subHappPerAppBypass": "Omitir (excluir apps de la lista)",
-      "subHappPresetApplied": "Happ preset applied to routing rules",
-      "subHappPresets": "Routing Presets",
-      "subHappPresetsDesc": "Pre-configured routing rule presets tailored for Happ clients.",
-      "subHappVisualBuilder": "Visual Rule Generator",
-      "subHappVisualBuilderDesc": "Create custom routing deeplink from domain and IP lists.",
-      "subHappBuildDeeplink": "Generate Deeplink",
-      "subHappModalTitle": "Happ Visual Routing Rule Generator",
-      "subHappDirectDomains": "Direct Domains (Bypass)",
-      "subHappProxyDomains": "Proxy Domains (Tunnel)",
-      "subHappBlockDomains": "Blocked Domains (Ad/Malware)",
-      "subHappDirectIPs": "Direct IPs / CIDRs",
-      "subHappProxyIPs": "Proxy IPs / CIDRs",
-      "subHappBlockIPs": "Blocked IPs / CIDRs",
-      "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappPresetApplied": "Preajuste de Happ aplicado a las reglas de enrutamiento",
+      "subHappPresets": "Preajustes de enrutamiento",
+      "subHappPresetsDesc": "Preajustes de reglas de enrutamiento preconfigurados para clientes Happ.",
+      "subHappVisualBuilder": "Generador visual de reglas",
+      "subHappVisualBuilderDesc": "Crear un deeplink de enrutamiento personalizado a partir de listas de dominios e IPs.",
+      "subHappBuildDeeplink": "Generar deeplink",
+      "subHappModalTitle": "Generador visual de reglas de enrutamiento de Happ",
+      "subHappDirectDomains": "Dominios directos (sin proxy)",
+      "subHappProxyDomains": "Dominios por proxy (túnel)",
+      "subHappBlockDomains": "Dominios bloqueados (publicidad/malware)",
+      "subHappDirectIPs": "IPs / CIDR directos",
+      "subHappProxyIPs": "IPs / CIDR por proxy",
+      "subHappBlockIPs": "IPs / CIDR bloqueados",
+      "subHappDeeplinkGenerated": "Deeplink generado y aplicado a las reglas de enrutamiento",
       "subHappGroupLinks": "Enlaces de suscripción",
-      "subHappGroupRouting": "Routing & Rules",
-      "subHappGroupBanners": "Banners & Announcements",
-      "subHappGroupNetwork": "Network & TUN Engine",
-      "subHappGroupThemes": "Appearance & Theme",
-      "subHappGroupFailover": "Migration & App Management",
-      "subHappGroupAndroid": "Android Per-App Proxy"
-
+      "subHappGroupRouting": "Enrutamiento y reglas",
+      "subHappGroupBanners": "Banners y anuncios",
+      "subHappGroupNetwork": "Red y motor TUN",
+      "subHappGroupThemes": "Apariencia y tema",
+      "subHappGroupFailover": "Migración y gestión de la app",
+      "subHappGroupAndroid": "Proxy por aplicación en Android",
+      "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 +2416,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 <email>` o `/usage <email>`",
+      "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",

+ 117 - 16
internal/web/translation/fa-IR.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "دانلود از طریق خروجی (اختیاری)",
       "geodataFile": "نام فایل",
       "geodataAddFile": "افزودن فایل",
+      "geodataUseStandardSources": "استفاده از منابع استاندارد",
       "geodataSaveRestart": "ذخیره و ری‌استارت Xray",
       "geodataConfirmTitle": "تنظیمات geodata ذخیره شود؟",
       "geodataConfirmContent": "قالب پیکربندی Xray به‌روزرسانی و Xray ری‌استارت می‌شود.",
@@ -1257,6 +1258,8 @@
       "noisesSett": "تنظیمات Noises",
       "trustedProxyCidrs": "CIDRهای پراکسی مورد اعتماد",
       "trustedProxyCidrsDesc": "IPها/CIDRها (با کاما) که مجازند هدرهای host، proto و client IP فوروارد را تنظیم کنند.",
+      "realityScanCandidates": "نامزدهای اسکن Reality",
+      "realityScanCandidatesDesc": "فهرست host:port (یا CIDR) جداشده با کاما که وقتی جستجو خالی است به‌عنوان فهرست پیش‌فرض «یافتن اهداف» استفاده می‌شود. برای مقاصد پرتکرار خود سفارشی کنید.",
       "ldap": {
         "enable": "فعال‌سازی همگام‌سازی LDAP",
         "host": "میزبان LDAP",
@@ -1507,7 +1510,7 @@
       "subHappSubInfoText": "متن بنر اعلانات",
       "subHappSubInfoTextDesc": "پیام اعلان سفارشی در بالای صفحه برنامه Happ (حداکثر ۲۰۰ نویسه).",
       "subHappSubInfoColor": "رنگ بنر اعلانات",
-      "subHappSubInfoColorDesc": "تم رنگی بنر اعلان (پیش‌فرض، اطلاع‌رسانی، موفقیت، هشدار یا اخطار).",
+      "subHappSubInfoColorDesc": "تم رنگی بنر اعلان.",
       "subHappSubInfoButtonText": "متن دکمه بنر",
       "subHappSubInfoButtonTextDesc": "عنوان دکمه اقدام در بنر اعلان (حداکثر ۲۵ نویسه).",
       "subHappSubInfoButtonLink": "لینک دکمه بنر",
@@ -1517,31 +1520,31 @@
       "subHappSubExpireButtonLink": "لینک تمدید اشتراک",
       "subHappSubExpireButtonLinkDesc": "آدرس صفحه خرید یا تمدید اشتراک منقضی‌شده.",
       "subHappNotificationExpire": "اعلان انقضای اشتراک",
-      "subHappNotificationExpireDesc": "نمایش هشدار انقضای اشتراک به کاربر پیش از پایان اعتبار در برنامه Happ.",
-      "subHappNoLimit": "حذف محدودیت تعداد قوانین",
-      "subHappNoLimitDesc": "اجازه اعمال تعداد نامحدود قوانین روتینگ بدون برش خوردن روی سیستم‌های تلفن همراه.",
+      "subHappNotificationExpireDesc": "برنامه Happ را وادار می‌کند ۳ روز پیش از پایان اشتراک به کاربر یادآوری کند.",
+      "subHappNoLimit": "حالت بدون محدودیت (No-Limit)",
+      "subHappNoLimitDesc": "افزایش سقف حافظه RAM هسته xray-core در Happ برای پایداری و کارایی بهتر (آزمایشی).",
       "subHappAlwaysHwid": "الزام شناسه سخت‌افزاری (HWID)",
-      "subHappAlwaysHwidDesc": "قفل کردن درخواست‌های اشتراک به شناسه سخت‌افزاری دستگاه جهت جلوگیری از اشتراک‌گذاری اکانت.",
+      "subHappAlwaysHwidDesc": "جلوگیری از خاموش کردن ارسال HWID توسط کاربر در تنظیمات Happ.",
       "subHappTunMode": "حالت تونل (TUN Mode)",
-      "subHappTunModeDesc": "حالت رابط شبکه مجازی TUN در برنامه Happ (پیش‌فرض، سیستمی یا سخت‌گیرانه).",
+      "subHappTunModeDesc": "پشته شبکه TUN در دسکتاپ: سیستمی (پشته سیستم‌عامل) یا gVisor (پشته فضای کاربری).",
       "subHappTunType": "موتور شبکه TUN",
-      "subHappTunTypeDesc": "پشته شبکه مورد استفاده برای TUN (سیستمی، gVisor یا ترکیبی).",
+      "subHappTunTypeDesc": "هسته مورد استفاده برای اتصال TUN در دسکتاپ: sing-box، tun2proxy، پیش‌فرض (Happ TUN) یا Xray.",
       "subHappExcludeRoutes": "مستثنی کردن مسیرهای CIDR",
       "subHappExcludeRoutesDesc": "رنج‌های IP جدا شده با کاما جهت دور زدن تونل VPN (مانند 192.168.0.0/16, 10.0.0.0/8).",
       "subHappExcludeApns": "مستثنی کردن سرویس‌های اعلان اپل (APNs)",
       "subHappExcludeApnsDesc": "دور زدن سرویس‌های اعلان اپل برای اطمینان از دریافت پایدار ناتیفیکیشن‌ها در iOS.",
       "subHappColorProfile": "پروفایل رنگ و پوسته",
-      "subHappColorProfileDesc": "پوسته ظاهری برنامه Happ (بنفش، فیروزه‌ای، سایبرپانک یا JSON سفارشی).",
+      "subHappColorProfileDesc": "تم رنگی سفارشی iOS به صورت رشته JSON، یا resetcolors برای بازگشت به رنگ‌های پیش‌فرض.",
       "subHappPingType": "روش تست پینگ",
-      "subHappPingTypeDesc": "پروتکل اندازه‌گیری تأخیر گره‌ها در برنامه Happ (icmp، tcp یا http).",
+      "subHappPingTypeDesc": "روش اندازه‌گیری تأخیر نودها در Happ: از طریق پروکسی (GET یا HEAD)، TCP یا ICMP.",
       "subHappAutoConnect": "اتصال خودکار هنگام اجرا",
       "subHappAutoConnectDesc": "اتصال خودکار به وی‌پی‌ان با باز شدن برنامه Happ.",
       "subHappAutoConnectType": "راهبرد اتصال خودکار",
-      "subHappAutoConnectTypeDesc": "هدف اتصال خودکار: سریع‌ترین سرور یا آخرین سرور استفاده‌شده.",
+      "subHappAutoConnectTypeDesc": "سروری که اتصال خودکار انتخاب می‌کند: کمترین تأخیر، آخرین نود استفاده‌شده یا یک نود تصادفی.",
       "subHappPerAppMode": "پراکسی انتخابی برنامه‌ها در اندروید",
-      "subHappPerAppModeDesc": "مدیریت عبور ترافیک برنامه‌های اندروید: خاموش، عبور فقط برنامه‌های منتخب یا مستثنی کردن آن‌ها.",
+      "subHappPerAppModeDesc": "مدیریت مسیریابی برنامه‌های اندروید: خاموش، روشن (پراکسی فقط برای برنامه‌های فهرست‌شده) یا بای‌پس (مستثنی‌کردن برنامه‌های فهرست‌شده).",
       "subHappPerAppList": "نام بسته‌های برنامه‌های اندروید",
-      "subHappPerAppListDesc": "نام بسته‌های اپلیکیشن‌های اندروید جدا شده با کاما (مانند com.telegram.messenger).",
+      "subHappPerAppListDesc": "نام بسته برنامه‌های اندروید برای شامل یا مستثنی کردن، جدا شده با کاما (مانند org.telegram.messenger).",
       "subHappPresetIran": "دور زدن سایت‌های ایران (Iran Bypass)",
       "subHappPresetChina": "دور زدن چین (China Direct)",
       "subHappPresetAdblock": "مسدودسازی تبلیغات (AdBlock)",
@@ -1583,12 +1586,35 @@
       "subHappDeeplinkGenerated": "دیپ‌لینک تولید و در قوانین روتینگ اعمال شد",
       "subHappGroupLinks": "لینک‌های اشتراک",
       "subHappGroupRouting": "قوانین و روتینگ",
-      "subHappGroupBanners": "اعلانات و بنرهای هوشمند",
+      "subHappGroupBanners": "بنرها و اعلانات",
       "subHappGroupNetwork": "تنظیمات شبکه و TUN",
       "subHappGroupThemes": "ظاهر و پوسته برنامه",
-      "subHappGroupFailover": "مهاجرت و مدیریت کلاینت",
-      "subHappGroupAndroid": "پراکسی برنامه‌های اندروید"
-
+      "subHappGroupFailover": "مهاجرت و مدیریت برنامه",
+      "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 +2416,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 <email>` یا `/usage <email>`",
+      "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": "خروجی",

+ 176 - 75
internal/web/translation/id-ID.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "Unduh melalui outbound (opsional)",
       "geodataFile": "Nama berkas",
       "geodataAddFile": "Tambah berkas",
+      "geodataUseStandardSources": "Gunakan sumber standar",
       "geodataSaveRestart": "Simpan & Mulai Ulang Xray",
       "geodataConfirmTitle": "Simpan pengaturan geodata?",
       "geodataConfirmContent": "Templat konfigurasi Xray akan diperbarui dan Xray akan dimulai ulang.",
@@ -1253,6 +1254,8 @@
       "noisesSett": "Pengaturan Noises",
       "trustedProxyCidrs": "CIDR proxy tepercaya",
       "trustedProxyCidrsDesc": "IP/CIDR (dipisahkan koma) yang diizinkan mengatur header forwarded host, proto, dan client IP.",
+      "realityScanCandidates": "Kandidat pemindaian Reality",
+      "realityScanCandidatesDesc": "Daftar target host:port (atau CIDR) dipisahkan koma yang dipakai sebagai default saat Cari Target dijalankan dengan pencarian kosong. Sesuaikan dengan tujuan yang sering Anda gunakan.",
       "ldap": {
         "enable": "Aktifkan sinkronisasi LDAP",
         "host": "LDAP host",
@@ -1496,66 +1499,66 @@
       "subExpiredTemplateDesc": "Templat untuk konfigurasi dummy saat akun langganan telah kedaluwarsa.",
       "subTrafficDepletedTemplate": "Templat Kuota Habis",
       "subTrafficDepletedTemplateDesc": "Templat untuk konfigurasi dummy saat kuota data langganan telah habis.",
-      "subHappAutoDetect": "Happ Header Auto-Detection",
-      "subHappAutoDetectDesc": "Automatically inject Happ routing and headers when client User-Agent indicates Happ.",
-      "subHappProviderId": "Provider ID",
-      "subHappProviderIdDesc": "Unique provider identifier for Happ client management, remote configuration binding, and migration.",
-      "subHappNewUrl": "New Subscription URL",
-      "subHappNewUrlDesc": "Target URL for automatic client migration. When set, Happ clients will migrate to this subscription link.",
-      "subHappFallbackUrl": "Fallback Subscription URL",
-      "subHappFallbackUrlDesc": "Backup subscription address used by Happ if the primary subscription URL becomes unreachable.",
-      "subHappSubInfoText": "Banner Announcement Text",
-      "subHappSubInfoTextDesc": "Custom announcement banner displayed at the top of the Happ client (max 200 characters).",
-      "subHappSubInfoColor": "Banner Accent Color",
-      "subHappSubInfoColorDesc": "Color theme style for the announcement banner.",
-      "subHappSubInfoButtonText": "Banner Button Text",
-      "subHappSubInfoButtonTextDesc": "Button label displayed inside the announcement banner (max 25 characters).",
-      "subHappSubInfoButtonLink": "Banner Button Link",
-      "subHappSubInfoButtonLinkDesc": "Target URL opened when the user clicks the banner action button.",
-      "subHappSubExpire": "Expired Subscription Banner",
-      "subHappSubExpireDesc": "Show an expired subscription banner in Happ when the user's traffic or validity has ended.",
-      "subHappSubExpireButtonLink": "Renewal Link",
-      "subHappSubExpireButtonLinkDesc": "Target URL opened when the user clicks the renewal button on an expired subscription.",
-      "subHappNotificationExpire": "Expiration Notifications",
-      "subHappNotificationExpireDesc": "Instruct Happ to alert the user in advance before their subscription expires.",
-      "subHappNoLimit": "Bypass Rule Limit",
-      "subHappNoLimitDesc": "Allow routing rule count to exceed default mobile platform limits without truncation.",
-      "subHappAlwaysHwid": "Enforce Hardware ID (HWID)",
-      "subHappAlwaysHwidDesc": "Bind subscription sessions to client device hardware identifier for enhanced security.",
-      "subHappTunMode": "TUN Mode",
-      "subHappTunModeDesc": "TUN virtual network interface routing mode: default, system, or strict.",
-      "subHappTunType": "TUN Engine",
-      "subHappTunTypeDesc": "Underlying network stack implementation for TUN: system, gVisor, or mixed.",
-      "subHappExcludeRoutes": "Exclude CIDR Routes",
-      "subHappExcludeRoutesDesc": "Comma-separated IP CIDRs (e.g. 192.168.0.0/16, 10.0.0.0/8) to bypass the VPN tunnel.",
-      "subHappExcludeApns": "Exclude Apple APNs",
-      "subHappExcludeApnsDesc": "Bypass Apple Push Notification services to maintain reliable background notifications on iOS.",
-      "subHappColorProfile": "Client Color Theme",
-      "subHappColorProfileDesc": "Client color theme profile: default, violet, turquoise, cyberpunk, or custom JSON theme.",
-      "subHappPingType": "Latency Ping Method",
-      "subHappPingTypeDesc": "Measurement protocol used by Happ for node latency testing: icmp, tcp, or http.",
-      "subHappAutoConnect": "Auto-Connect on Launch",
-      "subHappAutoConnectDesc": "Instruct Happ to automatically connect to VPN when the application starts.",
-      "subHappAutoConnectType": "Auto-Connect Target",
-      "subHappAutoConnectTypeDesc": "Connection target strategy for auto-connect: fastest node or last used node.",
-      "subHappPerAppMode": "Android Per-App Proxy Mode",
-      "subHappPerAppModeDesc": "Control Android application routing: off, include (proxy only listed apps), or exclude (bypass listed apps).",
-      "subHappPerAppList": "Android Package Names",
-      "subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. com.telegram.messenger).",
-      "subHappPresetIran": "Iran Bypass",
-      "subHappPresetChina": "China Direct",
+      "subHappAutoDetect": "Deteksi Otomatis Header Happ",
+      "subHappAutoDetectDesc": "Otomatis menyisipkan routing dan header Happ saat User-Agent klien menunjukkan Happ.",
+      "subHappProviderId": "ID Penyedia (Provider ID)",
+      "subHappProviderIdDesc": "Pengenal unik penyedia untuk manajemen klien Happ, pengikatan konfigurasi jarak jauh, dan migrasi.",
+      "subHappNewUrl": "URL Langganan Baru",
+      "subHappNewUrlDesc": "URL tujuan untuk migrasi klien otomatis. Jika diisi, klien Happ akan bermigrasi ke tautan langganan ini.",
+      "subHappFallbackUrl": "URL Langganan Cadangan",
+      "subHappFallbackUrlDesc": "Alamat langganan cadangan yang dipakai Happ jika URL langganan utama tidak dapat dijangkau.",
+      "subHappSubInfoText": "Teks Pengumuman Banner",
+      "subHappSubInfoTextDesc": "Banner pengumuman kustom yang ditampilkan di bagian atas klien Happ (maks. 200 karakter).",
+      "subHappSubInfoColor": "Warna Aksen Banner",
+      "subHappSubInfoColorDesc": "Gaya tema warna untuk banner pengumuman.",
+      "subHappSubInfoButtonText": "Teks Tombol Banner",
+      "subHappSubInfoButtonTextDesc": "Label tombol yang ditampilkan di dalam banner pengumuman (maks. 25 karakter).",
+      "subHappSubInfoButtonLink": "Tautan Tombol Banner",
+      "subHappSubInfoButtonLinkDesc": "URL tujuan yang dibuka saat pengguna mengklik tombol aksi banner.",
+      "subHappSubExpire": "Banner Langganan Kedaluwarsa",
+      "subHappSubExpireDesc": "Tampilkan banner langganan kedaluwarsa di Happ saat lalu lintas atau masa berlaku pengguna telah habis.",
+      "subHappSubExpireButtonLink": "Tautan Perpanjangan",
+      "subHappSubExpireButtonLinkDesc": "URL tujuan yang dibuka saat pengguna mengklik tombol perpanjangan pada langganan yang kedaluwarsa.",
+      "subHappNotificationExpire": "Notifikasi Kedaluwarsa",
+      "subHappNotificationExpireDesc": "Minta Happ mengingatkan pengguna 3 hari sebelum langganannya kedaluwarsa.",
+      "subHappNoLimit": "Mode Tanpa Batas",
+      "subHappNoLimitDesc": "Naikkan batas RAM xray-core di Happ untuk stabilitas dan performa yang lebih baik (beta).",
+      "subHappAlwaysHwid": "Wajibkan ID Perangkat Keras (HWID)",
+      "subHappAlwaysHwidDesc": "Cegah pengguna mematikan pengiriman HWID di pengaturan Happ.",
+      "subHappTunMode": "Mode TUN",
+      "subHappTunModeDesc": "Stack jaringan yang dipakai TUN di desktop: sistem (stack OS) atau gVisor (stack userspace).",
+      "subHappTunType": "Mesin TUN",
+      "subHappTunTypeDesc": "Inti yang dipakai untuk koneksi TUN di desktop: sing-box, tun2proxy, bawaan (Happ TUN), atau Xray.",
+      "subHappExcludeRoutes": "Kecualikan Rute CIDR",
+      "subHappExcludeRoutesDesc": "IP CIDR dipisahkan koma (mis. 192.168.0.0/16, 10.0.0.0/8) yang dikecualikan dari tunnel VPN.",
+      "subHappExcludeApns": "Kecualikan Apple APNs",
+      "subHappExcludeApnsDesc": "Lewatkan layanan Apple Push Notification di luar VPN agar notifikasi latar belakang di iOS tetap andal.",
+      "subHappColorProfile": "Tema Warna Klien",
+      "subHappColorProfileDesc": "Tema warna iOS kustom sebagai string JSON, atau resetcolors untuk mengembalikan warna bawaan.",
+      "subHappPingType": "Metode Ping Latensi",
+      "subHappPingTypeDesc": "Cara Happ mengukur latensi node: melalui proxy (GET atau HEAD), TCP, atau ICMP.",
+      "subHappAutoConnect": "Hubungkan Otomatis saat Dibuka",
+      "subHappAutoConnectDesc": "Minta Happ terhubung ke VPN secara otomatis saat aplikasi dimulai.",
+      "subHappAutoConnectType": "Target Koneksi Otomatis",
+      "subHappAutoConnectTypeDesc": "Server yang dipilih untuk koneksi otomatis: latensi terendah, terakhir digunakan, atau node acak.",
+      "subHappPerAppMode": "Mode Proxy Per-Aplikasi Android",
+      "subHappPerAppModeDesc": "Kontrol routing aplikasi Android: mati, nyala (proxy hanya aplikasi terdaftar), atau bypass (kecualikan aplikasi terdaftar).",
+      "subHappPerAppList": "Nama Paket Android",
+      "subHappPerAppListDesc": "Nama paket aplikasi Android yang disertakan atau dikecualikan, dipisahkan koma (mis. org.telegram.messenger).",
+      "subHappPresetIran": "Bypass Iran",
+      "subHappPresetChina": "Tiongkok Langsung",
       "subHappPresetAdblock": "AdBlock",
-      "subHappPresetGlobal": "Full Proxy",
-      "subHappPresetOff": "Nonaktifkan Perutean (happ://routing/off)",
-      "subHappColorBlue": "Biru (Standar / Default)",
+      "subHappPresetGlobal": "Proxy Penuh",
+      "subHappPresetOff": "Nonaktifkan Routing (happ://routing/off)",
+      "subHappColorBlue": "Biru (Standar / Bawaan)",
       "subHappColorGreen": "Hijau (Sukses)",
       "subHappColorRed": "Merah (Peringatan / Bahaya)",
-      "subHappTunModeDefault": "Default",
+      "subHappTunModeDefault": "Bawaan",
       "subHappTunModeSystem": "Sistem (Stack OS Standar)",
       "subHappTunModeGvisor": "gVisor (Stack Userspace)",
       "subHappTunTypeSingbox": "sing-box",
       "subHappTunTypeTun2proxy": "tun2proxy",
-      "subHappTunTypeDefault": "Default (Happ TUN)",
+      "subHappTunTypeDefault": "Bawaan (Happ TUN)",
       "subHappTunTypeXray": "Xray TUN",
       "subHappPingProxy": "Melalui Proxy (Latensi GET)",
       "subHappPingProxyHead": "Melalui Proxy (Latensi HEAD)",
@@ -1565,30 +1568,53 @@
       "subHappAutoConnectLastUsed": "Node Terakhir Digunakan",
       "subHappAutoConnectRandom": "Node Acak",
       "subHappPerAppOff": "Mati",
-      "subHappPerAppOn": "Nyala (Hanya Proksikan Aplikasi Terdaftar)",
+      "subHappPerAppOn": "Nyala (Proxy Hanya Aplikasi Terdaftar)",
       "subHappPerAppBypass": "Bypass (Kecualikan Aplikasi Terdaftar)",
-      "subHappPresetApplied": "Happ preset applied to routing rules",
-      "subHappPresets": "Routing Presets",
-      "subHappPresetsDesc": "Pre-configured routing rule presets tailored for Happ clients.",
-      "subHappVisualBuilder": "Visual Rule Generator",
-      "subHappVisualBuilderDesc": "Create custom routing deeplink from domain and IP lists.",
-      "subHappBuildDeeplink": "Generate Deeplink",
-      "subHappModalTitle": "Happ Visual Routing Rule Generator",
-      "subHappDirectDomains": "Direct Domains (Bypass)",
-      "subHappProxyDomains": "Proxy Domains (Tunnel)",
-      "subHappBlockDomains": "Blocked Domains (Ad/Malware)",
-      "subHappDirectIPs": "Direct IPs / CIDRs",
-      "subHappProxyIPs": "Proxy IPs / CIDRs",
-      "subHappBlockIPs": "Blocked IPs / CIDRs",
-      "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappPresetApplied": "Preset Happ diterapkan ke aturan routing",
+      "subHappPresets": "Preset Routing",
+      "subHappPresetsDesc": "Preset aturan routing siap pakai yang disesuaikan untuk klien Happ.",
+      "subHappVisualBuilder": "Pembuat Aturan Visual",
+      "subHappVisualBuilderDesc": "Buat deeplink routing kustom dari daftar domain dan IP.",
+      "subHappBuildDeeplink": "Buat Deeplink",
+      "subHappModalTitle": "Pembuat Aturan Routing Visual Happ",
+      "subHappDirectDomains": "Domain Langsung (Bypass)",
+      "subHappProxyDomains": "Domain Proxy (Tunnel)",
+      "subHappBlockDomains": "Domain Diblokir (Iklan/Malware)",
+      "subHappDirectIPs": "IP / CIDR Langsung",
+      "subHappProxyIPs": "IP / CIDR Proxy",
+      "subHappBlockIPs": "IP / CIDR Diblokir",
+      "subHappDeeplinkGenerated": "Deeplink dibuat dan diterapkan ke aturan routing",
       "subHappGroupLinks": "Tautan Langganan",
-      "subHappGroupRouting": "Routing & Rules",
-      "subHappGroupBanners": "Banners & Announcements",
-      "subHappGroupNetwork": "Network & TUN Engine",
-      "subHappGroupThemes": "Appearance & Theme",
-      "subHappGroupFailover": "Migration & App Management",
-      "subHappGroupAndroid": "Android Per-App Proxy"
-
+      "subHappGroupRouting": "Routing & Aturan",
+      "subHappGroupBanners": "Banner & Pengumuman",
+      "subHappGroupNetwork": "Jaringan & Mesin TUN",
+      "subHappGroupThemes": "Tampilan & Tema",
+      "subHappGroupFailover": "Migrasi & Manajemen Aplikasi",
+      "subHappGroupAndroid": "Proxy Per-Aplikasi Android",
+      "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 +2416,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 <email>` atau `/usage <email>`",
+      "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",

+ 177 - 76
internal/web/translation/ja-JP.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "アウトバウンド経由でダウンロード(任意)",
       "geodataFile": "ファイル名",
       "geodataAddFile": "ファイルを追加",
+      "geodataUseStandardSources": "標準ソースを使用",
       "geodataSaveRestart": "保存して Xray を再起動",
       "geodataConfirmTitle": "geodata 設定を保存しますか?",
       "geodataConfirmContent": "Xray 設定テンプレートを更新し、Xray を再起動します。",
@@ -1253,6 +1254,8 @@
       "noisesSett": "Noises設定",
       "trustedProxyCidrs": "信頼できるプロキシ CIDR",
       "trustedProxyCidrsDesc": "転送される host、proto、クライアント IP ヘッダーを設定可能な IP/CIDR (カンマ区切り)。",
+      "realityScanCandidates": "Reality スキャン候補",
+      "realityScanCandidatesDesc": "検索が空のときに「ターゲットを探す」で使うデフォルトの host:port(または CIDR)一覧(カンマ区切り)。よく使う宛先に合わせて編集できます。",
       "ldap": {
         "enable": "LDAP 同期を有効化",
         "host": "LDAP host",
@@ -1496,99 +1499,122 @@
       "subExpiredTemplateDesc": "サブスクリプションの有効期限が切れた際のダミー構成用テンプレート。",
       "subTrafficDepletedTemplate": "通信量超過テンプレート",
       "subTrafficDepletedTemplateDesc": "サブスクリプションの通信量が上限に達した際のダミー構成用テンプレート。",
-      "subHappAutoDetect": "Happ Header Auto-Detection",
-      "subHappAutoDetectDesc": "Automatically inject Happ routing and headers when client User-Agent indicates Happ.",
-      "subHappProviderId": "Provider ID",
-      "subHappProviderIdDesc": "Unique provider identifier for Happ client management, remote configuration binding, and migration.",
-      "subHappNewUrl": "New Subscription URL",
-      "subHappNewUrlDesc": "Target URL for automatic client migration. When set, Happ clients will migrate to this subscription link.",
-      "subHappFallbackUrl": "Fallback Subscription URL",
-      "subHappFallbackUrlDesc": "Backup subscription address used by Happ if the primary subscription URL becomes unreachable.",
-      "subHappSubInfoText": "Banner Announcement Text",
-      "subHappSubInfoTextDesc": "Custom announcement banner displayed at the top of the Happ client (max 200 characters).",
-      "subHappSubInfoColor": "Banner Accent Color",
-      "subHappSubInfoColorDesc": "Color theme style for the announcement banner.",
-      "subHappSubInfoButtonText": "Banner Button Text",
-      "subHappSubInfoButtonTextDesc": "Button label displayed inside the announcement banner (max 25 characters).",
-      "subHappSubInfoButtonLink": "Banner Button Link",
-      "subHappSubInfoButtonLinkDesc": "Target URL opened when the user clicks the banner action button.",
-      "subHappSubExpire": "Expired Subscription Banner",
-      "subHappSubExpireDesc": "Show an expired subscription banner in Happ when the user's traffic or validity has ended.",
-      "subHappSubExpireButtonLink": "Renewal Link",
-      "subHappSubExpireButtonLinkDesc": "Target URL opened when the user clicks the renewal button on an expired subscription.",
-      "subHappNotificationExpire": "Expiration Notifications",
-      "subHappNotificationExpireDesc": "Instruct Happ to alert the user in advance before their subscription expires.",
-      "subHappNoLimit": "Bypass Rule Limit",
-      "subHappNoLimitDesc": "Allow routing rule count to exceed default mobile platform limits without truncation.",
-      "subHappAlwaysHwid": "Enforce Hardware ID (HWID)",
-      "subHappAlwaysHwidDesc": "Bind subscription sessions to client device hardware identifier for enhanced security.",
-      "subHappTunMode": "TUN Mode",
-      "subHappTunModeDesc": "TUN virtual network interface routing mode: default, system, or strict.",
-      "subHappTunType": "TUN Engine",
-      "subHappTunTypeDesc": "Underlying network stack implementation for TUN: system, gVisor, or mixed.",
-      "subHappExcludeRoutes": "Exclude CIDR Routes",
-      "subHappExcludeRoutesDesc": "Comma-separated IP CIDRs (e.g. 192.168.0.0/16, 10.0.0.0/8) to bypass the VPN tunnel.",
-      "subHappExcludeApns": "Exclude Apple APNs",
-      "subHappExcludeApnsDesc": "Bypass Apple Push Notification services to maintain reliable background notifications on iOS.",
-      "subHappColorProfile": "Client Color Theme",
-      "subHappColorProfileDesc": "Client color theme profile: default, violet, turquoise, cyberpunk, or custom JSON theme.",
-      "subHappPingType": "Latency Ping Method",
-      "subHappPingTypeDesc": "Measurement protocol used by Happ for node latency testing: icmp, tcp, or http.",
-      "subHappAutoConnect": "Auto-Connect on Launch",
-      "subHappAutoConnectDesc": "Instruct Happ to automatically connect to VPN when the application starts.",
-      "subHappAutoConnectType": "Auto-Connect Target",
-      "subHappAutoConnectTypeDesc": "Connection target strategy for auto-connect: fastest node or last used node.",
-      "subHappPerAppMode": "Android Per-App Proxy Mode",
-      "subHappPerAppModeDesc": "Control Android application routing: off, include (proxy only listed apps), or exclude (bypass listed apps).",
-      "subHappPerAppList": "Android Package Names",
-      "subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. com.telegram.messenger).",
-      "subHappPresetIran": "Iran Bypass",
-      "subHappPresetChina": "China Direct",
+      "subHappAutoDetect": "Happ ヘッダーの自動検出",
+      "subHappAutoDetectDesc": "クライアントの User-Agent が Happ を示す場合、Happ のルーティングとヘッダーを自動的に挿入します。",
+      "subHappProviderId": "プロバイダー ID",
+      "subHappProviderIdDesc": "Happ クライアントの管理、リモート設定の紐付け、移行に使用する一意のプロバイダー識別子。",
+      "subHappNewUrl": "新しいサブスクリプション URL",
+      "subHappNewUrlDesc": "クライアントの自動移行先 URL。設定すると、Happ クライアントはこのサブスクリプションリンクへ移行します。",
+      "subHappFallbackUrl": "フォールバック用サブスクリプション URL",
+      "subHappFallbackUrlDesc": "プライマリのサブスクリプション URL に到達できなくなった場合に Happ が使用する予備のサブスクリプションアドレス。",
+      "subHappSubInfoText": "バナーのお知らせテキスト",
+      "subHappSubInfoTextDesc": "Happ クライアントの上部に表示されるカスタムのお知らせバナー (最大 200 文字)。",
+      "subHappSubInfoColor": "バナーのアクセントカラー",
+      "subHappSubInfoColorDesc": "お知らせバナーのカラーテーマ。",
+      "subHappSubInfoButtonText": "バナーのボタンテキスト",
+      "subHappSubInfoButtonTextDesc": "お知らせバナー内に表示されるボタンのラベル (最大 25 文字)。",
+      "subHappSubInfoButtonLink": "バナーのボタンリンク",
+      "subHappSubInfoButtonLinkDesc": "ユーザーがバナーのアクションボタンをクリックしたときに開く URL。",
+      "subHappSubExpire": "期限切れサブスクリプションのバナー",
+      "subHappSubExpireDesc": "ユーザーのトラフィックを使い切ったか有効期限が切れたときに、Happ に期限切れサブスクリプションのバナーを表示します。",
+      "subHappSubExpireButtonLink": "更新リンク",
+      "subHappSubExpireButtonLinkDesc": "期限切れのサブスクリプションで、ユーザーが更新ボタンをクリックしたときに開く URL。",
+      "subHappNotificationExpire": "有効期限の通知",
+      "subHappNotificationExpireDesc": "サブスクリプションの有効期限の 3 日前にユーザーへ通知するよう Happ に指示します。",
+      "subHappNoLimit": "無制限モード",
+      "subHappNoLimitDesc": "Happ の xray-core の RAM 上限を引き上げ、安定性とパフォーマンスを向上させます (ベータ)。",
+      "subHappAlwaysHwid": "ハードウェア ID (HWID) を強制",
+      "subHappAlwaysHwidDesc": "ユーザーが Happ の設定で HWID の送信をオフにできないようにします。",
+      "subHappTunMode": "TUN モード",
+      "subHappTunModeDesc": "デスクトップで TUN が使用するネットワークスタック: システム (OS のスタック) または gVisor (ユーザー空間スタック)。",
+      "subHappTunType": "TUN エンジン",
+      "subHappTunTypeDesc": "デスクトップで TUN 接続に使用するコア: sing-box、tun2proxy、デフォルト (Happ TUN)、または Xray。",
+      "subHappExcludeRoutes": "CIDR ルートを除外",
+      "subHappExcludeRoutesDesc": "VPN トンネルをバイパスする IP CIDR のカンマ区切りリスト (例: 192.168.0.0/16, 10.0.0.0/8)。",
+      "subHappExcludeApns": "Apple APNs を除外",
+      "subHappExcludeApnsDesc": "Apple プッシュ通知サービスをバイパスし、iOS でバックグラウンド通知を確実に受け取れるようにします。",
+      "subHappColorProfile": "クライアントのカラーテーマ",
+      "subHappColorProfileDesc": "iOS 用のカスタムカラーテーマを JSON 文字列で指定します。デフォルトの色に戻すには resetcolors を指定します。",
+      "subHappPingType": "遅延の測定方法",
+      "subHappPingTypeDesc": "Happ がノードの遅延を測定する方法: プロキシ経由 (GET または HEAD)、TCP、または ICMP。",
+      "subHappAutoConnect": "起動時に自動接続",
+      "subHappAutoConnectDesc": "アプリの起動時に VPN へ自動接続するよう Happ に指示します。",
+      "subHappAutoConnectType": "自動接続先",
+      "subHappAutoConnectTypeDesc": "自動接続で選択するサーバー: 最小遅延、最後に使用したノード、またはランダムなノード。",
+      "subHappPerAppMode": "Android アプリ別プロキシモード",
+      "subHappPerAppModeDesc": "Android アプリのルーティングを制御します: オフ、オン (リストされたアプリのみプロキシ)、またはバイパス (リストされたアプリを除外)。",
+      "subHappPerAppList": "Android パッケージ名",
+      "subHappPerAppListDesc": "対象または除外する Android アプリのパッケージ名をカンマ区切りで指定します (例: org.telegram.messenger)。",
+      "subHappPresetIran": "イラン向けバイパス",
+      "subHappPresetChina": "中国向け直接接続",
       "subHappPresetAdblock": "AdBlock",
-      "subHappPresetGlobal": "Full Proxy",
+      "subHappPresetGlobal": "フルプロキシ",
       "subHappPresetOff": "ルーティングを無効化 (happ://routing/off)",
       "subHappColorBlue": "ブルー (標準 / デフォルト)",
       "subHappColorGreen": "グリーン (成功)",
       "subHappColorRed": "レッド (警告 / 危険)",
       "subHappTunModeDefault": "デフォルト",
-      "subHappTunModeSystem": "システム (標準OSスタック)",
+      "subHappTunModeSystem": "システム (標準 OS スタック)",
       "subHappTunModeGvisor": "gVisor (ユーザー空間スタック)",
       "subHappTunTypeSingbox": "sing-box",
       "subHappTunTypeTun2proxy": "tun2proxy",
       "subHappTunTypeDefault": "デフォルト (Happ TUN)",
       "subHappTunTypeXray": "Xray TUN",
-      "subHappPingProxy": "プロキシ経由 (GET遅延)",
-      "subHappPingProxyHead": "プロキシ経由 (HEAD遅延)",
-      "subHappPingTcp": "TCPハンドシェイク Ping",
-      "subHappPingIcmp": "ICMP Ping",
+      "subHappPingProxy": "プロキシ経由 (GET 遅延)",
+      "subHappPingProxyHead": "プロキシ経由 (HEAD 遅延)",
+      "subHappPingTcp": "TCP ハンドシェイク ping",
+      "subHappPingIcmp": "ICMP ping",
       "subHappAutoConnectLowestDelay": "最小遅延 (最速ノード)",
       "subHappAutoConnectLastUsed": "最後に使用したノード",
-      "subHappAutoConnectRandom": "ランダムノード",
+      "subHappAutoConnectRandom": "ランダムノード",
       "subHappPerAppOff": "オフ",
       "subHappPerAppOn": "オン (リストされたアプリのみプロキシ)",
       "subHappPerAppBypass": "バイパス (リストされたアプリを除外)",
-      "subHappPresetApplied": "Happ preset applied to routing rules",
-      "subHappPresets": "Routing Presets",
-      "subHappPresetsDesc": "Pre-configured routing rule presets tailored for Happ clients.",
-      "subHappVisualBuilder": "Visual Rule Generator",
-      "subHappVisualBuilderDesc": "Create custom routing deeplink from domain and IP lists.",
-      "subHappBuildDeeplink": "Generate Deeplink",
-      "subHappModalTitle": "Happ Visual Routing Rule Generator",
-      "subHappDirectDomains": "Direct Domains (Bypass)",
-      "subHappProxyDomains": "Proxy Domains (Tunnel)",
-      "subHappBlockDomains": "Blocked Domains (Ad/Malware)",
-      "subHappDirectIPs": "Direct IPs / CIDRs",
-      "subHappProxyIPs": "Proxy IPs / CIDRs",
-      "subHappBlockIPs": "Blocked IPs / CIDRs",
-      "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappPresetApplied": "Happ プリセットをルーティングルールに適用しました",
+      "subHappPresets": "ルーティングプリセット",
+      "subHappPresetsDesc": "Happ クライアント向けに事前設定されたルーティングルールのプリセット。",
+      "subHappVisualBuilder": "ビジュアルルールジェネレーター",
+      "subHappVisualBuilderDesc": "ドメインと IP のリストからカスタムルーティングのディープリンクを作成します。",
+      "subHappBuildDeeplink": "ディープリンクを生成",
+      "subHappModalTitle": "Happ ビジュアルルーティングルールジェネレーター",
+      "subHappDirectDomains": "直接接続するドメイン (バイパス)",
+      "subHappProxyDomains": "プロキシするドメイン (トンネル)",
+      "subHappBlockDomains": "ブロックするドメイン (広告/マルウェア)",
+      "subHappDirectIPs": "直接接続する IP / CIDR",
+      "subHappProxyIPs": "プロキシする IP / CIDR",
+      "subHappBlockIPs": "ブロックする IP / CIDR",
+      "subHappDeeplinkGenerated": "ディープリンクを生成し、ルーティングルールに適用しました",
       "subHappGroupLinks": "サブスクリプションリンク",
-      "subHappGroupRouting": "Routing & Rules",
-      "subHappGroupBanners": "Banners & Announcements",
-      "subHappGroupNetwork": "Network & TUN Engine",
-      "subHappGroupThemes": "Appearance & Theme",
-      "subHappGroupFailover": "Migration & App Management",
-      "subHappGroupAndroid": "Android Per-App Proxy"
-
+      "subHappGroupRouting": "ルーティングとルール",
+      "subHappGroupBanners": "バナーとお知らせ",
+      "subHappGroupNetwork": "ネットワークと TUN エンジン",
+      "subHappGroupThemes": "外観とテーマ",
+      "subHappGroupFailover": "移行とアプリ管理",
+      "subHappGroupAndroid": "Android アプリ別プロキシ",
+      "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 +2416,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 <email>` または `/usage <email>`",
+      "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": "アウトバウンド",

+ 173 - 72
internal/web/translation/pt-BR.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "Baixar através de outbound (opcional)",
       "geodataFile": "Nome do arquivo",
       "geodataAddFile": "Adicionar arquivo",
+      "geodataUseStandardSources": "Usar fontes padrão",
       "geodataSaveRestart": "Salvar e reiniciar o Xray",
       "geodataConfirmTitle": "Salvar configurações de geodata?",
       "geodataConfirmContent": "O modelo de configuração do Xray será atualizado e o Xray será reiniciado.",
@@ -1253,6 +1254,8 @@
       "noisesSett": "Configurações de Noises",
       "trustedProxyCidrs": "CIDRs de proxy confiável",
       "trustedProxyCidrsDesc": "IPs/CIDRs separados por vírgula que podem definir os cabeçalhos host, proto e IP do cliente encaminhados.",
+      "realityScanCandidates": "Candidatos de varredura Reality",
+      "realityScanCandidatesDesc": "Lista de alvos host:port (ou CIDRs) separados por vírgula usada como padrão quando Procurar alvos roda com a busca vazia. Personalize com destinos que você usa com frequência.",
       "ldap": {
         "enable": "Habilitar sincronização LDAP",
         "host": "Host LDAP",
@@ -1496,63 +1499,63 @@
       "subExpiredTemplateDesc": "Modelo para a configuração fictícia quando a assinatura expirou.",
       "subTrafficDepletedTemplate": "Modelo de tráfego esgotado",
       "subTrafficDepletedTemplateDesc": "Modelo para a configuração fictícia quando a cota de tráfego foi esgotada.",
-      "subHappAutoDetect": "Happ Header Auto-Detection",
-      "subHappAutoDetectDesc": "Automatically inject Happ routing and headers when client User-Agent indicates Happ.",
-      "subHappProviderId": "Provider ID",
-      "subHappProviderIdDesc": "Unique provider identifier for Happ client management, remote configuration binding, and migration.",
-      "subHappNewUrl": "New Subscription URL",
-      "subHappNewUrlDesc": "Target URL for automatic client migration. When set, Happ clients will migrate to this subscription link.",
-      "subHappFallbackUrl": "Fallback Subscription URL",
-      "subHappFallbackUrlDesc": "Backup subscription address used by Happ if the primary subscription URL becomes unreachable.",
-      "subHappSubInfoText": "Banner Announcement Text",
-      "subHappSubInfoTextDesc": "Custom announcement banner displayed at the top of the Happ client (max 200 characters).",
-      "subHappSubInfoColor": "Banner Accent Color",
-      "subHappSubInfoColorDesc": "Color theme style for the announcement banner.",
-      "subHappSubInfoButtonText": "Banner Button Text",
-      "subHappSubInfoButtonTextDesc": "Button label displayed inside the announcement banner (max 25 characters).",
-      "subHappSubInfoButtonLink": "Banner Button Link",
-      "subHappSubInfoButtonLinkDesc": "Target URL opened when the user clicks the banner action button.",
-      "subHappSubExpire": "Expired Subscription Banner",
-      "subHappSubExpireDesc": "Show an expired subscription banner in Happ when the user's traffic or validity has ended.",
-      "subHappSubExpireButtonLink": "Renewal Link",
-      "subHappSubExpireButtonLinkDesc": "Target URL opened when the user clicks the renewal button on an expired subscription.",
-      "subHappNotificationExpire": "Expiration Notifications",
-      "subHappNotificationExpireDesc": "Instruct Happ to alert the user in advance before their subscription expires.",
-      "subHappNoLimit": "Bypass Rule Limit",
-      "subHappNoLimitDesc": "Allow routing rule count to exceed default mobile platform limits without truncation.",
-      "subHappAlwaysHwid": "Enforce Hardware ID (HWID)",
-      "subHappAlwaysHwidDesc": "Bind subscription sessions to client device hardware identifier for enhanced security.",
-      "subHappTunMode": "TUN Mode",
-      "subHappTunModeDesc": "TUN virtual network interface routing mode: default, system, or strict.",
-      "subHappTunType": "TUN Engine",
-      "subHappTunTypeDesc": "Underlying network stack implementation for TUN: system, gVisor, or mixed.",
-      "subHappExcludeRoutes": "Exclude CIDR Routes",
-      "subHappExcludeRoutesDesc": "Comma-separated IP CIDRs (e.g. 192.168.0.0/16, 10.0.0.0/8) to bypass the VPN tunnel.",
-      "subHappExcludeApns": "Exclude Apple APNs",
-      "subHappExcludeApnsDesc": "Bypass Apple Push Notification services to maintain reliable background notifications on iOS.",
-      "subHappColorProfile": "Client Color Theme",
-      "subHappColorProfileDesc": "Client color theme profile: default, violet, turquoise, cyberpunk, or custom JSON theme.",
-      "subHappPingType": "Latency Ping Method",
-      "subHappPingTypeDesc": "Measurement protocol used by Happ for node latency testing: icmp, tcp, or http.",
-      "subHappAutoConnect": "Auto-Connect on Launch",
-      "subHappAutoConnectDesc": "Instruct Happ to automatically connect to VPN when the application starts.",
-      "subHappAutoConnectType": "Auto-Connect Target",
-      "subHappAutoConnectTypeDesc": "Connection target strategy for auto-connect: fastest node or last used node.",
-      "subHappPerAppMode": "Android Per-App Proxy Mode",
-      "subHappPerAppModeDesc": "Control Android application routing: off, include (proxy only listed apps), or exclude (bypass listed apps).",
-      "subHappPerAppList": "Android Package Names",
-      "subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. com.telegram.messenger).",
-      "subHappPresetIran": "Iran Bypass",
-      "subHappPresetChina": "China Direct",
+      "subHappAutoDetect": "Detecção automática de cabeçalhos do Happ",
+      "subHappAutoDetectDesc": "Injeta automaticamente o roteamento e os cabeçalhos do Happ quando o User-Agent do cliente indicar o Happ.",
+      "subHappProviderId": "ID do provedor (Provider ID)",
+      "subHappProviderIdDesc": "Identificador único do provedor para gerenciamento de clientes Happ, vinculação de configuração remota e migração.",
+      "subHappNewUrl": "Nova URL de assinatura",
+      "subHappNewUrlDesc": "URL de destino para a migração automática de clientes. Quando definida, os clientes Happ migrarão para este link de assinatura.",
+      "subHappFallbackUrl": "URL de assinatura de reserva",
+      "subHappFallbackUrlDesc": "Endereço de assinatura de reserva usado pelo Happ se a URL de assinatura principal ficar inacessível.",
+      "subHappSubInfoText": "Texto do banner de anúncio",
+      "subHappSubInfoTextDesc": "Banner de anúncio personalizado exibido no topo do cliente Happ (máx. 200 caracteres).",
+      "subHappSubInfoColor": "Cor de destaque do banner",
+      "subHappSubInfoColorDesc": "Estilo de cor do banner de anúncio.",
+      "subHappSubInfoButtonText": "Texto do botão do banner",
+      "subHappSubInfoButtonTextDesc": "Rótulo do botão exibido dentro do banner de anúncio (máx. 25 caracteres).",
+      "subHappSubInfoButtonLink": "Link do botão do banner",
+      "subHappSubInfoButtonLinkDesc": "URL de destino aberta quando o usuário clica no botão de ação do banner.",
+      "subHappSubExpire": "Banner de assinatura expirada",
+      "subHappSubExpireDesc": "Exibe um banner de assinatura expirada no Happ quando o tráfego do usuário se esgotar ou a validade terminar.",
+      "subHappSubExpireButtonLink": "Link de renovação",
+      "subHappSubExpireButtonLinkDesc": "URL de destino aberta quando o usuário clica no botão de renovação de uma assinatura expirada.",
+      "subHappNotificationExpire": "Notificações de expiração",
+      "subHappNotificationExpireDesc": "Instrui o Happ a lembrar o usuário 3 dias antes de a assinatura expirar.",
+      "subHappNoLimit": "Modo sem limites",
+      "subHappNoLimitDesc": "Aumenta o limite de RAM do xray-core no Happ para mais estabilidade e desempenho (beta).",
+      "subHappAlwaysHwid": "Forçar ID de hardware (HWID)",
+      "subHappAlwaysHwidDesc": "Impede que os usuários desativem o envio do HWID nas configurações do Happ.",
+      "subHappTunMode": "Modo TUN",
+      "subHappTunModeDesc": "Pilha de rede usada pelo TUN no desktop: sistema (pilha do SO) ou gVisor (pilha em espaço de usuário).",
+      "subHappTunType": "Mecanismo TUN",
+      "subHappTunTypeDesc": "Núcleo usado para a conexão TUN no desktop: sing-box, tun2proxy, padrão (Happ TUN) ou Xray.",
+      "subHappExcludeRoutes": "Excluir rotas CIDR",
+      "subHappExcludeRoutesDesc": "CIDRs de IP separados por vírgula (ex.: 192.168.0.0/16, 10.0.0.0/8) a serem desviados do túnel VPN.",
+      "subHappExcludeApns": "Excluir APNs da Apple",
+      "subHappExcludeApnsDesc": "Desvia do túnel os serviços de notificação push da Apple para manter as notificações em segundo plano confiáveis no iOS.",
+      "subHappColorProfile": "Tema de cores do cliente",
+      "subHappColorProfileDesc": "Tema de cores personalizado do iOS como string JSON, ou resetcolors para restaurar as cores padrão.",
+      "subHappPingType": "Método de ping de latência",
+      "subHappPingTypeDesc": "Como o Happ mede a latência dos nós: via proxy (GET ou HEAD), TCP ou ICMP.",
+      "subHappAutoConnect": "Conectar automaticamente ao iniciar",
+      "subHappAutoConnectDesc": "Instrui o Happ a conectar automaticamente à VPN quando o aplicativo for iniciado.",
+      "subHappAutoConnectType": "Destino da conexão automática",
+      "subHappAutoConnectTypeDesc": "Servidor escolhido para a conexão automática: menor latência, último usado ou um nó aleatório.",
+      "subHappPerAppMode": "Modo de proxy por aplicativo no Android",
+      "subHappPerAppModeDesc": "Controla o roteamento de aplicativos Android: desativado, ativado (proxy apenas nos apps listados) ou desviar (excluir apps listados).",
+      "subHappPerAppList": "Nomes de pacotes Android",
+      "subHappPerAppListDesc": "Nomes de pacotes de aplicativos Android separados por vírgula a incluir ou excluir (ex.: org.telegram.messenger).",
+      "subHappPresetIran": "Irã (desvio)",
+      "subHappPresetChina": "China (direto)",
       "subHappPresetAdblock": "AdBlock",
-      "subHappPresetGlobal": "Full Proxy",
+      "subHappPresetGlobal": "Proxy total",
       "subHappPresetOff": "Desativar roteamento (happ://routing/off)",
       "subHappColorBlue": "Azul (padrão)",
       "subHappColorGreen": "Verde (sucesso)",
       "subHappColorRed": "Vermelho (aviso / perigo)",
       "subHappTunModeDefault": "Padrão",
       "subHappTunModeSystem": "Sistema (pilha padrão do SO)",
-      "subHappTunModeGvisor": "gVisor (pilha de espaço do usuário)",
+      "subHappTunModeGvisor": "gVisor (pilha em espaço de usuário)",
       "subHappTunTypeSingbox": "sing-box",
       "subHappTunTypeTun2proxy": "tun2proxy",
       "subHappTunTypeDefault": "Padrão (Happ TUN)",
@@ -1565,30 +1568,53 @@
       "subHappAutoConnectLastUsed": "Último nó usado",
       "subHappAutoConnectRandom": "Nó aleatório",
       "subHappPerAppOff": "Desativado",
-      "subHappPerAppOn": "Ativado (apenas apps listados)",
+      "subHappPerAppOn": "Ativado (proxy apenas nos apps listados)",
       "subHappPerAppBypass": "Desviar (excluir apps listados)",
-      "subHappPresetApplied": "Happ preset applied to routing rules",
-      "subHappPresets": "Routing Presets",
-      "subHappPresetsDesc": "Pre-configured routing rule presets tailored for Happ clients.",
-      "subHappVisualBuilder": "Visual Rule Generator",
-      "subHappVisualBuilderDesc": "Create custom routing deeplink from domain and IP lists.",
-      "subHappBuildDeeplink": "Generate Deeplink",
-      "subHappModalTitle": "Happ Visual Routing Rule Generator",
-      "subHappDirectDomains": "Direct Domains (Bypass)",
-      "subHappProxyDomains": "Proxy Domains (Tunnel)",
-      "subHappBlockDomains": "Blocked Domains (Ad/Malware)",
-      "subHappDirectIPs": "Direct IPs / CIDRs",
-      "subHappProxyIPs": "Proxy IPs / CIDRs",
-      "subHappBlockIPs": "Blocked IPs / CIDRs",
-      "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappPresetApplied": "Predefinição do Happ aplicada às regras de roteamento",
+      "subHappPresets": "Predefinições de roteamento",
+      "subHappPresetsDesc": "Predefinições de regras de roteamento pré-configuradas para clientes Happ.",
+      "subHappVisualBuilder": "Gerador visual de regras",
+      "subHappVisualBuilderDesc": "Crie um deeplink de roteamento personalizado a partir de listas de domínios e IPs.",
+      "subHappBuildDeeplink": "Gerar deeplink",
+      "subHappModalTitle": "Gerador visual de regras de roteamento do Happ",
+      "subHappDirectDomains": "Domínios diretos (desvio)",
+      "subHappProxyDomains": "Domínios via proxy (túnel)",
+      "subHappBlockDomains": "Domínios bloqueados (anúncios/malware)",
+      "subHappDirectIPs": "IPs / CIDRs diretos",
+      "subHappProxyIPs": "IPs / CIDRs via proxy",
+      "subHappBlockIPs": "IPs / CIDRs bloqueados",
+      "subHappDeeplinkGenerated": "Deeplink gerado e aplicado às regras de roteamento",
       "subHappGroupLinks": "Links de assinatura",
-      "subHappGroupRouting": "Routing & Rules",
-      "subHappGroupBanners": "Banners & Announcements",
-      "subHappGroupNetwork": "Network & TUN Engine",
-      "subHappGroupThemes": "Appearance & Theme",
-      "subHappGroupFailover": "Migration & App Management",
-      "subHappGroupAndroid": "Android Per-App Proxy"
-
+      "subHappGroupRouting": "Roteamento e regras",
+      "subHappGroupBanners": "Banners e anúncios",
+      "subHappGroupNetwork": "Rede e mecanismo TUN",
+      "subHappGroupThemes": "Aparência e tema",
+      "subHappGroupFailover": "Migração e gerenciamento do app",
+      "subHappGroupAndroid": "Proxy por aplicativo no Android",
+      "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 +2416,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 <email>` ou `/usage <email>`",
+      "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",

+ 116 - 15
internal/web/translation/ru-RU.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "Скачивать через outbound (необязательно)",
       "geodataFile": "Имя файла",
       "geodataAddFile": "Добавить файл",
+      "geodataUseStandardSources": "Использовать штатные источники",
       "geodataSaveRestart": "Сохранить и перезапустить Xray",
       "geodataConfirmTitle": "Сохранить настройки geodata?",
       "geodataConfirmContent": "Шаблон конфигурации Xray будет обновлён, а Xray перезапущен.",
@@ -1253,6 +1254,8 @@
       "noisesSett": "Настройки Noises",
       "trustedProxyCidrs": "Доверенные CIDR прокси",
       "trustedProxyCidrsDesc": "IP/CIDR через запятую, которым разрешено устанавливать заголовки forwarded host, proto и client IP.",
+      "realityScanCandidates": "Кандидаты сканирования Reality",
+      "realityScanCandidatesDesc": "Список host:port (или CIDR) через запятую — используется по умолчанию в «Найти цели», если поиск пуст. Настройте под часто используемые цели.",
       "ldap": {
         "enable": "Включить LDAP-синхронизацию",
         "host": "LDAP-хост",
@@ -1513,35 +1516,35 @@
       "subHappSubInfoButtonLink": "Ссылка кнопки баннера",
       "subHappSubInfoButtonLinkDesc": "URL-адрес, открывающийся при нажатии на кнопку баннера.",
       "subHappSubExpire": "Баннер об окончании подписки",
-      "subHappSubExpireDesc": "Отображать баннер о скором окончании или истечении срока действия подписки.",
+      "subHappSubExpireDesc": "Показывать в Happ баннер об истёкшей подписке, когда у пользователя закончился трафик или срок действия.",
       "subHappSubExpireButtonLink": "Ссылка для продления подписки",
       "subHappSubExpireButtonLinkDesc": "URL для кнопки «Продлить» при истечении срока подписки.",
       "subHappNotificationExpire": "Уведомление об окончании подписки",
       "subHappNotificationExpireDesc": "Отправлять напоминания пользователю за 3 дня до окончания подписки.",
-      "subHappNoLimit": "Режим без ограничений (No Limit)",
-      "subHappNoLimitDesc": "Увеличивает лимит оперативной памяти и снимает ограничения на количество правил.",
+      "subHappNoLimit": "Режим No-Limit",
+      "subHappNoLimitDesc": "Повышает лимит оперативной памяти xray-core в Happ для стабильности и производительности (бета).",
       "subHappAlwaysHwid": "Обязательный HWID",
-      "subHappAlwaysHwidDesc": "Запрещает пользователю отключать передачу идентификатора устройства (HWID).",
+      "subHappAlwaysHwidDesc": "Запрещает пользователю отключать отправку HWID в настройках Happ.",
       "subHappTunMode": "Режим TUN",
-      "subHappTunModeDesc": "Сетевой стек для TUN: system (системный) или gvisor (пользовательский стек).",
-      "subHappTunType": "Ядро туنнеля (TUN Type)",
-      "subHappTunTypeDesc": "Выбор ядра туннеля: singbox, tun2proxy, default (Happ TUN) или xray.",
+      "subHappTunModeDesc": "Сетевой стек TUN на десктопе: системный (стек ОС) или gVisor (пользовательский стек).",
+      "subHappTunType": "Движок TUN",
+      "subHappTunTypeDesc": "Ядро для TUN-подключения на десктопе: sing-box, tun2proxy, по умолчанию (Happ TUN) или Xray.",
       "subHappExcludeRoutes": "Исключения маршрутов (CIDR)",
-      "subHappExcludeRoutesDesc": "Список подсетей и IP-адресов через запятую, трафик которых идет мимо туннеля.",
+      "subHappExcludeRoutesDesc": "IP-подсети (CIDR) через запятую (например, 192.168.0.0/16, 10.0.0.0/8), трафик которых идёт мимо VPN-туннеля.",
       "subHappExcludeApns": "Исключить push-уведомления Apple (APNS)",
       "subHappExcludeApnsDesc": "Трафик уведомлений Apple направляется напрямую для надежной доставки на iOS.",
       "subHappColorProfile": "Цветовая тема клиента",
-      "subHappColorProfileDesc": "Тема оформления интерфейса Happ: violet, turquoise, cyberpunk или свой JSON.",
+      "subHappColorProfileDesc": "Своя цветовая тема для iOS в виде JSON-строки или resetcolors для сброса к стандартным цветам.",
       "subHappPingType": "Метод проверки пинга",
-      "subHappPingTypeDesc": "Тип проверки задержки: via Proxy (GET), via Proxy (HEAD), TCP или ICMP.",
+      "subHappPingTypeDesc": "Способ измерения задержки узлов в Happ: через прокси (GET или HEAD), TCP или ICMP.",
       "subHappAutoConnect": "Автоподключение при запуске",
       "subHappAutoConnectDesc": "Автоматически подключаться к серверу при запуске приложения.",
       "subHappAutoConnectType": "Критерий автоподключения",
-      "subHappAutoConnectTypeDesc": "Сервер для автоподключения: lowestdelay (наименьший пинг), lastused (последний) или random.",
+      "subHappAutoConnectTypeDesc": "Сервер для автоподключения: минимальная задержка, последний использованный или случайный узел.",
       "subHappPerAppMode": "Прокси для приложений (Android)",
-      "subHappPerAppModeDesc": "Режим раздельного туннелирования: off (выкл), on (только выбранные) или bypass (все кроме выбранных).",
+      "subHappPerAppModeDesc": "Маршрутизация приложений Android: выкл, вкл (прокси только для выбранных) или обход (исключить выбранные).",
       "subHappPerAppList": "Пакеты приложений Android",
-      "subHappPerAppListDesc": "Список идентификаторов пакетов через запятую (например, org.telegram.messenger).",
+      "subHappPerAppListDesc": "Имена пакетов Android-приложений для включения или исключения через запятую (например, org.telegram.messenger).",
       "subHappPresetIran": "Обход сайтов Ирана (Iran Bypass)",
       "subHappPresetChina": "Обход сайтов Китая (China Direct)",
       "subHappPresetAdblock": "Блокировка рекламы (AdBlock)",
@@ -1587,8 +1590,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 +2416,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 <email>` или `/usage <email>`",
+      "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": "Исходящее подключение",

+ 173 - 72
internal/web/translation/tr-TR.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "Outbound üzerinden indir (isteğe bağlı)",
       "geodataFile": "Dosya adı",
       "geodataAddFile": "Dosya ekle",
+      "geodataUseStandardSources": "Standart kaynakları kullan",
       "geodataSaveRestart": "Kaydet ve Xray'i Yeniden Başlat",
       "geodataConfirmTitle": "Geodata ayarları kaydedilsin mi?",
       "geodataConfirmContent": "Xray yapılandırma şablonu güncellenecek ve Xray yeniden başlatılacak.",
@@ -1253,6 +1254,8 @@
       "noisesSett": "Noises Ayarları",
       "trustedProxyCidrs": "Güvenilir Proxy CIDR'leri",
       "trustedProxyCidrsDesc": "İletilen host, proto ve istemci IP başlıklarını ayarlamasına izin verilen IP'ler/CIDR'ler (virgülle ayrılmış).",
+      "realityScanCandidates": "Reality tarama adayları",
+      "realityScanCandidatesDesc": "Arama boşken Hedef Bul için varsayılan olarak kullanılan, virgülle ayrılmış host:port hedefleri (veya CIDR). Sık kullandığınız hedeflere göre özelleştirin.",
       "ldap": {
         "enable": "LDAP senkronizasyonunu etkinleştir",
         "host": "LDAP sunucusu",
@@ -1496,56 +1499,56 @@
       "subExpiredTemplateDesc": "Abonelik süresi dolduğunda sahte yapılandırma için kullanılacak şablon.",
       "subTrafficDepletedTemplate": "Trafik Tükendi Şablonu",
       "subTrafficDepletedTemplateDesc": "Abonelik trafik kotası bittiğinde sahte yapılandırma için kullanılacak şablon.",
-      "subHappAutoDetect": "Happ Header Auto-Detection",
-      "subHappAutoDetectDesc": "Automatically inject Happ routing and headers when client User-Agent indicates Happ.",
-      "subHappProviderId": "Provider ID",
-      "subHappProviderIdDesc": "Unique provider identifier for Happ client management, remote configuration binding, and migration.",
-      "subHappNewUrl": "New Subscription URL",
-      "subHappNewUrlDesc": "Target URL for automatic client migration. When set, Happ clients will migrate to this subscription link.",
-      "subHappFallbackUrl": "Fallback Subscription URL",
-      "subHappFallbackUrlDesc": "Backup subscription address used by Happ if the primary subscription URL becomes unreachable.",
-      "subHappSubInfoText": "Banner Announcement Text",
-      "subHappSubInfoTextDesc": "Custom announcement banner displayed at the top of the Happ client (max 200 characters).",
-      "subHappSubInfoColor": "Banner Accent Color",
-      "subHappSubInfoColorDesc": "Color theme style for the announcement banner.",
-      "subHappSubInfoButtonText": "Banner Button Text",
-      "subHappSubInfoButtonTextDesc": "Button label displayed inside the announcement banner (max 25 characters).",
-      "subHappSubInfoButtonLink": "Banner Button Link",
-      "subHappSubInfoButtonLinkDesc": "Target URL opened when the user clicks the banner action button.",
-      "subHappSubExpire": "Expired Subscription Banner",
-      "subHappSubExpireDesc": "Show an expired subscription banner in Happ when the user's traffic or validity has ended.",
-      "subHappSubExpireButtonLink": "Renewal Link",
-      "subHappSubExpireButtonLinkDesc": "Target URL opened when the user clicks the renewal button on an expired subscription.",
-      "subHappNotificationExpire": "Expiration Notifications",
-      "subHappNotificationExpireDesc": "Instruct Happ to alert the user in advance before their subscription expires.",
-      "subHappNoLimit": "Bypass Rule Limit",
-      "subHappNoLimitDesc": "Allow routing rule count to exceed default mobile platform limits without truncation.",
-      "subHappAlwaysHwid": "Enforce Hardware ID (HWID)",
-      "subHappAlwaysHwidDesc": "Bind subscription sessions to client device hardware identifier for enhanced security.",
-      "subHappTunMode": "TUN Mode",
-      "subHappTunModeDesc": "TUN virtual network interface routing mode: default, system, or strict.",
-      "subHappTunType": "TUN Engine",
-      "subHappTunTypeDesc": "Underlying network stack implementation for TUN: system, gVisor, or mixed.",
-      "subHappExcludeRoutes": "Exclude CIDR Routes",
-      "subHappExcludeRoutesDesc": "Comma-separated IP CIDRs (e.g. 192.168.0.0/16, 10.0.0.0/8) to bypass the VPN tunnel.",
-      "subHappExcludeApns": "Exclude Apple APNs",
-      "subHappExcludeApnsDesc": "Bypass Apple Push Notification services to maintain reliable background notifications on iOS.",
-      "subHappColorProfile": "Client Color Theme",
-      "subHappColorProfileDesc": "Client color theme profile: default, violet, turquoise, cyberpunk, or custom JSON theme.",
-      "subHappPingType": "Latency Ping Method",
-      "subHappPingTypeDesc": "Measurement protocol used by Happ for node latency testing: icmp, tcp, or http.",
-      "subHappAutoConnect": "Auto-Connect on Launch",
-      "subHappAutoConnectDesc": "Instruct Happ to automatically connect to VPN when the application starts.",
-      "subHappAutoConnectType": "Auto-Connect Target",
-      "subHappAutoConnectTypeDesc": "Connection target strategy for auto-connect: fastest node or last used node.",
-      "subHappPerAppMode": "Android Per-App Proxy Mode",
-      "subHappPerAppModeDesc": "Control Android application routing: off, include (proxy only listed apps), or exclude (bypass listed apps).",
-      "subHappPerAppList": "Android Package Names",
-      "subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. com.telegram.messenger).",
-      "subHappPresetIran": "Iran Bypass",
-      "subHappPresetChina": "China Direct",
+      "subHappAutoDetect": "Happ Başlıkları Otomatik Algılama",
+      "subHappAutoDetectDesc": "İstemcinin User-Agent değeri Happ'i gösterdiğinde Happ yönlendirmesini ve başlıklarını otomatik olarak ekler.",
+      "subHappProviderId": "Sağlayıcı Kimliği (Provider ID)",
+      "subHappProviderIdDesc": "Happ istemci yönetimi, uzak yapılandırma bağlama ve geçiş için benzersiz sağlayıcı tanımlayıcısı.",
+      "subHappNewUrl": "Yeni Abonelik URL'si",
+      "subHappNewUrlDesc": "Otomatik istemci geçişi için hedef URL. Ayarlandığında Happ istemcileri bu abonelik bağlantısına geçer.",
+      "subHappFallbackUrl": "Yedek Abonelik URL'si",
+      "subHappFallbackUrlDesc": "Birincil abonelik URL'sine erişilemez hale gelirse Happ'in kullandığı yedek abonelik adresi.",
+      "subHappSubInfoText": "Duyuru Afişi Metni",
+      "subHappSubInfoTextDesc": "Happ istemcisinin üst kısmında görüntülenen özel duyuru afişi (en fazla 200 karakter).",
+      "subHappSubInfoColor": "Afiş Vurgu Rengi",
+      "subHappSubInfoColorDesc": "Duyuru afişinin renk teması.",
+      "subHappSubInfoButtonText": "Afiş Düğmesi Metni",
+      "subHappSubInfoButtonTextDesc": "Duyuru afişinin içinde görüntülenen düğme etiketi (en fazla 25 karakter).",
+      "subHappSubInfoButtonLink": "Afiş Düğmesi Bağlantısı",
+      "subHappSubInfoButtonLinkDesc": "Kullanıcı afişteki eylem düğmesine tıkladığında açılan hedef URL.",
+      "subHappSubExpire": "Süresi Dolmuş Abonelik Afişi",
+      "subHappSubExpireDesc": "Kullanıcının trafiği veya geçerlilik süresi bittiğinde Happ'te süresi dolmuş abonelik afişi gösterir.",
+      "subHappSubExpireButtonLink": "Yenileme Bağlantısı",
+      "subHappSubExpireButtonLinkDesc": "Kullanıcı süresi dolmuş bir abonelikte yenileme düğmesine tıkladığında açılan hedef URL.",
+      "subHappNotificationExpire": "Son Kullanma Bildirimleri",
+      "subHappNotificationExpireDesc": "Happ'in, abonelik süresi dolmadan 3 gün önce kullanıcıya hatırlatma yapmasını sağlar.",
+      "subHappNoLimit": "Sınırsız Mod",
+      "subHappNoLimitDesc": "Daha iyi kararlılık ve performans için Happ'te xray-core RAM sınırını yükseltir (beta).",
+      "subHappAlwaysHwid": "Donanım Kimliğini (HWID) Zorunlu Kıl",
+      "subHappAlwaysHwidDesc": "Kullanıcıların Happ ayarlarında HWID gönderimini kapatmasını engeller.",
+      "subHappTunMode": "TUN Modu",
+      "subHappTunModeDesc": "Masaüstünde TUN'un kullandığı ağ yığını: sistem (işletim sistemi yığını) veya gVisor (kullanıcı alanı yığını).",
+      "subHappTunType": "TUN Motoru",
+      "subHappTunTypeDesc": "Masaüstünde TUN bağlantısı için kullanılan çekirdek: sing-box, tun2proxy, varsayılan (Happ TUN) veya Xray.",
+      "subHappExcludeRoutes": "CIDR Yönlendirmelerini Hariç Tut",
+      "subHappExcludeRoutesDesc": "VPN tünelinin dışında tutulacak, virgülle ayrılmış IP CIDR'leri (ör. 192.168.0.0/16, 10.0.0.0/8).",
+      "subHappExcludeApns": "Apple APNs'i Hariç Tut",
+      "subHappExcludeApnsDesc": "iOS'ta arka plan bildirimlerinin güvenilir çalışması için Apple Push Notification hizmetlerini VPN'in dışında tutar.",
+      "subHappColorProfile": "İstemci Renk Teması",
+      "subHappColorProfileDesc": "JSON dizesi olarak özel iOS renk teması veya varsayılan renkleri geri yüklemek için resetcolors.",
+      "subHappPingType": "Gecikme Ping Yöntemi",
+      "subHappPingTypeDesc": "Happ'in düğüm gecikmesini ölçme yöntemi: proxy üzerinden (GET veya HEAD), TCP veya ICMP.",
+      "subHappAutoConnect": "Açılışta Otomatik Bağlan",
+      "subHappAutoConnectDesc": "Uygulama başladığında Happ'in VPN'e otomatik olarak bağlanmasını sağlar.",
+      "subHappAutoConnectType": "Otomatik Bağlantı Hedefi",
+      "subHappAutoConnectTypeDesc": "Otomatik bağlantı için seçilen sunucu: en düşük gecikme, son kullanılan veya rastgele düğüm.",
+      "subHappPerAppMode": "Android Uygulama Başına Proxy Modu",
+      "subHappPerAppModeDesc": "Android uygulama yönlendirmesini denetler: kapalı, açık (yalnızca listelenen uygulamalar proxy üzerinden) veya atla (listelenen uygulamaları hariç tut).",
+      "subHappPerAppList": "Android Paket Adları",
+      "subHappPerAppListDesc": "Dahil edilecek veya hariç tutulacak Android uygulamalarının virgülle ayrılmış paket adları (ör. org.telegram.messenger).",
+      "subHappPresetIran": "İran'ı Atla",
+      "subHappPresetChina": "Çin Doğrudan",
       "subHappPresetAdblock": "AdBlock",
-      "subHappPresetGlobal": "Full Proxy",
+      "subHappPresetGlobal": "Tam Proxy",
       "subHappPresetOff": "Yönlendirmeyi Devre Dışı Bırak (happ://routing/off)",
       "subHappColorBlue": "Mavi (Standart / Varsayılan)",
       "subHappColorGreen": "Yeşil (Başarılı)",
@@ -1560,35 +1563,58 @@
       "subHappPingProxy": "Proxy Üzerinden (GET Gecikmesi)",
       "subHappPingProxyHead": "Proxy Üzerinden (HEAD Gecikmesi)",
       "subHappPingTcp": "TCP El Sıkışma Pingi",
-      "subHappPingIcmp": "ICMP Ping",
+      "subHappPingIcmp": "ICMP Pingi",
       "subHappAutoConnectLowestDelay": "En Düşük Gecikme (En Hızlı Düğüm)",
       "subHappAutoConnectLastUsed": "Son Kullanılan Düğüm",
       "subHappAutoConnectRandom": "Rastgele Düğüm",
       "subHappPerAppOff": "Kapalı",
-      "subHappPerAppOn": "Açık (Yalnızca Listelenen Uygulamalar)",
+      "subHappPerAppOn": "Açık (Yalnızca Listelenen Uygulamalar Proxy Üzerinden)",
       "subHappPerAppBypass": "Atla (Listelenen Uygulamaları Hariç Tut)",
-      "subHappPresetApplied": "Happ preset applied to routing rules",
-      "subHappPresets": "Routing Presets",
-      "subHappPresetsDesc": "Pre-configured routing rule presets tailored for Happ clients.",
-      "subHappVisualBuilder": "Visual Rule Generator",
-      "subHappVisualBuilderDesc": "Create custom routing deeplink from domain and IP lists.",
-      "subHappBuildDeeplink": "Generate Deeplink",
-      "subHappModalTitle": "Happ Visual Routing Rule Generator",
-      "subHappDirectDomains": "Direct Domains (Bypass)",
-      "subHappProxyDomains": "Proxy Domains (Tunnel)",
-      "subHappBlockDomains": "Blocked Domains (Ad/Malware)",
-      "subHappDirectIPs": "Direct IPs / CIDRs",
-      "subHappProxyIPs": "Proxy IPs / CIDRs",
-      "subHappBlockIPs": "Blocked IPs / CIDRs",
-      "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappPresetApplied": "Happ hazır ayarı yönlendirme kurallarına uygulandı",
+      "subHappPresets": "Yönlendirme Hazır Ayarları",
+      "subHappPresetsDesc": "Happ istemcilerine özel, önceden yapılandırılmış yönlendirme kuralı hazır ayarları.",
+      "subHappVisualBuilder": "Görsel Kural Oluşturucu",
+      "subHappVisualBuilderDesc": "Alan adı ve IP listelerinden özel yönlendirme derin bağlantısı oluşturur.",
+      "subHappBuildDeeplink": "Derin Bağlantı Oluştur",
+      "subHappModalTitle": "Happ Görsel Yönlendirme Kuralı Oluşturucu",
+      "subHappDirectDomains": "Doğrudan Alan Adları (Atla)",
+      "subHappProxyDomains": "Proxy Alan Adları (Tünel)",
+      "subHappBlockDomains": "Engellenen Alan Adları (Reklam/Kötü Amaçlı Yazılım)",
+      "subHappDirectIPs": "Doğrudan IP'ler / CIDR'ler",
+      "subHappProxyIPs": "Proxy IP'ler / CIDR'ler",
+      "subHappBlockIPs": "Engellenen IP'ler / CIDR'ler",
+      "subHappDeeplinkGenerated": "Derin bağlantı oluşturuldu ve yönlendirme kurallarına uygulandı",
       "subHappGroupLinks": "Abonelik Bağlantıları",
-      "subHappGroupRouting": "Routing & Rules",
-      "subHappGroupBanners": "Banners & Announcements",
-      "subHappGroupNetwork": "Network & TUN Engine",
-      "subHappGroupThemes": "Appearance & Theme",
-      "subHappGroupFailover": "Migration & App Management",
-      "subHappGroupAndroid": "Android Per-App Proxy"
-
+      "subHappGroupRouting": "Yönlendirme ve Kurallar",
+      "subHappGroupBanners": "Afişler ve Duyurular",
+      "subHappGroupNetwork": "Ağ ve TUN Motoru",
+      "subHappGroupThemes": "Görünüm ve Tema",
+      "subHappGroupFailover": "Geçiş ve Uygulama Yönetimi",
+      "subHappGroupAndroid": "Android Uygulama Başına 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 +2416,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 <email>` veya `/usage <email>`",
+      "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ı",

+ 174 - 73
internal/web/translation/uk-UA.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "Завантажувати через outbound (необов’язково)",
       "geodataFile": "Ім’я файлу",
       "geodataAddFile": "Додати файл",
+      "geodataUseStandardSources": "Використати стандартні джерела",
       "geodataSaveRestart": "Зберегти та перезапустити Xray",
       "geodataConfirmTitle": "Зберегти налаштування geodata?",
       "geodataConfirmContent": "Шаблон конфігурації Xray буде оновлено, а Xray перезапущено.",
@@ -1253,6 +1254,8 @@
       "noisesSett": "Налаштування Noises",
       "trustedProxyCidrs": "Довірені CIDR проксі",
       "trustedProxyCidrsDesc": "IP/CIDR через кому, яким дозволено встановлювати заголовки forwarded host, proto та client IP.",
+      "realityScanCandidates": "Кандидати сканування Reality",
+      "realityScanCandidatesDesc": "Список host:port (або CIDR) через кому — типовий список для «Знайти цілі», коли пошук порожній. Налаштуйте під часто використовувані цілі.",
       "ldap": {
         "enable": "Увімкнути LDAP-синхронізацію",
         "host": "LDAP-хост",
@@ -1496,63 +1499,63 @@
       "subExpiredTemplateDesc": "Шаблон для фіктивного вузла, коли термін дії підписки закінчився.",
       "subTrafficDepletedTemplate": "Шаблон вичерпання трафіку",
       "subTrafficDepletedTemplateDesc": "Шаблон для фіктивного вузла, коли ліміт трафіку вичерпано.",
-      "subHappAutoDetect": "Happ Header Auto-Detection",
-      "subHappAutoDetectDesc": "Automatically inject Happ routing and headers when client User-Agent indicates Happ.",
-      "subHappProviderId": "Provider ID",
-      "subHappProviderIdDesc": "Unique provider identifier for Happ client management, remote configuration binding, and migration.",
-      "subHappNewUrl": "New Subscription URL",
-      "subHappNewUrlDesc": "Target URL for automatic client migration. When set, Happ clients will migrate to this subscription link.",
-      "subHappFallbackUrl": "Fallback Subscription URL",
-      "subHappFallbackUrlDesc": "Backup subscription address used by Happ if the primary subscription URL becomes unreachable.",
-      "subHappSubInfoText": "Banner Announcement Text",
-      "subHappSubInfoTextDesc": "Custom announcement banner displayed at the top of the Happ client (max 200 characters).",
-      "subHappSubInfoColor": "Banner Accent Color",
-      "subHappSubInfoColorDesc": "Color theme style for the announcement banner.",
-      "subHappSubInfoButtonText": "Banner Button Text",
-      "subHappSubInfoButtonTextDesc": "Button label displayed inside the announcement banner (max 25 characters).",
-      "subHappSubInfoButtonLink": "Banner Button Link",
-      "subHappSubInfoButtonLinkDesc": "Target URL opened when the user clicks the banner action button.",
-      "subHappSubExpire": "Expired Subscription Banner",
-      "subHappSubExpireDesc": "Show an expired subscription banner in Happ when the user's traffic or validity has ended.",
-      "subHappSubExpireButtonLink": "Renewal Link",
-      "subHappSubExpireButtonLinkDesc": "Target URL opened when the user clicks the renewal button on an expired subscription.",
-      "subHappNotificationExpire": "Expiration Notifications",
-      "subHappNotificationExpireDesc": "Instruct Happ to alert the user in advance before their subscription expires.",
-      "subHappNoLimit": "Bypass Rule Limit",
-      "subHappNoLimitDesc": "Allow routing rule count to exceed default mobile platform limits without truncation.",
-      "subHappAlwaysHwid": "Enforce Hardware ID (HWID)",
-      "subHappAlwaysHwidDesc": "Bind subscription sessions to client device hardware identifier for enhanced security.",
-      "subHappTunMode": "TUN Mode",
-      "subHappTunModeDesc": "TUN virtual network interface routing mode: default, system, or strict.",
-      "subHappTunType": "TUN Engine",
-      "subHappTunTypeDesc": "Underlying network stack implementation for TUN: system, gVisor, or mixed.",
-      "subHappExcludeRoutes": "Exclude CIDR Routes",
-      "subHappExcludeRoutesDesc": "Comma-separated IP CIDRs (e.g. 192.168.0.0/16, 10.0.0.0/8) to bypass the VPN tunnel.",
-      "subHappExcludeApns": "Exclude Apple APNs",
-      "subHappExcludeApnsDesc": "Bypass Apple Push Notification services to maintain reliable background notifications on iOS.",
-      "subHappColorProfile": "Client Color Theme",
-      "subHappColorProfileDesc": "Client color theme profile: default, violet, turquoise, cyberpunk, or custom JSON theme.",
-      "subHappPingType": "Latency Ping Method",
-      "subHappPingTypeDesc": "Measurement protocol used by Happ for node latency testing: icmp, tcp, or http.",
-      "subHappAutoConnect": "Auto-Connect on Launch",
-      "subHappAutoConnectDesc": "Instruct Happ to automatically connect to VPN when the application starts.",
-      "subHappAutoConnectType": "Auto-Connect Target",
-      "subHappAutoConnectTypeDesc": "Connection target strategy for auto-connect: fastest node or last used node.",
-      "subHappPerAppMode": "Android Per-App Proxy Mode",
-      "subHappPerAppModeDesc": "Control Android application routing: off, include (proxy only listed apps), or exclude (bypass listed apps).",
-      "subHappPerAppList": "Android Package Names",
-      "subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. com.telegram.messenger).",
-      "subHappPresetIran": "Iran Bypass",
-      "subHappPresetChina": "China Direct",
+      "subHappAutoDetect": "Автовизначення заголовків Happ",
+      "subHappAutoDetectDesc": "Автоматично додавати маршрутизацію та заголовки Happ, коли User-Agent клієнта вказує на Happ.",
+      "subHappProviderId": "Ідентифікатор провайдера (Provider ID)",
+      "subHappProviderIdDesc": "Унікальний ідентифікатор провайдера для керування клієнтами Happ, прив'язки віддаленої конфігурації та міграції.",
+      "subHappNewUrl": "Новий URL підписки",
+      "subHappNewUrlDesc": "Цільовий URL для автоматичної міграції клієнтів. Якщо задано, клієнти Happ перейдуть на це посилання підписки.",
+      "subHappFallbackUrl": "Резервний URL підписки",
+      "subHappFallbackUrlDesc": "Резервна адреса підписки, яку Happ використовує, якщо основний URL підписки стане недоступним.",
+      "subHappSubInfoText": "Текст оголошення в банері",
+      "subHappSubInfoTextDesc": "Власний банер з оголошенням угорі клієнта Happ (макс. 200 символів).",
+      "subHappSubInfoColor": "Акцентний колір банера",
+      "subHappSubInfoColorDesc": "Колірне оформлення банера з оголошенням.",
+      "subHappSubInfoButtonText": "Текст кнопки банера",
+      "subHappSubInfoButtonTextDesc": "Напис на кнопці всередині банера з оголошенням (макс. 25 символів).",
+      "subHappSubInfoButtonLink": "Посилання кнопки банера",
+      "subHappSubInfoButtonLinkDesc": "Цільовий URL, який відкривається, коли користувач натискає кнопку дії банера.",
+      "subHappSubExpire": "Банер про закінчення підписки",
+      "subHappSubExpireDesc": "Показувати в Happ банер про закінчення підписки, коли в користувача закінчився трафік або термін дії.",
+      "subHappSubExpireButtonLink": "Посилання для продовження",
+      "subHappSubExpireButtonLinkDesc": "Цільовий URL, який відкривається, коли користувач натискає кнопку продовження простроченої підписки.",
+      "subHappNotificationExpire": "Сповіщення про закінчення терміну",
+      "subHappNotificationExpireDesc": "Доручити Happ нагадати користувачеві за 3 дні до закінчення підписки.",
+      "subHappNoLimit": "Режим без обмежень",
+      "subHappNoLimitDesc": "Підвищити ліміт оперативної пам'яті xray-core у Happ для кращої стабільності та продуктивності (бета).",
+      "subHappAlwaysHwid": "Примусовий апаратний ідентифікатор (HWID)",
+      "subHappAlwaysHwidDesc": "Не дозволяти користувачам вимикати надсилання HWID у налаштуваннях Happ.",
+      "subHappTunMode": "Режим TUN",
+      "subHappTunModeDesc": "Мережевий стек TUN на комп'ютерах: системний (стек ОС) або gVisor (стек у просторі користувача).",
+      "subHappTunType": "Рушій TUN",
+      "subHappTunTypeDesc": "Ядро для TUN-з'єднання на комп'ютерах: sing-box, tun2proxy, за замовчуванням (Happ TUN) або Xray.",
+      "subHappExcludeRoutes": "Виключити маршрути CIDR",
+      "subHappExcludeRoutesDesc": "IP CIDR через кому (напр. 192.168.0.0/16, 10.0.0.0/8), трафік до яких обходить VPN-тунель.",
+      "subHappExcludeApns": "Виключити Apple APNs",
+      "subHappExcludeApnsDesc": "Пропускати служби Apple Push Notification в обхід VPN для надійних фонових сповіщень на iOS.",
+      "subHappColorProfile": "Колірна тема клієнта",
+      "subHappColorProfileDesc": "Власна колірна тема iOS у вигляді рядка JSON або resetcolors для відновлення стандартних кольорів.",
+      "subHappPingType": "Метод вимірювання затримки",
+      "subHappPingTypeDesc": "Як Happ вимірює затримку вузлів: через проксі (GET або HEAD), TCP або ICMP.",
+      "subHappAutoConnect": "Автопідключення під час запуску",
+      "subHappAutoConnectDesc": "Доручити Happ автоматично підключатися до VPN під час запуску програми.",
+      "subHappAutoConnectType": "Ціль автопідключення",
+      "subHappAutoConnectTypeDesc": "Сервер для автопідключення: з найменшою затримкою, останній використаний або випадковий вузол.",
+      "subHappPerAppMode": "Режим проксі для окремих програм Android",
+      "subHappPerAppModeDesc": "Керування маршрутизацією програм Android: вимкнено, увімкнено (проксі лише для вказаних програм) або обхід (виключити вказані програми).",
+      "subHappPerAppList": "Назви пакетів Android",
+      "subHappPerAppListDesc": "Назви пакетів програм Android через кому для включення або виключення (напр. org.telegram.messenger).",
+      "subHappPresetIran": "Обхід для Ірану",
+      "subHappPresetChina": "Китай напряму",
       "subHappPresetAdblock": "AdBlock",
-      "subHappPresetGlobal": "Full Proxy",
+      "subHappPresetGlobal": "Усе через проксі",
       "subHappPresetOff": "Вимкнути маршрутизацію (happ://routing/off)",
       "subHappColorBlue": "Синій (стандартний / за замовчуванням)",
       "subHappColorGreen": "Зелений (успіх)",
       "subHappColorRed": "Червоний (попередження / небезпека)",
       "subHappTunModeDefault": "За замовчуванням",
       "subHappTunModeSystem": "Системний (стандартний стек ОС)",
-      "subHappTunModeGvisor": "gVisor (користувацький стек)",
+      "subHappTunModeGvisor": "gVisor (стек у просторі користувача)",
       "subHappTunTypeSingbox": "sing-box",
       "subHappTunTypeTun2proxy": "tun2proxy",
       "subHappTunTypeDefault": "За замовчуванням (Happ TUN)",
@@ -1565,30 +1568,53 @@
       "subHappAutoConnectLastUsed": "Останній використаний вузол",
       "subHappAutoConnectRandom": "Випадковий вузол",
       "subHappPerAppOff": "Вимк",
-      "subHappPerAppOn": "Увімк (тільки обрані програми)",
-      "subHappPerAppBypass": "Обхід (виключити обрані програми)",
-      "subHappPresetApplied": "Happ preset applied to routing rules",
-      "subHappPresets": "Routing Presets",
-      "subHappPresetsDesc": "Pre-configured routing rule presets tailored for Happ clients.",
-      "subHappVisualBuilder": "Visual Rule Generator",
-      "subHappVisualBuilderDesc": "Create custom routing deeplink from domain and IP lists.",
-      "subHappBuildDeeplink": "Generate Deeplink",
-      "subHappModalTitle": "Happ Visual Routing Rule Generator",
-      "subHappDirectDomains": "Direct Domains (Bypass)",
-      "subHappProxyDomains": "Proxy Domains (Tunnel)",
-      "subHappBlockDomains": "Blocked Domains (Ad/Malware)",
-      "subHappDirectIPs": "Direct IPs / CIDRs",
-      "subHappProxyIPs": "Proxy IPs / CIDRs",
-      "subHappBlockIPs": "Blocked IPs / CIDRs",
-      "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappPerAppOn": "Увімк (проксі лише для вказаних програм)",
+      "subHappPerAppBypass": "Обхід (виключити вказані програми)",
+      "subHappPresetApplied": "Шаблон Happ застосовано до правил маршрутизації",
+      "subHappPresets": "Шаблони маршрутизації",
+      "subHappPresetsDesc": "Готові шаблони правил маршрутизації, адаптовані для клієнтів Happ.",
+      "subHappVisualBuilder": "Візуальний генератор правил",
+      "subHappVisualBuilderDesc": "Створіть власний діплінк маршрутизації зі списків доменів та IP.",
+      "subHappBuildDeeplink": "Згенерувати діплінк",
+      "subHappModalTitle": "Візуальний генератор правил маршрутизації Happ",
+      "subHappDirectDomains": "Домени напряму (обхід)",
+      "subHappProxyDomains": "Домени через проксі (тунель)",
+      "subHappBlockDomains": "Заблоковані домени (реклама/шкідливе ПЗ)",
+      "subHappDirectIPs": "IP / CIDR напряму",
+      "subHappProxyIPs": "IP / CIDR через проксі",
+      "subHappBlockIPs": "Заблоковані IP / CIDR",
+      "subHappDeeplinkGenerated": "Діплінк згенеровано та застосовано до правил маршрутизації",
       "subHappGroupLinks": "Посилання на підписку",
-      "subHappGroupRouting": "Routing & Rules",
-      "subHappGroupBanners": "Banners & Announcements",
-      "subHappGroupNetwork": "Network & TUN Engine",
-      "subHappGroupThemes": "Appearance & Theme",
-      "subHappGroupFailover": "Migration & App Management",
-      "subHappGroupAndroid": "Android Per-App Proxy"
-
+      "subHappGroupRouting": "Маршрутизація та правила",
+      "subHappGroupBanners": "Банери та оголошення",
+      "subHappGroupNetwork": "Мережа та рушій TUN",
+      "subHappGroupThemes": "Зовнішній вигляд і тема",
+      "subHappGroupFailover": "Міграція та керування програмою",
+      "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": "Час сповіщення",
+      "discordNotifyTimeDesc": "Як часто бот Discord надсилає періодичні звіти. Виберіть готовий інтервал або «Власний», щоб ввести вираз crontab.",
+      "discordNotifyBackup": "Резервне копіювання бази даних",
+      "discordNotifyBackupDesc": "Надіслати файл резервної копії бази даних зі звітом.",
+      "discordEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення в Discord"
     },
     "xray": {
       "save": "Зберегти",
@@ -2390,6 +2416,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 <email>` або `/usage <email>`",
+      "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": "Вихідне з'єднання",

+ 171 - 70
internal/web/translation/vi-VN.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "Tải qua outbound (tùy chọn)",
       "geodataFile": "Tên tệp",
       "geodataAddFile": "Thêm tệp",
+      "geodataUseStandardSources": "Dùng nguồn tiêu chuẩn",
       "geodataSaveRestart": "Lưu và khởi động lại Xray",
       "geodataConfirmTitle": "Lưu cài đặt geodata?",
       "geodataConfirmContent": "Mẫu cấu hình Xray sẽ được cập nhật và Xray sẽ khởi động lại.",
@@ -1253,6 +1254,8 @@
       "noisesSett": "Cài đặt Noises",
       "trustedProxyCidrs": "CIDR proxy tin cậy",
       "trustedProxyCidrsDesc": "IPs/CIDRs cách nhau bằng dấu phẩy được phép đặt header host, proto và IP client chuyển tiếp.",
+      "realityScanCandidates": "Danh sách ứng viên quét Reality",
+      "realityScanCandidatesDesc": "Danh sách host:port (hoặc CIDR) cách nhau bằng dấu phẩy dùng làm mặc định khi Tìm mục tiêu chạy với ô tìm kiếm trống. Tuỳ chỉnh theo các đích bạn dùng thường xuyên.",
       "ldap": {
         "enable": "Bật đồng bộ LDAP",
         "host": "LDAP host",
@@ -1496,56 +1499,56 @@
       "subExpiredTemplateDesc": "Mẫu cho cấu hình ảo khi tài khoản đăng ký đã hết hạn.",
       "subTrafficDepletedTemplate": "Mẫu hết dung lượng",
       "subTrafficDepletedTemplateDesc": "Mẫu cho cấu hình ảo khi tài khoản đăng ký đã dùng hết dung lượng.",
-      "subHappAutoDetect": "Happ Header Auto-Detection",
-      "subHappAutoDetectDesc": "Automatically inject Happ routing and headers when client User-Agent indicates Happ.",
-      "subHappProviderId": "Provider ID",
-      "subHappProviderIdDesc": "Unique provider identifier for Happ client management, remote configuration binding, and migration.",
-      "subHappNewUrl": "New Subscription URL",
-      "subHappNewUrlDesc": "Target URL for automatic client migration. When set, Happ clients will migrate to this subscription link.",
-      "subHappFallbackUrl": "Fallback Subscription URL",
-      "subHappFallbackUrlDesc": "Backup subscription address used by Happ if the primary subscription URL becomes unreachable.",
-      "subHappSubInfoText": "Banner Announcement Text",
-      "subHappSubInfoTextDesc": "Custom announcement banner displayed at the top of the Happ client (max 200 characters).",
-      "subHappSubInfoColor": "Banner Accent Color",
-      "subHappSubInfoColorDesc": "Color theme style for the announcement banner.",
-      "subHappSubInfoButtonText": "Banner Button Text",
-      "subHappSubInfoButtonTextDesc": "Button label displayed inside the announcement banner (max 25 characters).",
-      "subHappSubInfoButtonLink": "Banner Button Link",
-      "subHappSubInfoButtonLinkDesc": "Target URL opened when the user clicks the banner action button.",
-      "subHappSubExpire": "Expired Subscription Banner",
-      "subHappSubExpireDesc": "Show an expired subscription banner in Happ when the user's traffic or validity has ended.",
-      "subHappSubExpireButtonLink": "Renewal Link",
-      "subHappSubExpireButtonLinkDesc": "Target URL opened when the user clicks the renewal button on an expired subscription.",
-      "subHappNotificationExpire": "Expiration Notifications",
-      "subHappNotificationExpireDesc": "Instruct Happ to alert the user in advance before their subscription expires.",
-      "subHappNoLimit": "Bypass Rule Limit",
-      "subHappNoLimitDesc": "Allow routing rule count to exceed default mobile platform limits without truncation.",
-      "subHappAlwaysHwid": "Enforce Hardware ID (HWID)",
-      "subHappAlwaysHwidDesc": "Bind subscription sessions to client device hardware identifier for enhanced security.",
-      "subHappTunMode": "TUN Mode",
-      "subHappTunModeDesc": "TUN virtual network interface routing mode: default, system, or strict.",
-      "subHappTunType": "TUN Engine",
-      "subHappTunTypeDesc": "Underlying network stack implementation for TUN: system, gVisor, or mixed.",
-      "subHappExcludeRoutes": "Exclude CIDR Routes",
-      "subHappExcludeRoutesDesc": "Comma-separated IP CIDRs (e.g. 192.168.0.0/16, 10.0.0.0/8) to bypass the VPN tunnel.",
-      "subHappExcludeApns": "Exclude Apple APNs",
-      "subHappExcludeApnsDesc": "Bypass Apple Push Notification services to maintain reliable background notifications on iOS.",
-      "subHappColorProfile": "Client Color Theme",
-      "subHappColorProfileDesc": "Client color theme profile: default, violet, turquoise, cyberpunk, or custom JSON theme.",
-      "subHappPingType": "Latency Ping Method",
-      "subHappPingTypeDesc": "Measurement protocol used by Happ for node latency testing: icmp, tcp, or http.",
-      "subHappAutoConnect": "Auto-Connect on Launch",
-      "subHappAutoConnectDesc": "Instruct Happ to automatically connect to VPN when the application starts.",
-      "subHappAutoConnectType": "Auto-Connect Target",
-      "subHappAutoConnectTypeDesc": "Connection target strategy for auto-connect: fastest node or last used node.",
-      "subHappPerAppMode": "Android Per-App Proxy Mode",
-      "subHappPerAppModeDesc": "Control Android application routing: off, include (proxy only listed apps), or exclude (bypass listed apps).",
-      "subHappPerAppList": "Android Package Names",
-      "subHappPerAppListDesc": "Comma-separated package names of Android applications to include or exclude (e.g. com.telegram.messenger).",
-      "subHappPresetIran": "Iran Bypass",
-      "subHappPresetChina": "China Direct",
+      "subHappAutoDetect": "Tự động nhận diện header Happ",
+      "subHappAutoDetectDesc": "Tự động chèn định tuyến và header Happ khi User-Agent của ứng dụng khách cho thấy đó là Happ.",
+      "subHappProviderId": "ID nhà cung cấp (Provider ID)",
+      "subHappProviderIdDesc": "Mã định danh duy nhất của nhà cung cấp, dùng để quản lý ứng dụng khách Happ, liên kết cấu hình từ xa và di chuyển.",
+      "subHappNewUrl": "URL đăng ký mới",
+      "subHappNewUrlDesc": "URL đích để tự động di chuyển ứng dụng khách. Khi được đặt, ứng dụng Happ sẽ chuyển sang liên kết đăng ký này.",
+      "subHappFallbackUrl": "URL đăng ký dự phòng",
+      "subHappFallbackUrlDesc": "Địa chỉ đăng ký dự phòng mà Happ dùng khi không thể truy cập URL đăng ký chính.",
+      "subHappSubInfoText": "Nội dung biểu ngữ thông báo",
+      "subHappSubInfoTextDesc": "Biểu ngữ thông báo tùy chỉnh hiển thị ở đầu ứng dụng Happ (tối đa 200 ký tự).",
+      "subHappSubInfoColor": "Màu nhấn biểu ngữ",
+      "subHappSubInfoColorDesc": "Kiểu màu chủ đề cho biểu ngữ thông báo.",
+      "subHappSubInfoButtonText": "Chữ trên nút biểu ngữ",
+      "subHappSubInfoButtonTextDesc": "Nhãn nút hiển thị bên trong biểu ngữ thông báo (tối đa 25 ký tự).",
+      "subHappSubInfoButtonLink": "Liên kết nút biểu ngữ",
+      "subHappSubInfoButtonLinkDesc": "URL đích được mở khi người dùng nhấn nút hành động trên biểu ngữ.",
+      "subHappSubExpire": "Biểu ngữ đăng ký hết hạn",
+      "subHappSubExpireDesc": "Hiển thị biểu ngữ đăng ký hết hạn trong Happ khi lưu lượng hoặc thời hạn của người dùng đã hết.",
+      "subHappSubExpireButtonLink": "Liên kết gia hạn",
+      "subHappSubExpireButtonLinkDesc": "URL đích được mở khi người dùng nhấn nút gia hạn trên đăng ký đã hết hạn.",
+      "subHappNotificationExpire": "Thông báo hết hạn",
+      "subHappNotificationExpireDesc": "Yêu cầu Happ nhắc người dùng 3 ngày trước khi đăng ký hết hạn.",
+      "subHappNoLimit": "Chế độ không giới hạn",
+      "subHappNoLimitDesc": "Nâng giới hạn RAM của xray-core trong Happ để tăng độ ổn định và hiệu năng (beta).",
+      "subHappAlwaysHwid": "Bắt buộc ID phần cứng (HWID)",
+      "subHappAlwaysHwidDesc": "Ngăn người dùng tắt việc gửi HWID trong cài đặt Happ.",
+      "subHappTunMode": "Chế độ TUN",
+      "subHappTunModeDesc": "Ngăn xếp mạng mà TUN dùng trên máy tính: hệ thống (ngăn xếp của hệ điều hành) hoặc gVisor (ngăn xếp không gian người dùng).",
+      "subHappTunType": "Lõi TUN",
+      "subHappTunTypeDesc": "Lõi dùng cho kết nối TUN trên máy tính: sing-box, tun2proxy, mặc định (Happ TUN) hoặc Xray.",
+      "subHappExcludeRoutes": "Loại trừ tuyến CIDR",
+      "subHappExcludeRoutesDesc": "Các IP CIDR cách nhau bằng dấu phẩy (ví dụ 192.168.0.0/16, 10.0.0.0/8) sẽ bỏ qua đường hầm VPN.",
+      "subHappExcludeApns": "Loại trừ Apple APNs",
+      "subHappExcludeApnsDesc": "Bỏ qua dịch vụ Apple Push Notification để thông báo nền trên iOS luôn ổn định.",
+      "subHappColorProfile": "Chủ đề màu ứng dụng khách",
+      "subHappColorProfileDesc": "Chủ đề màu iOS tùy chỉnh dưới dạng chuỗi JSON, hoặc resetcolors để khôi phục màu mặc định.",
+      "subHappPingType": "Phương thức đo độ trễ",
+      "subHappPingTypeDesc": "Cách Happ đo độ trễ của nút: qua proxy (GET hoặc HEAD), TCP hoặc ICMP.",
+      "subHappAutoConnect": "Tự động kết nối khi khởi động",
+      "subHappAutoConnectDesc": "Yêu cầu Happ tự động kết nối VPN khi ứng dụng khởi động.",
+      "subHappAutoConnectType": "Mục tiêu tự động kết nối",
+      "subHappAutoConnectTypeDesc": "Máy chủ được chọn khi tự động kết nối: độ trễ thấp nhất, dùng gần nhất hoặc một nút ngẫu nhiên.",
+      "subHappPerAppMode": "Chế độ proxy theo ứng dụng Android",
+      "subHappPerAppModeDesc": "Kiểm soát định tuyến ứng dụng Android: tắt, bật (chỉ proxy ứng dụng trong danh sách) hoặc bỏ qua (loại trừ ứng dụng trong danh sách).",
+      "subHappPerAppList": "Tên gói Android",
+      "subHappPerAppListDesc": "Tên gói ứng dụng Android cách nhau bằng dấu phẩy để bao gồm hoặc loại trừ (ví dụ org.telegram.messenger).",
+      "subHappPresetIran": "Bỏ qua Iran",
+      "subHappPresetChina": "Trực tiếp Trung Quốc",
       "subHappPresetAdblock": "AdBlock",
-      "subHappPresetGlobal": "Full Proxy",
+      "subHappPresetGlobal": "Proxy toàn bộ",
       "subHappPresetOff": "Tắt định tuyến (happ://routing/off)",
       "subHappColorBlue": "Xanh dương (Tiêu chuẩn / Mặc định)",
       "subHappColorGreen": "Xanh lá (Thành công)",
@@ -1567,28 +1570,51 @@
       "subHappPerAppOff": "Tắt",
       "subHappPerAppOn": "Bật (Chỉ proxy ứng dụng trong danh sách)",
       "subHappPerAppBypass": "Bỏ qua (Loại trừ ứng dụng trong danh sách)",
-      "subHappPresetApplied": "Happ preset applied to routing rules",
-      "subHappPresets": "Routing Presets",
-      "subHappPresetsDesc": "Pre-configured routing rule presets tailored for Happ clients.",
-      "subHappVisualBuilder": "Visual Rule Generator",
-      "subHappVisualBuilderDesc": "Create custom routing deeplink from domain and IP lists.",
-      "subHappBuildDeeplink": "Generate Deeplink",
-      "subHappModalTitle": "Happ Visual Routing Rule Generator",
-      "subHappDirectDomains": "Direct Domains (Bypass)",
-      "subHappProxyDomains": "Proxy Domains (Tunnel)",
-      "subHappBlockDomains": "Blocked Domains (Ad/Malware)",
-      "subHappDirectIPs": "Direct IPs / CIDRs",
-      "subHappProxyIPs": "Proxy IPs / CIDRs",
-      "subHappBlockIPs": "Blocked IPs / CIDRs",
-      "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappPresetApplied": "Đã áp dụng mẫu Happ vào quy tắc định tuyến",
+      "subHappPresets": "Mẫu định tuyến",
+      "subHappPresetsDesc": "Các mẫu quy tắc định tuyến cấu hình sẵn dành cho ứng dụng Happ.",
+      "subHappVisualBuilder": "Trình tạo quy tắc trực quan",
+      "subHappVisualBuilderDesc": "Tạo deeplink định tuyến tùy chỉnh từ danh sách tên miền và IP.",
+      "subHappBuildDeeplink": "Tạo deeplink",
+      "subHappModalTitle": "Trình tạo quy tắc định tuyến trực quan cho Happ",
+      "subHappDirectDomains": "Tên miền trực tiếp (bỏ qua)",
+      "subHappProxyDomains": "Tên miền proxy (đường hầm)",
+      "subHappBlockDomains": "Tên miền bị chặn (quảng cáo/mã độc)",
+      "subHappDirectIPs": "IP / CIDR trực tiếp",
+      "subHappProxyIPs": "IP / CIDR proxy",
+      "subHappBlockIPs": "IP / CIDR bị chặn",
+      "subHappDeeplinkGenerated": "Đã tạo deeplink và áp dụng vào quy tắc định tuyến",
       "subHappGroupLinks": "Liên kết đăng ký",
-      "subHappGroupRouting": "Routing & Rules",
-      "subHappGroupBanners": "Banners & Announcements",
-      "subHappGroupNetwork": "Network & TUN Engine",
-      "subHappGroupThemes": "Appearance & Theme",
-      "subHappGroupFailover": "Migration & App Management",
-      "subHappGroupAndroid": "Android Per-App Proxy"
-
+      "subHappGroupRouting": "Định tuyến & quy tắc",
+      "subHappGroupBanners": "Biểu ngữ & thông báo",
+      "subHappGroupNetwork": "Mạng & lõi TUN",
+      "subHappGroupThemes": "Giao diện & chủ đề",
+      "subHappGroupFailover": "Di chuyển & quản lý ứng dụng",
+      "subHappGroupAndroid": "Proxy theo ứng dụng Android",
+      "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 +2416,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 <email>` hoặc `/usage <email>`",
+      "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",

+ 116 - 15
internal/web/translation/zh-CN.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "通过出站下载(可选)",
       "geodataFile": "文件名",
       "geodataAddFile": "添加文件",
+      "geodataUseStandardSources": "使用标准来源",
       "geodataSaveRestart": "保存并重启 Xray",
       "geodataConfirmTitle": "保存 geodata 设置?",
       "geodataConfirmContent": "将更新 Xray 配置模板并重启 Xray。",
@@ -1253,6 +1254,8 @@
       "noisesSett": "Noises 设置",
       "trustedProxyCidrs": "可信代理 CIDR",
       "trustedProxyCidrsDesc": "允许设置转发 host、proto 和客户端 IP 标头的 IP/CIDR(逗号分隔)。",
+      "realityScanCandidates": "Reality 扫描候选列表",
+      "realityScanCandidatesDesc": "逗号分隔的 host:port 目标(或 CIDR),作为“查找目标”在搜索框为空时的默认列表。可按常用目标自定义。",
       "ldap": {
         "enable": "启用 LDAP 同步",
         "host": "LDAP host",
@@ -1513,35 +1516,35 @@
       "subHappSubInfoButtonLink": "横幅按钮链接",
       "subHappSubInfoButtonLinkDesc": "点击横幅按钮时打开的目标网址或 DeepLink。",
       "subHappSubExpire": "订阅到期提醒横幅",
-      "subHappSubExpireDesc": "当用户订阅即将到期或已过期时在 Happ 中显示续费提示横幅。",
+      "subHappSubExpireDesc": "当用户流量用尽或订阅已过期时,在 Happ 中显示订阅过期横幅。",
       "subHappSubExpireButtonLink": "续费链接",
       "subHappSubExpireButtonLinkDesc": "到期横幅中点击「续费」按钮时跳转的支付或购买页面。",
       "subHappNotificationExpire": "订阅到期推送提醒",
-      "subHappNotificationExpireDesc": "在订阅到期前 3 天向用户发送每日一次的客户端到期提醒。",
-      "subHappNoLimit": "解除规则数量限制 (No Limit)",
-      "subHappNoLimitDesc": "提升内核内存上限,允许应用超出移动端默认数量的复杂路由规则。",
+      "subHappNotificationExpireDesc": "让 Happ 在订阅到期前 3 天提醒用户。",
+      "subHappNoLimit": "No-Limit 模式",
+      "subHappNoLimitDesc": "提高 Happ 中 xray-core 的内存上限,以提升稳定性与性能(测试版)。",
       "subHappAlwaysHwid": "强制绑定硬件标识 (HWID)",
-      "subHappAlwaysHwidDesc": "禁止客户端关闭硬件标识符上报,强化多设备防盗刷安全。",
+      "subHappAlwaysHwidDesc": "禁止用户在 Happ 设置中关闭 HWID 上报。",
       "subHappTunMode": "TUN 运行模式",
-      "subHappTunModeDesc": "TUN 网络接口栈:system (系统网络栈) 或 gvisor (用户态协议栈)。",
+      "subHappTunModeDesc": "桌面端 TUN 使用的网络栈:system (系统网络栈) 或 gVisor (用户态协议栈)。",
       "subHappTunType": "TUN 隧道内核 (TUN Type)",
-      "subHappTunTypeDesc": "隧道实现引擎:singbox、tun2proxy、default (Happ 原生) 或 xray。",
+      "subHappTunTypeDesc": "桌面端 TUN 连接使用的内核:sing-box、tun2proxy、默认 (Happ TUN) 或 Xray。",
       "subHappExcludeRoutes": "排除直连网段 (CIDR)",
-      "subHappExcludeRoutesDesc": "逗号分隔的 IP/CIDR 地址段,此网段流量不走 VPN 隧道直连。",
+      "subHappExcludeRoutesDesc": "逗号分隔的 IP/CIDR 网段(例如 192.168.0.0/16, 10.0.0.0/8),这些网段的流量不经过 VPN 隧道。",
       "subHappExcludeApns": "排除苹果推送服务 (APNs)",
       "subHappExcludeApnsDesc": "让 Apple APNs 流量直连,保障 iOS 设备在后台稳定接收推送通知。",
       "subHappColorProfile": "客户端外观主题",
-      "subHappColorProfileDesc": "Happ 界面配色主题:violet、turquoise、cyberpunk 或自定义 JSON 调色板。",
+      "subHappColorProfileDesc": "以 JSON 字符串自定义 iOS 配色主题,或填写 resetcolors 恢复默认颜色。",
       "subHappPingType": "延迟测速协议",
-      "subHappPingTypeDesc": "节点测速协议:via Proxy (GET)、via Proxy (HEAD)、TCP 握手或 ICMP。",
+      "subHappPingTypeDesc": "Happ 测量节点延迟的方式:经由代理 (GET 或 HEAD)、TCP 或 ICMP。",
       "subHappAutoConnect": "启动时自动连接",
       "subHappAutoConnectDesc": "Happ 客户端打开时自动连接代理节点。",
       "subHappAutoConnectType": "自动连接策略",
-      "subHappAutoConnectTypeDesc": "连接目标选择:lowestdelay (最低延迟)、lastused (上次使用) 或 random。",
+      "subHappAutoConnectTypeDesc": "自动连接选择的节点:最低延迟、上次使用或随机节点。",
       "subHappPerAppMode": "Android 分应用代理",
-      "subHappPerAppModeDesc": "分应用代理模式:off (关闭)、on (仅代理选定应用) 或 bypass (绕过选定应用)。",
+      "subHappPerAppModeDesc": "Android 分应用路由:关闭、开启 (仅代理列表中的应用) 或绕过 (排除列表中的应用)。",
       "subHappPerAppList": "Android 应用包名列表",
-      "subHappPerAppListDesc": "以逗号分隔的应用包名(例如 org.telegram.messenger, com.google.android.youtube)。",
+      "subHappPerAppListDesc": "以逗号分隔的 Android 应用包名,用于包含或排除(例如 org.telegram.messenger)。",
       "subHappPresetIran": "伊朗直连规则 (Iran Bypass)",
       "subHappPresetChina": "大陆直连规则 (China Direct)",
       "subHappPresetAdblock": "广告拦截规则 (AdBlock)",
@@ -1587,8 +1590,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 +2416,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 <email>` 或 `/usage <email>`",
+      "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": "出站",

+ 184 - 83
internal/web/translation/zh-TW.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "透過出站下載(可選)",
       "geodataFile": "檔案名稱",
       "geodataAddFile": "新增檔案",
+      "geodataUseStandardSources": "使用標準來源",
       "geodataSaveRestart": "儲存並重啟 Xray",
       "geodataConfirmTitle": "儲存 geodata 設定?",
       "geodataConfirmContent": "將更新 Xray 設定範本並重啟 Xray。",
@@ -1253,6 +1254,8 @@
       "noisesSett": "Noises 設定",
       "trustedProxyCidrs": "信任代理 CIDR",
       "trustedProxyCidrsDesc": "允許設定轉發 host、proto 與客戶端 IP 標頭的 IP/CIDR(逗號分隔)。",
+      "realityScanCandidates": "Reality 掃描候選清單",
+      "realityScanCandidatesDesc": "以逗號分隔的 host:port 目標(或 CIDR),作為「尋找目標」在搜尋框為空時的預設清單。可依常用目標自訂。",
       "ldap": {
         "enable": "啟用 LDAP 同步",
         "host": "LDAP host",
@@ -1496,99 +1499,122 @@
       "subExpiredTemplateDesc": "使用者訂閱過期時提示配置的備註範本。",
       "subTrafficDepletedTemplate": "流量耗盡範本",
       "subTrafficDepletedTemplateDesc": "使用者訂閱流量用盡時提示配置的備註範本。",
-      "subHappAutoDetect": "Happ 客户端请求头自动识别",
-      "subHappAutoDetectDesc": "当客户端 User-Agent 包含 Happ 时自动注入专属路由规则与响应头。",
-      "subHappProviderId": "服务提供商标识 (Provider ID)",
-      "subHappProviderIdDesc": "Happ 客户端管理、远程配置绑定与订阅迁移所需的唯一标识符。",
-      "subHappNewUrl": "新订阅地址 (迁移)",
-      "subHappNewUrlDesc": "自动迁移的新订阅 URL。客户端收到后会自动将订阅地址切换为此链接。",
-      "subHappFallbackUrl": "备用订阅地址 (Fallback)",
-      "subHappFallbackUrlDesc": "主订阅服务器不可用时 Happ 自动切换的备份订阅链接。",
-      "subHappSubInfoText": "横幅公告文本",
-      "subHappSubInfoTextDesc": "显示在 Happ 客户端顶部的自定义通知横幅(最多 200 字符)。",
-      "subHappSubInfoColor": "横幅配色主题",
-      "subHappSubInfoColorDesc": "横幅主题颜色:blue (默认蓝色)、green (绿色)、red (红色警告)。",
-      "subHappSubInfoButtonText": "横幅按钮文字",
-      "subHappSubInfoButtonTextDesc": "通知横幅内的操作按钮标题(最多 25 字符)。",
-      "subHappSubInfoButtonLink": "横幅按钮链接",
-      "subHappSubInfoButtonLinkDesc": "点击横幅按钮时打开的目标网址或 DeepLink。",
-      "subHappSubExpire": "订阅到期提醒横幅",
-      "subHappSubExpireDesc": "当用户订阅即将到期或已过期时在 Happ 中显示续费提示横幅。",
-      "subHappSubExpireButtonLink": "续费链接",
-      "subHappSubExpireButtonLinkDesc": "到期横幅中点击「续费」按钮时跳转的支付或购买页面。",
-      "subHappNotificationExpire": "订阅到期推送提醒",
-      "subHappNotificationExpireDesc": "在订阅到期前 3 天向用户发送每日一次的客户端到期提醒。",
-      "subHappNoLimit": "解除规则数量限制 (No Limit)",
-      "subHappNoLimitDesc": "提升内核内存上限,允许应用超出移动端默认数量的复杂路由规则。",
-      "subHappAlwaysHwid": "强制绑定硬件标识 (HWID)",
-      "subHappAlwaysHwidDesc": "禁止客户端关闭硬件标识符上报,强化多设备防盗刷安全。",
-      "subHappTunMode": "TUN 运行模式",
-      "subHappTunModeDesc": "TUN 网络接口栈:system (系统网络栈) 或 gvisor (用户态协议栈)。",
-      "subHappTunType": "TUN 隧道内核 (TUN Type)",
-      "subHappTunTypeDesc": "隧道实现引擎:singbox、tun2proxy、default (Happ 原生) 或 xray。",
-      "subHappExcludeRoutes": "排除直连网段 (CIDR)",
-      "subHappExcludeRoutesDesc": "逗号分隔的 IP/CIDR 地址段,此网段流量不走 VPN 隧道直连。",
-      "subHappExcludeApns": "排除苹果推送服务 (APNs)",
-      "subHappExcludeApnsDesc": "让 Apple APNs 流量直连,保障 iOS 设备在后台稳定接收推送通知。",
-      "subHappColorProfile": "客户端外观主题",
-      "subHappColorProfileDesc": "Happ 界面配色主题:violet、turquoise、cyberpunk 或自定义 JSON 调色板。",
-      "subHappPingType": "延迟测速协议",
-      "subHappPingTypeDesc": "节点测速协议:via Proxy (GET)、via Proxy (HEAD)、TCP 握手或 ICMP。",
-      "subHappAutoConnect": "启动时自动连接",
-      "subHappAutoConnectDesc": "Happ 客户端打开时自动连接代理节点。",
-      "subHappAutoConnectType": "自动连接策略",
-      "subHappAutoConnectTypeDesc": "连接目标选择:lowestdelay (最低延迟)、lastused (上次使用) 或 random。",
-      "subHappPerAppMode": "Android 分应用代理",
-      "subHappPerAppModeDesc": "分应用代理模式:off (关闭)、on (仅代理选定应用) 或 bypass (绕过选定应用)。",
-      "subHappPerAppList": "Android 应用包名列表",
-      "subHappPerAppListDesc": "以逗号分隔的应用包名(例如 org.telegram.messenger, com.google.android.youtube)。",
-      "subHappPresetIran": "伊朗直连规则 (Iran Bypass)",
-      "subHappPresetChina": "大陆直连规则 (China Direct)",
-      "subHappPresetAdblock": "广告拦截规则 (AdBlock)",
-      "subHappPresetGlobal": "全局代理规则 (Global)",
-      "subHappPresetOff": "停用路由 (happ://routing/off)",
-      "subHappColorBlue": "藍色 (標準 / 預設)",
-      "subHappColorGreen": "綠色 (成功)",
-      "subHappColorRed": "紅色 (警告 / 危險)",
+      "subHappAutoDetect": "Happ 標頭自動識別",
+      "subHappAutoDetectDesc": "當用戶端 User-Agent 顯示為 Happ 時,自動注入 Happ 路由與標頭。",
+      "subHappProviderId": "提供者 ID",
+      "subHappProviderIdDesc": "用於 Happ 用戶端管理、遠端設定綁定與遷移的唯一提供者識別碼。",
+      "subHappNewUrl": "新訂閱 URL",
+      "subHappNewUrlDesc": "用戶端自動遷移的目標 URL。設定後,Happ 用戶端將遷移至此訂閱連結。",
+      "subHappFallbackUrl": "備用訂閱 URL",
+      "subHappFallbackUrlDesc": "主要訂閱 URL 無法連線時,Happ 使用的備用訂閱位址。",
+      "subHappSubInfoText": "橫幅公告文字",
+      "subHappSubInfoTextDesc": "顯示在 Happ 用戶端頂部的自訂公告橫幅(最多 200 個字元)。",
+      "subHappSubInfoColor": "橫幅強調色",
+      "subHappSubInfoColorDesc": "公告橫幅的顏色主題樣式。",
+      "subHappSubInfoButtonText": "橫幅按鈕文字",
+      "subHappSubInfoButtonTextDesc": "公告橫幅內顯示的按鈕文字(最多 25 個字元)。",
+      "subHappSubInfoButtonLink": "橫幅按鈕連結",
+      "subHappSubInfoButtonLinkDesc": "使用者點擊橫幅操作按鈕時開啟的目標 URL。",
+      "subHappSubExpire": "訂閱過期橫幅",
+      "subHappSubExpireDesc": "當使用者的流量用盡或有效期結束時,在 Happ 中顯示訂閱過期橫幅。",
+      "subHappSubExpireButtonLink": "續訂連結",
+      "subHappSubExpireButtonLinkDesc": "使用者在已過期訂閱上點擊續訂按鈕時開啟的目標 URL。",
+      "subHappNotificationExpire": "到期通知",
+      "subHappNotificationExpireDesc": "指示 Happ 在訂閱到期前 3 天提醒使用者。",
+      "subHappNoLimit": "無限制模式",
+      "subHappNoLimitDesc": "提高 Happ 中 xray-core 的記憶體上限,以提升穩定性與效能(測試版)。",
+      "subHappAlwaysHwid": "強制啟用硬體識別碼(HWID)",
+      "subHappAlwaysHwidDesc": "禁止使用者在 Happ 設定中關閉 HWID 傳送。",
+      "subHappTunMode": "TUN 模式",
+      "subHappTunModeDesc": "桌面版 TUN 使用的網路堆疊:系統(作業系統堆疊)或 gVisor(使用者空間堆疊)。",
+      "subHappTunType": "TUN 引擎",
+      "subHappTunTypeDesc": "桌面版 TUN 連線使用的核心:sing-box、tun2proxy、預設(Happ TUN)或 Xray。",
+      "subHappExcludeRoutes": "排除 CIDR 路由",
+      "subHappExcludeRoutesDesc": "以逗號分隔、要繞過 VPN 通道的 IP CIDR(例如 192.168.0.0/16, 10.0.0.0/8)。",
+      "subHappExcludeApns": "排除 Apple APNs",
+      "subHappExcludeApnsDesc": "讓 Apple 推播通知服務繞過 VPN,確保 iOS 在背景穩定接收通知。",
+      "subHappColorProfile": "用戶端顏色主題",
+      "subHappColorProfileDesc": "以 JSON 字串自訂 iOS 顏色主題,或填入 resetcolors 以還原預設顏色。",
+      "subHappPingType": "延遲測試方式",
+      "subHappPingTypeDesc": "Happ 測量節點延遲的方式:經由代理(GET 或 HEAD)、TCP 或 ICMP。",
+      "subHappAutoConnect": "啟動時自動連線",
+      "subHappAutoConnectDesc": "指示 Happ 在應用程式啟動時自動連線至 VPN。",
+      "subHappAutoConnectType": "自動連線目標",
+      "subHappAutoConnectTypeDesc": "自動連線時選擇的伺服器:最低延遲、最後使用或隨機節點。",
+      "subHappPerAppMode": "Android 個別應用程式代理模式",
+      "subHappPerAppModeDesc": "控制 Android 應用程式路由:關閉、開啟(僅代理清單中的應用程式)或繞過(排除清單中的應用程式)。",
+      "subHappPerAppList": "Android 套件名稱",
+      "subHappPerAppListDesc": "要納入或排除的 Android 應用程式套件名稱,以逗號分隔(例如 org.telegram.messenger)。",
+      "subHappPresetIran": "伊朗直",
+      "subHappPresetChina": "中國直連",
+      "subHappPresetAdblock": "廣告封鎖(AdBlock)",
+      "subHappPresetGlobal": "全域代理",
+      "subHappPresetOff": "停用路由(happ://routing/off)",
+      "subHappColorBlue": "藍色(標準 / 預設)",
+      "subHappColorGreen": "綠色(成功)",
+      "subHappColorRed": "紅色(警告 / 危險)",
       "subHappTunModeDefault": "預設",
-      "subHappTunModeSystem": "系統 (標準作業系統協議棧)",
-      "subHappTunModeGvisor": "gVisor (使用者空間協議棧)",
+      "subHappTunModeSystem": "系統(標準作業系統堆疊)",
+      "subHappTunModeGvisor": "gVisor(使用者空間堆疊)",
       "subHappTunTypeSingbox": "sing-box",
       "subHappTunTypeTun2proxy": "tun2proxy",
-      "subHappTunTypeDefault": "預設 (Happ TUN)",
+      "subHappTunTypeDefault": "預設(Happ TUN)",
       "subHappTunTypeXray": "Xray TUN",
-      "subHappPingProxy": "經由代理 (GET 延遲)",
-      "subHappPingProxyHead": "經由代理 (HEAD 延遲)",
-      "subHappPingTcp": "TCP 握 Ping",
+      "subHappPingProxy": "經由代理(GET 延遲)",
+      "subHappPingProxyHead": "經由代理(HEAD 延遲)",
+      "subHappPingTcp": "TCP 握 Ping",
       "subHappPingIcmp": "ICMP Ping",
-      "subHappAutoConnectLowestDelay": "最低延遲 (最快節點)",
+      "subHappAutoConnectLowestDelay": "最低延遲(最快節點)",
       "subHappAutoConnectLastUsed": "最後使用的節點",
       "subHappAutoConnectRandom": "隨機節點",
       "subHappPerAppOff": "關閉",
-      "subHappPerAppOn": "開啟 (僅代理列表中的應用)",
-      "subHappPerAppBypass": "繞過 (排除列表中的應用)",
-      "subHappPresetApplied": "Happ 预设规则已应用",
-      "subHappPresets": "预设路由规则",
-      "subHappPresetsDesc": "专为 Happ 客户端预设调优的常用分流规则。",
-      "subHappVisualBuilder": "可视化规则生成器",
-      "subHappVisualBuilderDesc": "通过域名与 IP 列表快速生成自定义 Happ 路由 DeepLink。",
-      "subHappBuildDeeplink": "生成 DeepLink",
-      "subHappModalTitle": "Happ 路由规则可视化生成器",
-      "subHappDirectDomains": "直连域名 (Direct)",
-      "subHappProxyDomains": "代理域名 (Proxy)",
-      "subHappBlockDomains": "阻止域名 (Block)",
-      "subHappDirectIPs": "直 IP / CIDR",
+      "subHappPerAppOn": "開啟(僅代理清單中的應用程式)",
+      "subHappPerAppBypass": "繞過(排除清單中的應用程式)",
+      "subHappPresetApplied": "已將 Happ 範本套用至路由規則",
+      "subHappPresets": "路由範本",
+      "subHappPresetsDesc": "為 Happ 用戶端量身打造的預先設定路由規則範本。",
+      "subHappVisualBuilder": "視覺化規則產生器",
+      "subHappVisualBuilderDesc": "從網域與 IP 清單建立自訂路由深層連結。",
+      "subHappBuildDeeplink": "產生深層連結",
+      "subHappModalTitle": "Happ 視覺化路由規則產生器",
+      "subHappDirectDomains": "直連網域(繞過)",
+      "subHappProxyDomains": "代理網域(通道)",
+      "subHappBlockDomains": "封鎖網域(廣告 / 惡意軟體)",
+      "subHappDirectIPs": "直 IP / CIDR",
       "subHappProxyIPs": "代理 IP / CIDR",
-      "subHappBlockIPs": "阻止 IP / CIDR",
-      "subHappDeeplinkGenerated": "DeepLink 已生成并填入路由规则",
+      "subHappBlockIPs": "封鎖 IP / CIDR",
+      "subHappDeeplinkGenerated": "已產生深層連結並套用至路由規則",
       "subHappGroupLinks": "訂閱連結",
-      "subHappGroupRouting": "路由分流与规则",
-      "subHappGroupBanners": "横幅公告与通知",
-      "subHappGroupNetwork": "网络与 TUN 引擎",
-      "subHappGroupThemes": "界面与主题外观",
-      "subHappGroupFailover": "迁移与客户端管理",
-      "subHappGroupAndroid": "Android 分应用代理"
-
+      "subHappGroupRouting": "路由與規則",
+      "subHappGroupBanners": "橫幅與公告",
+      "subHappGroupNetwork": "網路與 TUN 引擎",
+      "subHappGroupThemes": "外觀與主題",
+      "subHappGroupFailover": "遷移與應用程式管理",
+      "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 +2416,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 <email>` 或 `/usage <email>`",
+      "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": "出站",

+ 89 - 2
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()