feat: add telegram bot (#71)

This commit is contained in:
Buer
2024-02-23 18:24:25 +08:00
committed by GitHub
parent 43b4ee37d9
commit e90f4c99fc
33 changed files with 1726 additions and 29 deletions

View File

@@ -9,7 +9,8 @@ import {
IconGardenCart,
IconUser,
IconUserScan,
IconActivity
IconActivity,
IconBrandTelegram
} from '@tabler/icons-react';
// constant
@@ -23,7 +24,8 @@ const icons = {
IconGardenCart,
IconUser,
IconUserScan,
IconActivity
IconActivity,
IconBrandTelegram
};
// ==============================|| DASHBOARD MENU ITEMS ||============================== //
@@ -118,6 +120,15 @@ const panel = {
icon: icons.IconAdjustments,
breadcrumbs: false,
isAdmin: true
},
{
id: 'telegram',
title: 'Telegram Bot',
type: 'item',
url: '/panel/telegram',
icon: icons.IconBrandTelegram,
breadcrumbs: false,
isAdmin: true
}
]
};

View File

@@ -14,6 +14,7 @@ const User = Loadable(lazy(() => import('views/User')));
const Profile = Loadable(lazy(() => import('views/Profile')));
const NotFoundView = Loadable(lazy(() => import('views/Error')));
const Analytics = Loadable(lazy(() => import('views/Analytics')));
const Telegram = Loadable(lazy(() => import('views/Telegram')));
// dashboard routing
const Dashboard = Loadable(lazy(() => import('views/Dashboard')));
@@ -71,6 +72,10 @@ const MainRoutes = {
{
path: '404',
element: <NotFoundView />
},
{
path: 'telegram',
element: <Telegram />
}
]
};

View File

