mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
feat(discord): add Discord notification bot service (#6486)
* feat(discord): add Discord notification bot service, settings UI, and event subscriber - internal/web/service/discord: implement lightweight Discord REST API v10 client and EventBus subscriber - internal/web/service/setting: add discordBotEnable, discordBotToken, discordChannelId, discordEnabledEvents, discordCpu, discordMemory settings and secret protection - internal/web/controller: register POST /panel/api/setting/testDiscord endpoint - frontend: add Discord settings tab, notifications configuration, sidebar navigation, and command palette integration - translation: add localization keys across all 13 locales - tests: add comprehensive unit tests with httptest server and verify route/i18n contracts * fix(discord): address PR review findings on concurrency, linting, i18n, and stories - subscriber: eliminate unbounded goroutines, sending inline per EventBus contract - discord: accept context.Context in SendMessage, SendEmbed, SendTest with http.NewRequestWithContext - format: apply gofumpt to controller and entity struct alignments - i18n: localize testDiscord controller responses across all 13 locales - storybook: add DiscordNotifications.stories.tsx component story * docs: add Discord bot setup and operations guide - add docs/content/docs/en/operations/discord-bot.mdx with setup steps, event indicators, settings, and troubleshooting - add docs/content/docs/ru/operations/discord-bot.mdx with localized instructions - update operations/meta.json across en, ru, zh, fa - link Discord bot from panel configuration overview * feat(discord): add discordLang, discordRunTime, discordBotBackup settings and update settings UI - internal/web/entity: add DiscordRunTime, DiscordBotBackup, DiscordLang fields to AllSetting - internal/web/service/setting: add defaultValueMap entries, getters, and setters - frontend: update AllSetting schema, model defaults, and generate OpenAPI / Zod contracts - frontend: extract shared NotifyTimeField component and update DiscordTab with General and Notifications tabs - translation: add localization keys across all 13 locales * feat(discord): implement scheduled status reports and database backup attachments - internal/web/service/discord: add SendMessageWithFiles supporting multipart uploads - internal/web/service/discord: implement BuildReport and SendReport generating rich status embeds - internal/web/service/discord: attach database backup (and config.json) when discordBotBackup is enabled - internal/web/job: implement DiscordNotifyJob scheduled via robfig/cron - internal/web/locale: add LocalizerFor and I18nForLang helpers - internal/web/controller: trigger reloadDiscordFunc to dynamically reschedule cron upon setting changes - internal/web/web: register and reschedule DiscordNotifyJob - tests: comprehensive unit tests for multipart uploads, status reporting, and job execution * feat(discord): add interactive bot commands via Gateway WebSocket and update documentation - internal/web/service/discord/gateway: connect to Discord Gateway v10 via WebSocket (gorilla/websocket) - internal/web/service/discord/gateway: handle heartbeat loop, reconnection, and command dispatch - commands: implement !status, !report, !backup, !usage <email>, !inbounds, !restart, !help (with ! and / prefixes) - internal/web/web: start/stop Gateway client with server and reload dynamically on setting updates - docs: update operations guide (en, ru) with scheduled reports, backups, commands, and privileged intents - tests: add end-to-end WebSocket Gateway test verifying command handling * style(discord): fix goimports formatting and add 3x-ui to gitignore * fix(discord): stop gateway panics, reconnect storms and proxy bypass The Gateway client wrote to its websocket from both the heartbeat ticker and the read loop answering server-requested op 1 heartbeats. gorilla panics on concurrent writes and neither goroutine recovers, so a colliding heartbeat took the whole panel process down; writes now share writeMu. It also reconnected every 5s forever after close codes Discord marks non-reconnectable (4004 bad token, 4010-4014, including 4014 when Message Content Intent is off), re-identifying and logging a warning each time. The loop now stops on those codes; the docs say to restart the panel. The gateway dialed with websocket.DefaultDialer, bypassing the panel egress proxy the REST client already uses, so where Discord is filtered notifications arrived but commands never connected. * fix(discord): deliver the scheduled report when the backup upload fails SendReport posted the report embed and the x-ui.db/config.json attachments in one multipart request. Once the database outgrows Discord's upload cap (20 MiB by default) the request is rejected and the report embed is lost with it on every run, leaving only a log warning. Send the embed first and the attachments as a second message. * chore(discord): delete tests that pass whether or not the code works TestDiscordNotifyJob_NilServiceNoPanic and TestHandleEvent_NilDiscordService feed a nil DiscordService that web.go never passes, and TestDiscordNotifyJob_DisabledNoPanic passes with or without the enable guard because Xray is not running under test. * fix(discord): require admin user IDs for bot commands and honor discordLang Any member who could post in the configured channel could run !backup (the whole x-ui.db and config.json, even with discordBotBackup off), !restart and !usage. Commands now run only for the Discord user IDs in the new discordAdminIds setting; an empty list turns commands off. discordLang was saved and offered in the UI, but nothing read it, so every embed stayed English. The test message, alerts, the scheduled report and command replies now render through I18nForLang in the chosen language, with a discord section in all 13 locales. InitLocalizer takes an fs.FS so tests load the real translation files. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -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')}`,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": "",
|
||||
@@ -138,11 +148,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,
|
||||
|
||||
@@ -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"
|
||||
@@ -439,6 +473,16 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"required": [
|
||||
"datepicker",
|
||||
"discordAdminIds",
|
||||
"discordBotBackup",
|
||||
"discordBotEnable",
|
||||
"discordBotToken",
|
||||
"discordChannelId",
|
||||
"discordCpu",
|
||||
"discordEnabledEvents",
|
||||
"discordLang",
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
@@ -580,6 +624,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 +674,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"hasApiToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasDiscordBotToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasLdapPassword": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1035,11 +1116,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",
|
||||
|
||||
@@ -9,6 +9,16 @@ export type transportBits = number;
|
||||
|
||||
export interface AllSetting {
|
||||
datepicker: string;
|
||||
discordAdminIds: string;
|
||||
discordBotBackup: boolean;
|
||||
discordBotEnable: boolean;
|
||||
discordBotToken: string;
|
||||
discordChannelId: string;
|
||||
discordCpu: number;
|
||||
discordEnabledEvents: string;
|
||||
discordLang: string;
|
||||
discordMemory: number;
|
||||
discordRunTime: string;
|
||||
expireDiff: number;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
@@ -146,11 +156,22 @@ export interface AllSetting {
|
||||
|
||||
export interface AllSettingView {
|
||||
datepicker: string;
|
||||
discordAdminIds: string;
|
||||
discordBotBackup: boolean;
|
||||
discordBotEnable: boolean;
|
||||
discordBotToken: string;
|
||||
discordChannelId: string;
|
||||
discordCpu: number;
|
||||
discordEnabledEvents: string;
|
||||
discordLang: string;
|
||||
discordMemory: number;
|
||||
discordRunTime: string;
|
||||
expireDiff: number;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
happLinkEnable: boolean;
|
||||
hasApiToken: boolean;
|
||||
hasDiscordBotToken: boolean;
|
||||
hasLdapPassword: boolean;
|
||||
hasNordSecret: boolean;
|
||||
hasSmtpPassword: boolean;
|
||||
|
||||
@@ -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(),
|
||||
@@ -161,11 +171,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(),
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -147,6 +147,18 @@ export class AllSetting {
|
||||
clearTgBotToken = false;
|
||||
clearLdapPassword = false;
|
||||
clearSmtpPassword = false;
|
||||
discordBotEnable = false;
|
||||
discordBotToken = '';
|
||||
discordChannelId = '';
|
||||
discordAdminIds = '';
|
||||
discordRunTime = '@daily';
|
||||
discordBotBackup = false;
|
||||
discordCpu = 80;
|
||||
discordMemory = 80;
|
||||
discordLang = 'en-US';
|
||||
discordEnabledEvents = 'login.attempt,cpu.high';
|
||||
hasDiscordBotToken = false;
|
||||
clearDiscordBotToken = false;
|
||||
|
||||
constructor(data?: unknown) {
|
||||
if (data != null) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -132,6 +132,17 @@ export const AllSettingSchema = z
|
||||
hasWarpSecret: z.boolean().optional(),
|
||||
hasNordSecret: z.boolean().optional(),
|
||||
hasSmtpPassword: z.boolean().optional(),
|
||||
hasDiscordBotToken: z.boolean().optional(),
|
||||
discordBotEnable: z.boolean().optional(),
|
||||
discordBotToken: z.string().optional(),
|
||||
discordChannelId: z.string().optional(),
|
||||
discordAdminIds: z.string().optional(),
|
||||
discordRunTime: z.string().optional(),
|
||||
discordBotBackup: z.boolean().optional(),
|
||||
discordCpu: z.number().int().min(0).max(100).optional(),
|
||||
discordMemory: z.number().int().min(0).max(100).optional(),
|
||||
discordLang: z.string().optional(),
|
||||
discordEnabledEvents: z.string().optional(),
|
||||
})
|
||||
.loose();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user