@@ -13,6 +13,7 @@ const ResetPassword = Loadable(lazy(() => import('views/Authentication/Auth/Rese
const Home = Loadable(lazy(() => import('views/Home')));
const About = Loadable(lazy(() => import('views/About')));
const NotFoundView = Loadable(lazy(() => import('views/Error')));
const Jump = Loadable(lazy(() => import('views/Jump')));
// ==============================|| AUTHENTICATION ROUTING ||============================== //
@@ -51,6 +52,10 @@ const OtherRoutes = {
{
path: '/404',
element: <NotFoundView />
},
{
path: '/jump',
element: <Jump />
}
]
};

View File

@@ -0,0 +1,16 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export default function Jump() {
const location = useLocation();
useEffect(() => {
const params = new URLSearchParams(location.search);
const jump = params.get('url');
if (jump) {
window.location.href = jump;
}
}, [location]);
return <div>正在跳转中...</div>;
}

View File

@@ -12,11 +12,13 @@ import {
DialogTitle,
DialogContent,
DialogActions,
Divider
Divider,
Chip,
Typography
} from '@mui/material';
import Grid from '@mui/material/Unstable_Grid2';
import SubCard from 'ui-component/cards/SubCard';
import { IconBrandWechat, IconBrandGithub, IconMail } from '@tabler/icons-react';
import { IconBrandWechat, IconBrandGithub, IconMail, IconBrandTelegram } from '@tabler/icons-react';
import Label from 'ui-component/Label';
import { API } from 'utils/api';
import { showError, showSuccess, onGitHubOAuthClicked, copy } from 'utils/common';
@@ -141,6 +143,9 @@ export default function Profile() {
<Label variant="ghost" color={inputs.email ? 'primary' : 'default'}>
<IconMail /> {inputs.email || '未绑定'}
</Label>
<Label variant="ghost" color={inputs.telegram_id ? 'primary' : 'default'}>
<IconBrandTelegram /> {inputs.telegram_id || '未绑定'}
</Label>
</Stack>
<SubCard title="个人信息">
<Grid container spacing={2}>
@@ -209,6 +214,7 @@ export default function Profile() {
</Button>
</Grid>
)}
<Grid xs={12} md={4}>
<Button
variant="contained"
@@ -229,6 +235,35 @@ export default function Profile() {
<></>
)}
</Grid>
{status.telegram_bot && ( //&& !inputs.telegram_id
<Grid xs={12} md={12}>
<Stack spacing={2}>
<Divider />
<Alert severity="info">
<Typography variant="h3">Telegram 机器人</Typography>
<br />
<Typography variant="body1">
1. 点击下方按钮将会在 Telegram 中打开 机器人点击 /start 开始
<br />
<Chip
icon={<IconBrandTelegram />}
label={'@' + status.telegram_bot}
color="primary"
variant="outlined"
size="small"
onClick={() => window.open('https://t.me/' + status.telegram_bot, '_blank')}
/>
<br />
<br />
2. 向机器人发送/bind命令后输入下方的访问令牌即可绑定(如果没有生成请点击下方按钮生成)
</Typography>
</Alert>
{/* <Typography variant=""> */}
</Stack>
</Grid>
)}
</Grid>
</SubCard>
<SubCard title="其他">

View File

@@ -0,0 +1,225 @@
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { Formik } from 'formik';
import { useTheme } from '@mui/material/styles';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Divider,
FormControl,
InputLabel,
OutlinedInput,
FormHelperText,
Select,
MenuItem,
TextField
} from '@mui/material';
import { showSuccess, showError } from 'utils/common';
import { API } from 'utils/api';
const validationSchema = Yup.object().shape({
is_edit: Yup.boolean(),
command: Yup.string().required('命令 不能为空'),
description: Yup.string().required('说明 不能为空'),
parse_mode: Yup.string().required('消息类型 不能为空'),
reply_message: Yup.string().required('消息内容 不能为空')
});
const originInputs = {
command: '',
description: '',
parse_mode: 'MarkdownV2',
reply_message: ''
};
const EditModal = ({ open, actionId, onCancel, onOk }) => {
const theme = useTheme();
const [inputs, setInputs] = useState(originInputs);
const submit = async (values, { setErrors, setStatus, setSubmitting }) => {
setSubmitting(true);
let res;
try {
if (values.is_edit) {
res = await API.post(`/api/option/telegram/`, { ...values, id: parseInt(actionId) });
} else {
res = await API.post(`/api/option/telegram/`, values);
}
const { success, message } = res.data;
if (success) {
if (values.is_edit) {
showSuccess('菜单更新成功!');
} else {
showSuccess('菜单创建成功!');
}
setSubmitting(false);
setStatus({ success: true });
onOk(true);
} else {
showError(message);
setErrors({ submit: message });
}
} catch (error) {
return;
}
};
const load = async () => {
try {
let res = await API.get(`/api/option/telegram/${actionId}`);
const { success, message, data } = res.data;
if (success) {
data.is_edit = true;
setInputs(data);
} else {
showError(message);
}
} catch (error) {
return;
}
};
useEffect(() => {
if (actionId) {
load().then();
} else {
setInputs(originInputs);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionId]);
return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>
<DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>
{actionId ? '编辑菜单' : '新建菜单'}
</DialogTitle>
<Divider />
<DialogContent>
<Formik initialValues={inputs} enableReinitialize validationSchema={validationSchema} onSubmit={submit}>
{({ errors, handleBlur, handleChange, handleSubmit, touched, values, isSubmitting }) => (
<form noValidate onSubmit={handleSubmit}>
<FormControl fullWidth error={Boolean(touched.command && errors.command)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-command-label">命令</InputLabel>
<OutlinedInput
id="channel-command-label"
label="命令"
type="text"
value={values.command}
name="command"
onBlur={handleBlur}
onChange={handleChange}
inputProps={{ autoComplete: 'command' }}
aria-describedby="helper-text-channel-command-label"
/>
{touched.command && errors.command && (
<FormHelperText error id="helper-tex-channel-command-label">
{errors.command}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.description && errors.description)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-description-label">说明</InputLabel>
<OutlinedInput
id="channel-description-label"
label="说明"
type="text"
value={values.description}
name="description"
onBlur={handleBlur}
onChange={handleChange}
inputProps={{ autoComplete: 'description' }}
aria-describedby="helper-text-channel-description-label"
/>
{touched.description && errors.description && (
<FormHelperText error id="helper-tex-channel-description-label">
{errors.description}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.parse_mode && errors.parse_mode)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-parse_mode-label">消息类型</InputLabel>
<Select
id="channel-parse_mode-label"
label="消息类型"
value={values.parse_mode}
name="parse_mode"
onBlur={handleBlur}
onChange={handleChange}
MenuProps={{
PaperProps: {
style: {
maxHeight: 200
}
}
}}
>
<MenuItem key="MarkdownV2" value="MarkdownV2">
{' '}
MarkdownV2{' '}
</MenuItem>
<MenuItem key="Markdown" value="Markdown">
{' '}
Markdown{' '}
</MenuItem>
<MenuItem key="html" value="html">
{' '}
html{' '}
</MenuItem>
</Select>
{touched.parse_mode && errors.parse_mode && (
<FormHelperText error id="helper-tex-channel-parse_mode-label">
{errors.parse_mode}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.reply_message && errors.reply_message)} sx={{ ...theme.typography.otherInput }}>
<TextField
multiline
id="channel-reply_message-label"
label="消息内容"
value={values.reply_message}
name="reply_message"
onBlur={handleBlur}
onChange={handleChange}
aria-describedby="helper-text-channel-reply_message-label"
minRows={5}
placeholder="消息内容"
/>
{touched.reply_message && errors.reply_message && (
<FormHelperText error id="helper-tex-channel-reply_message-label">
{errors.reply_message}
</FormHelperText>
)}
</FormControl>
<DialogActions>
<Button onClick={onCancel}>取消</Button>
<Button disableElevation disabled={isSubmitting} type="submit" variant="contained" color="primary">
提交
</Button>
</DialogActions>
</form>
)}
</Formik>
</DialogContent>
</Dialog>
);
};
export default EditModal;
EditModal.propTypes = {
open: PropTypes.bool,
actionId: PropTypes.number,
onCancel: PropTypes.func,
onOk: PropTypes.func
};

View File

@@ -0,0 +1,114 @@
import PropTypes from 'prop-types';
import { useState } from 'react';
import {
Popover,
TableRow,
MenuItem,
TableCell,
IconButton,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Button,
Stack
} from '@mui/material';
import { IconDotsVertical, IconEdit, IconTrash } from '@tabler/icons-react';
export default function TelegramTableRow({ item, manageAction, handleOpenModal, setModalId }) {
const [open, setOpen] = useState(null);
const [openDelete, setOpenDelete] = useState(false);
const handleDeleteOpen = () => {
handleCloseMenu();
setOpenDelete(true);
};
const handleDeleteClose = () => {
setOpenDelete(false);
};
const handleOpenMenu = (event) => {
setOpen(event.currentTarget);
};
const handleCloseMenu = () => {
setOpen(null);
};
const handleDelete = async () => {
handleCloseMenu();
await manageAction(item.id, 'delete');
};
return (
<>
<TableRow tabIndex={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{item.command}</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.parse_mode}</TableCell>
<TableCell>{item.reply_message}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
<IconButton onClick={handleOpenMenu} sx={{ color: 'rgb(99, 115, 129)' }}>
<IconDotsVertical />
</IconButton>
</Stack>
</TableCell>
</TableRow>
<Popover
open={!!open}
anchorEl={open}
onClose={handleCloseMenu}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: { width: 140 }
}}
>
<MenuItem
onClick={() => {
handleCloseMenu();
handleOpenModal();
setModalId(item.id);
}}
>
<IconEdit style={{ marginRight: '16px' }} />
编辑
</MenuItem>
<MenuItem onClick={handleDeleteOpen} sx={{ color: 'error.main' }}>
<IconTrash style={{ marginRight: '16px' }} />
删除
</MenuItem>
</Popover>
<Dialog open={openDelete} onClose={handleDeleteClose}>
<DialogTitle>删除菜单</DialogTitle>
<DialogContent>
<DialogContentText>是否删除菜单 {item.name}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteClose}>关闭</Button>
<Button onClick={handleDelete} sx={{ color: 'error.main' }} autoFocus>
删除
</Button>
</DialogActions>
</Dialog>
</>
);
}
TelegramTableRow.propTypes = {
item: PropTypes.object,
manageAction: PropTypes.func,
handleOpenModal: PropTypes.func,
setModalId: PropTypes.func
};

View File

@@ -0,0 +1,270 @@
import { useState, useEffect } from 'react';
import { showError, showSuccess } from 'utils/common';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import PerfectScrollbar from 'react-perfect-scrollbar';
import TablePagination from '@mui/material/TablePagination';
import LinearProgress from '@mui/material/LinearProgress';
import ButtonGroup from '@mui/material/ButtonGroup';
import Toolbar from '@mui/material/Toolbar';
import { Button, Card, Box, Stack, Container, Typography, Chip, Alert } from '@mui/material';
import TelegramTableRow from './component/TableRow';
import KeywordTableHead from 'ui-component/TableHead';
import TableToolBar from 'ui-component/TableToolBar';
import { API } from 'utils/api';
import { ITEMS_PER_PAGE } from 'constants';
import { IconRefresh, IconPlus } from '@tabler/icons-react';
import EditeModal from './component/EditModal';
import { IconBrandTelegram, IconReload } from '@tabler/icons-react';
// ----------------------------------------------------------------------
export default function Telegram() {
const [page, setPage] = useState(0);
const [order, setOrder] = useState('desc');
const [orderBy, setOrderBy] = useState('id');
const [rowsPerPage, setRowsPerPage] = useState(ITEMS_PER_PAGE);
const [listCount, setListCount] = useState(0);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [telegramMenus, setTelegramMenus] = useState([]);
const [refreshFlag, setRefreshFlag] = useState(false);
let [status, setStatus] = useState(false);
let [isWebhook, setIsWebhook] = useState(false);
const [openModal, setOpenModal] = useState(false);
const [editTelegramMenusId, setEditTelegramMenusId] = useState(0);
const handleSort = (event, id) => {
const isAsc = orderBy === id && order === 'asc';
if (id !== '') {
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(id);
}
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setPage(0);
setRowsPerPage(parseInt(event.target.value, 10));
};
const searchMenus = async (event) => {
event.preventDefault();
const formData = new FormData(event.target);
setPage(0);
setSearchKeyword(formData.get('keyword'));
};
const fetchData = async (page, rowsPerPage, keyword, order, orderBy) => {
setSearching(true);
try {
if (orderBy) {
orderBy = order === 'desc' ? '-' + orderBy : orderBy;
}
const res = await API.get(`/api/option/telegram/`, {
params: {
page: page + 1,
size: rowsPerPage,
keyword: keyword,
order: orderBy
}
});
const { success, message, data } = res.data;
if (success) {
setListCount(data.total_count);
setTelegramMenus(data.data);
} else {
showError(message);
}
} catch (error) {
console.error(error);
}
setSearching(false);
};
const reload = async () => {
try {
const res = await API.put('/api/option/telegram/reload');
const { success, message } = res.data;
if (success) {
showSuccess('重载成功!');
} else {
showError(message);
}
} catch (error) {
return;
}
};
const getStatus = async () => {
try {
const res = await API.get('/api/option/telegram/status');
const { success, data } = res.data;
if (success) {
setStatus(data.status);
setIsWebhook(data.is_webhook);
}
} catch (error) {
return;
}
};
// 处理刷新
const handleRefresh = async () => {
setOrderBy('id');
setOrder('desc');
setRefreshFlag(!refreshFlag);
};
useEffect(() => {
fetchData(page, rowsPerPage, searchKeyword, order, orderBy);
}, [page, rowsPerPage, searchKeyword, order, orderBy, refreshFlag]);
useEffect(() => {
getStatus().then();
}, []);
const manageMenus = async (id, action) => {
const url = '/api/option/telegram/';
let res;
try {
switch (action) {
case 'delete':
res = await API.delete(url + id);
break;
}
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
if (action === 'delete') {
await handleRefresh();
}
} else {
showError(message);
}
return res.data;
} catch (error) {
return;
}
};
const handleOpenModal = (id) => {
setEditTelegramMenusId(id);
setOpenModal(true);
};
const handleCloseModal = () => {
setOpenModal(false);
setEditTelegramMenusId(0);
};
const handleOkModal = (status) => {
if (status === true) {
handleCloseModal();
handleRefresh();
}
};
return (
<>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={5}>
<Typography variant="h4">Telegram Bot菜单</Typography>
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
新建
</Button>
</Stack>
<Stack mb={5}>
<Alert severity="info">
添加修改菜单命令/说明后如果没有修改命令和说明可以不用重载需要重新载入菜单才能生效
如果未查看到新菜单请尝试杀后台后重新启动程序
</Alert>
</Stack>
<Stack direction="row" alignItems="center" justifyContent="flex-start" mb={2} spacing={2}>
<Chip
icon={<IconBrandTelegram />}
label={(status ? '在线' : '离线') + (isWebhook ? '(Webhook)' : '(Polling)')}
color={status ? 'primary' : 'error'}
variant="outlined"
size="small"
/>
<Button variant="contained" size="small" endIcon={<IconReload />} onClick={reload}>
重新载入菜单
</Button>
</Stack>
<Card>
<Box component="form" onSubmit={searchMenus} noValidate>
<TableToolBar placeholder={'搜索ID和命令...'} />
</Box>
<Toolbar
sx={{
textAlign: 'right',
height: 50,
display: 'flex',
justifyContent: 'space-between',
p: (theme) => theme.spacing(0, 1, 0, 3)
}}
>
<Container>
<ButtonGroup variant="outlined" aria-label="outlined small primary button group">
<Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>
刷新
</Button>
</ButtonGroup>
</Container>
</Toolbar>
{searching && <LinearProgress />}
<PerfectScrollbar component="div">
<TableContainer sx={{ overflow: 'unset' }}>
<Table sx={{ minWidth: 800 }}>
<KeywordTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleSort}
headLabel={[
{ id: 'id', label: 'ID', disableSort: false },
{ id: 'command', label: '命令', disableSort: false },
{ id: 'description', label: '说明', disableSort: false },
{ id: 'parse_mode', label: '回复类型', disableSort: false },
{ id: 'reply_message', label: '回复内容', disableSort: false },
{ id: 'action', label: '操作', disableSort: true }
]}
/>
<TableBody>
{telegramMenus.map((row) => (
<TelegramTableRow
item={row}
manageAction={manageMenus}
key={row.id}
handleOpenModal={handleOpenModal}
setModalId={setEditTelegramMenusId}
/>
))}
</TableBody>
</Table>
</TableContainer>
</PerfectScrollbar>
<TablePagination
page={page}
component="div"
count={listCount}
rowsPerPage={rowsPerPage}
onPageChange={handleChangePage}
rowsPerPageOptions={[10, 25, 30]}
onRowsPerPageChange={handleChangeRowsPerPage}
showFirstButton
showLastButton
/>
</Card>
<EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} actionId={editTelegramMenusId} />
</>
);
}