同步主分支
2
.github/actions/setup-python/action.yml
vendored
@ -5,7 +5,7 @@ inputs:
|
||||
python-version:
|
||||
description: Python version
|
||||
required: false
|
||||
default: "3.9"
|
||||
default: "3.10"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
|
5
.github/workflows/main.yml
vendored
@ -12,6 +12,7 @@ on:
|
||||
- tests/**
|
||||
- pyproject.toml
|
||||
- poetry.lock
|
||||
- .github/**
|
||||
pull_request:
|
||||
paths:
|
||||
- admin-frontend/**
|
||||
@ -20,6 +21,7 @@ on:
|
||||
- tests/**
|
||||
- pyproject.toml
|
||||
- poetry.lock
|
||||
- .github/**
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@ -47,7 +49,7 @@ jobs:
|
||||
needs: build-frontend
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10"]
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
fail-fast: false
|
||||
env:
|
||||
@ -77,6 +79,7 @@ jobs:
|
||||
- name: Upload coverage report
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
flags: smoke-test
|
||||
env_vars: OS,PYTHON_VERSION
|
||||
|
||||
docker-main:
|
||||
|
1
.gitignore
vendored
@ -133,6 +133,7 @@ ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
pythonenv*
|
||||
venv_test/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
|
@ -20,4 +20,27 @@ repos:
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [markdown, ts, tsx]
|
||||
exclude: 'admin-frontend/'
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: v8.20.0
|
||||
hooks:
|
||||
- id: eslint
|
||||
additional_dependencies:
|
||||
- "eslint@8.2.0"
|
||||
- "@typescript-eslint/eslint-plugin"
|
||||
- "@typescript-eslint/parser"
|
||||
- "eslint-config-airbnb"
|
||||
- "eslint-config-airbnb-typescript"
|
||||
- "eslint-import-resolver-typescript"
|
||||
- "eslint-plugin-import"
|
||||
- "eslint-plugin-jsx-a11y"
|
||||
- "eslint-plugin-react"
|
||||
- "eslint-plugin-react-hooks"
|
||||
- "eslint-plugin-react-redux"
|
||||
types_or: [ts, tsx]
|
||||
types: []
|
||||
files: ^admin-frontend/
|
||||
args: [--fix, -c, './admin-frontend/.eslintrc.json', --rule, 'import/no-unresolved: off']
|
||||
|
||||
exclude: 'CHANGELOG.md'
|
||||
|
35
CHANGELOG.md
@ -2,13 +2,46 @@
|
||||
|
||||
## 最近更新
|
||||
|
||||
- feat (issue #67 ):添加屏蔽特定tag的功能 [@AzideCupric](https://github.com/AzideCupric) ([#101](https://github.com/felinae98/nonebot-bison/pull/101))
|
||||
- 调整调度器 api [@felinae98](https://github.com/felinae98) ([#125](https://github.com/felinae98/nonebot-bison/pull/125))
|
||||
|
||||
## v0.6.1
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复前端 title [@felinae98](https://github.com/felinae98) ([#121](https://github.com/felinae98/nonebot-bison/pull/121))
|
||||
- 使用新的文件来标志 legacy db 已弃用 [@felinae98](https://github.com/felinae98) ([#120](https://github.com/felinae98/nonebot-bison/pull/120))
|
||||
- 修复添加按钮没反应 [@felinae98](https://github.com/felinae98) ([#119](https://github.com/felinae98/nonebot-bison/pull/119))
|
||||
- 修复前端面包屑错误 [@felinae98](https://github.com/felinae98) ([#118](https://github.com/felinae98/nonebot-bison/pull/118))
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### 破坏性更新
|
||||
|
||||
- 弃用 tinydb,使用 sqlite 作为数据库(届时将自动迁移数据库,可能存在失败的情况)
|
||||
- 放弃对 Python3.9 的支持
|
||||
- 重写前端
|
||||
|
||||
### 新功能
|
||||
|
||||
- 使用了新的调度器
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 处理「添加重复订阅」异常 [@felinae98](https://github.com/felinae98) ([#115](https://github.com/felinae98/nonebot-bison/pull/115))
|
||||
|
||||
## v0.5.5
|
||||
|
||||
### 新功能
|
||||
|
||||
- feat (issue #67 ):添加屏蔽特定tag的功能 [@AzideCupric](https://github.com/AzideCupric) ([#101](https://github.com/felinae98/nonebot-bison/pull/101))
|
||||
- feat: 临时解决 bilibili 的反爬机制 [@felinae98](https://github.com/felinae98) ([#110](https://github.com/felinae98/nonebot-bison/pull/110))
|
||||
- 在StatusChange中提供了如果api返回错误不更新status的方法 [@felinae98](https://github.com/felinae98) ([#96](https://github.com/felinae98/nonebot-bison/pull/96))
|
||||
- 添加 CustomPost [@felinae98](https://github.com/felinae98) ([#81](https://github.com/felinae98/nonebot-bison/pull/81))
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- fix: 修复 bilibili-live 中获取状态错误后产生的错误行为 [@felinae98](https://github.com/felinae98) ([#111](https://github.com/felinae98/nonebot-bison/pull/111))
|
||||
|
||||
## v0.5.4
|
||||
|
||||
### 新功能
|
||||
|
39
admin-frontend/.eslintrc.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"jest": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"airbnb"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"react",
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-filename-extension": [ "warn", {"extensions": [".tsx"]} ],
|
||||
"no-use-before-define": "off",
|
||||
"@typescript-eslint/no-use-before-define": ["error"],
|
||||
"import/extensions": ["error", "ignorePackages", {"ts": "never", "tsx": "never"}],
|
||||
"no-param-reassign": ["error", { "props": false }],
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error"]
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), using the [Redux](https://redux.js.org/) and [Redux Toolkit](https://redux-toolkit.js.org/) TS template.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
|
@ -1,36 +1,35 @@
|
||||
{
|
||||
"name": "admin-frontend",
|
||||
"name": "nonebot-bison-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"homepage": "bison",
|
||||
"proxy": "http://127.0.0.1:8080",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^4.6.4",
|
||||
"@reduxjs/toolkit": "^1.7.0",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"antd": "^4.16.13",
|
||||
"axios": "^0.21.4",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^5.3.0",
|
||||
"react-scripts": "^5.0.0",
|
||||
"typescript": "^4.1.2",
|
||||
"web-vitals": "^1.0.1"
|
||||
"@arco-design/web-react": "^2.39.3",
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.0.1",
|
||||
"@testing-library/user-event": "^14.1.1",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/node": "^17.0.25",
|
||||
"@types/react": "^18.0.6",
|
||||
"@types/react-dom": "^18.0.2",
|
||||
"@types/redux-persist": "^4.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.0.1",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"typescript": "^4.6.0",
|
||||
"web-vitals": "^2.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build && cp -r -f build/* ../src/plugins/nonebot_bison/admin_page/dist",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "eslint --fix src/**/*.ts src/**/*.tsx"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
@ -51,10 +50,16 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.175",
|
||||
"@types/react-redux": "^7.1.20",
|
||||
"@types/react-router-dom": "^5.3.0",
|
||||
"react-app-rewired": "^2.1.8",
|
||||
"redux-devtools": "^3.7.0"
|
||||
"@typescript-eslint/eslint-plugin": "^5.31.0",
|
||||
"@typescript-eslint/parser": "^5.31.0",
|
||||
"eslint": "^7.32.0 || ^8.2.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.3.0",
|
||||
"eslint-plugin-import": "^2.25.3",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"eslint-plugin-react-redux": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.5 KiB |
@ -24,7 +24,7 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
<title>Nonebot Bison Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 12 KiB |
@ -9,30 +9,31 @@
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
animation: App-logo-float infinite 3s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
color: rgb(112, 76, 182);
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
@keyframes App-logo-float {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
50% {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,15 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './app/store';
|
||||
import App from './App';
|
||||
|
||||
test("renders learn react link", () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
test('renders learn react link', () => {
|
||||
const { getByText } = render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(getByText(/learn/i)).toBeInTheDocument();
|
||||
});
|
||||
|
@ -1,45 +1,60 @@
|
||||
import "antd/dist/antd.css";
|
||||
import React, { useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
|
||||
import "./App.css";
|
||||
import { Admin } from "./pages/admin";
|
||||
import { Auth } from "./pages/auth";
|
||||
import { getGlobalConf } from "./store/globalConfSlice";
|
||||
import { useAppSelector } from "./store/hooks";
|
||||
import { loadLoginState, loginSelector } from "./store/loginSlice";
|
||||
|
||||
function LoginSwitch() {
|
||||
const login = useSelector(loginSelector);
|
||||
if (login.login) {
|
||||
return <Admin />;
|
||||
} else {
|
||||
return <div>not login</div>;
|
||||
}
|
||||
}
|
||||
import React, { useEffect } from 'react';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import './App.css';
|
||||
import { useAppDispatch, useAppSelector } from './app/hooks';
|
||||
import Auth from './features/auth/Auth';
|
||||
import { loadGlobalConf, selectGlobalConfLoaded } from './features/globalConf/globalConfSlice';
|
||||
import GroupManager from './features/subsribeConfigManager/GroupManager';
|
||||
import SubscribeManager from './features/subsribeConfigManager/SubscribeManager';
|
||||
import WeightConfig from './features/weightConfig/WeightManager';
|
||||
import Home from './pages/Home';
|
||||
import Unauthed from './pages/Unauthed';
|
||||
|
||||
function App() {
|
||||
const dispatch = useDispatch();
|
||||
const globalConf = useAppSelector((state) => state.globalConf);
|
||||
const dispatch = useAppDispatch();
|
||||
const globalConfLoaded = useAppSelector(selectGlobalConfLoaded);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getGlobalConf());
|
||||
dispatch(loadLoginState());
|
||||
}, [dispatch]);
|
||||
if (!globalConfLoaded) {
|
||||
dispatch(loadGlobalConf());
|
||||
}
|
||||
}, [globalConfLoaded]);
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/auth/:code',
|
||||
element: <Auth />,
|
||||
},
|
||||
{
|
||||
path: '/unauthed',
|
||||
element: <Unauthed />,
|
||||
},
|
||||
{
|
||||
path: '/home/',
|
||||
element: <Home />,
|
||||
// loader: homeLoader,
|
||||
children: [
|
||||
{
|
||||
path: 'groups',
|
||||
element: <GroupManager />,
|
||||
},
|
||||
{
|
||||
path: 'groups/:groupNumber',
|
||||
element: <SubscribeManager />,
|
||||
},
|
||||
{
|
||||
path: 'weight',
|
||||
element: <WeightConfig />,
|
||||
},
|
||||
],
|
||||
},
|
||||
], { basename: '/bison' });
|
||||
|
||||
return (
|
||||
<>
|
||||
{globalConf.loaded && (
|
||||
<Router basename="/bison">
|
||||
<Switch>
|
||||
<Route path="/auth/:code">
|
||||
<Auth />
|
||||
</Route>
|
||||
<Route path="/admin/">
|
||||
<LoginSwitch />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
)}
|
||||
</>
|
||||
globalConfLoaded
|
||||
? (
|
||||
<RouterProvider router={router} />
|
||||
) : <div>loading</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,63 +0,0 @@
|
||||
import axios from "axios";
|
||||
import {
|
||||
GlobalConf,
|
||||
TokenResp,
|
||||
SubscribeResp,
|
||||
TargetNameResp,
|
||||
SubscribeConfig,
|
||||
} from "../utils/type";
|
||||
import { baseUrl } from "./utils";
|
||||
|
||||
export async function getGlobalConf(): Promise<GlobalConf> {
|
||||
const res = await axios.get<GlobalConf>(`${baseUrl}global_conf`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function auth(token: string): Promise<TokenResp> {
|
||||
const res = await axios.get<TokenResp>(`${baseUrl}auth`, {
|
||||
params: { token },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getSubscribe(): Promise<SubscribeResp> {
|
||||
const res = await axios.get(`${baseUrl}subs`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getTargetName(
|
||||
platformName: string,
|
||||
target: string
|
||||
): Promise<TargetNameResp> {
|
||||
const res = await axios.get(`${baseUrl}target_name`, {
|
||||
params: { platformName, target },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function addSubscribe(groupNumber: string, req: SubscribeConfig) {
|
||||
const res = await axios.post(`${baseUrl}subs`, req, {
|
||||
params: { groupNumber },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function delSubscribe(
|
||||
groupNumber: string,
|
||||
platformName: string,
|
||||
target: string
|
||||
) {
|
||||
const res = await axios.delete(`${baseUrl}subs`, {
|
||||
params: { groupNumber, platformName, target },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateSubscribe(
|
||||
groupNumber: string,
|
||||
req: SubscribeConfig
|
||||
) {
|
||||
return axios
|
||||
.patch(`${baseUrl}subs`, req, { params: { groupNumber } })
|
||||
.then((res) => res.data);
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { Store } from "src/store";
|
||||
import { clearLoginStatus } from "src/store/loginSlice";
|
||||
// import { useContext } from 'react';
|
||||
// import { LoginContext } from "../utils/context";
|
||||
|
||||
export const baseUrl = "/bison/api/";
|
||||
let store: Store;
|
||||
export const injectStore = (_store: Store) => {
|
||||
store = _store;
|
||||
};
|
||||
|
||||
// const loginStatus = useContext(LoginContext);
|
||||
axios.interceptors.request.use(
|
||||
function (config) {
|
||||
if (
|
||||
config.url &&
|
||||
config.url.startsWith(baseUrl) &&
|
||||
config.url !== `${baseUrl}auth` &&
|
||||
config.url !== `${baseUrl}global_conf`
|
||||
) {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
} else {
|
||||
throw new axios.Cancel("User not login");
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
function (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
function (response) {
|
||||
// const data = response.data;
|
||||
// const parseToMap = (item: any): any => {
|
||||
// if (item instanceof Array) {
|
||||
// return item.map(parseToMap);
|
||||
// } else if (item instanceof Object) {
|
||||
// let res = new Map();
|
||||
// for (const key of Object.keys(item)) {
|
||||
// res.set(key, parseToMap(item[key]));
|
||||
// }
|
||||
// return res;
|
||||
// } else {
|
||||
// return item;
|
||||
// }
|
||||
// }
|
||||
// response.data = parseToMap(data);
|
||||
return response;
|
||||
},
|
||||
function (error: AxiosError) {
|
||||
if (error.response && error.response.status === 401) {
|
||||
store.dispatch(clearLoginStatus());
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
6
admin-frontend/src/app/hooks.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from './store';
|
||||
|
||||
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
58
admin-frontend/src/app/store.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import {
|
||||
Action, combineReducers, configureStore, ThunkAction,
|
||||
} from '@reduxjs/toolkit';
|
||||
import {
|
||||
persistStore,
|
||||
persistReducer,
|
||||
FLUSH,
|
||||
REHYDRATE,
|
||||
PAUSE,
|
||||
PERSIST,
|
||||
PURGE,
|
||||
REGISTER,
|
||||
} from 'redux-persist';
|
||||
import storage from 'redux-persist/lib/storage';
|
||||
import authReducer from '../features/auth/authSlice';
|
||||
import globalConfReducer from '../features/globalConf/globalConfSlice';
|
||||
import { subscribeApi } from '../features/subsribeConfigManager/subscribeConfigSlice';
|
||||
import { targetNameApi } from '../features/targetName/targetNameSlice';
|
||||
import { weightApi } from '../features/weightConfig/weightConfigSlice';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
globalConf: globalConfReducer,
|
||||
[subscribeApi.reducerPath]: subscribeApi.reducer,
|
||||
[weightApi.reducerPath]: weightApi.reducer,
|
||||
[targetNameApi.reducerPath]: targetNameApi.reducer,
|
||||
});
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage,
|
||||
whitelist: ['auth'],
|
||||
};
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, rootReducer);
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: (getDefaultMiddleware) => getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
|
||||
},
|
||||
})
|
||||
.concat(subscribeApi.middleware)
|
||||
.concat(weightApi.middleware)
|
||||
.concat(targetNameApi.middleware),
|
||||
});
|
||||
|
||||
export const persistor = persistStore(store);
|
||||
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppThunk<ReturnType = void> = ThunkAction<
|
||||
ReturnType,
|
||||
RootState,
|
||||
unknown,
|
||||
Action<string>
|
||||
>;
|
@ -1,219 +0,0 @@
|
||||
import { Form, Input, Modal, Select, Tag } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { addSubscribe, getTargetName, updateSubscribe } from "src/api/config";
|
||||
import { InputTag } from "src/component/inputTag";
|
||||
import { platformConfSelector } from "src/store/globalConfSlice";
|
||||
import { CategoryConfig, SubscribeConfig } from "src/utils/type";
|
||||
|
||||
interface InputTagCustomProp {
|
||||
value?: Array<string>;
|
||||
onChange?: (value: Array<string>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
function InputTagCustom(prop: InputTagCustomProp) {
|
||||
const [value, setValue] = useState(prop.value || []);
|
||||
const handleSetValue = (newVal: Array<string>) => {
|
||||
setValue(() => newVal);
|
||||
if (prop.onChange) {
|
||||
prop.onChange(newVal);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (prop.value) {
|
||||
setValue(prop.value);
|
||||
}
|
||||
}, [prop.value]);
|
||||
return (
|
||||
<>
|
||||
{prop.disabled ? (
|
||||
<Tag color="default">不支持标签</Tag>
|
||||
) : (
|
||||
<>
|
||||
{value.length === 0 && <Tag color="green">全部标签</Tag>}
|
||||
<InputTag
|
||||
color="blue"
|
||||
addText="添加标签"
|
||||
value={value}
|
||||
onChange={handleSetValue}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddModalProp {
|
||||
showModal: boolean;
|
||||
groupNumber: string;
|
||||
setShowModal: (s: boolean) => void;
|
||||
refresh: () => void;
|
||||
initVal?: SubscribeConfig;
|
||||
}
|
||||
export function AddModal({
|
||||
showModal,
|
||||
groupNumber,
|
||||
setShowModal,
|
||||
refresh,
|
||||
initVal,
|
||||
}: AddModalProp) {
|
||||
const [confirmLoading, setConfirmLoading] = useState<boolean>(false);
|
||||
const platformConf = useSelector(platformConfSelector);
|
||||
const [hasTarget, setHasTarget] = useState(false);
|
||||
const [categories, setCategories] = useState({} as CategoryConfig);
|
||||
const [enabledTag, setEnableTag] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [inited, setInited] = useState(false);
|
||||
const changePlatformSelect = (platform: string) => {
|
||||
setHasTarget((_) => platformConf[platform].hasTarget);
|
||||
setCategories((_) => platformConf[platform].categories);
|
||||
setEnableTag(platformConf[platform].enabledTag);
|
||||
if (!platformConf[platform].hasTarget) {
|
||||
getTargetName(platform, "default").then((res) => {
|
||||
console.log(res);
|
||||
form.setFieldsValue({
|
||||
targetName: res.targetName,
|
||||
target: "",
|
||||
});
|
||||
});
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
targetName: "",
|
||||
target: "",
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleSubmit = (value: any) => {
|
||||
let newVal = Object.assign({}, value);
|
||||
if (typeof newVal.tags !== "object") {
|
||||
newVal.tags = [];
|
||||
}
|
||||
if (typeof newVal.cats !== "object") {
|
||||
newVal.cats = [];
|
||||
}
|
||||
if (newVal.target === "") {
|
||||
newVal.target = "default";
|
||||
}
|
||||
if (initVal) {
|
||||
// patch
|
||||
updateSubscribe(groupNumber, newVal).then(() => {
|
||||
setConfirmLoading(false);
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
refresh();
|
||||
});
|
||||
} else {
|
||||
addSubscribe(groupNumber, newVal).then(() => {
|
||||
setConfirmLoading(false);
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleModleFinish = () => {
|
||||
form.submit();
|
||||
setConfirmLoading(() => true);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (initVal && !inited) {
|
||||
const platformName = initVal.platformName;
|
||||
setHasTarget(platformConf[platformName].hasTarget);
|
||||
setCategories(platformConf[platformName].categories);
|
||||
setEnableTag(platformConf[platformName].enabledTag);
|
||||
setInited(true);
|
||||
form.setFieldsValue(initVal);
|
||||
}
|
||||
}, [initVal, form, platformConf, inited]);
|
||||
return (
|
||||
<Modal
|
||||
title="添加订阅"
|
||||
visible={showModal}
|
||||
confirmLoading={confirmLoading}
|
||||
onCancel={() => setShowModal(false)}
|
||||
onOk={handleModleFinish}
|
||||
>
|
||||
<Form form={form} labelCol={{ span: 6 }} name="b" onFinish={handleSubmit}>
|
||||
<Form.Item label="平台" name="platformName" rules={[]}>
|
||||
<Select style={{ width: "80%" }} onChange={changePlatformSelect}>
|
||||
{Object.keys(platformConf).map((platformName) => (
|
||||
<Select.Option key={platformName} value={platformName}>
|
||||
{platformConf[platformName].name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="账号"
|
||||
name="target"
|
||||
rules={[
|
||||
{ required: hasTarget, message: "请输入账号" },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
try {
|
||||
const res = await getTargetName(
|
||||
form.getFieldValue("platformName"),
|
||||
value
|
||||
);
|
||||
if (res.targetName) {
|
||||
form.setFieldsValue({
|
||||
targetName: res.targetName,
|
||||
});
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
targetName: "",
|
||||
});
|
||||
return Promise.reject("账号不正确,请重新检查账号");
|
||||
}
|
||||
} catch {
|
||||
return Promise.reject("服务器错误,请稍后再试");
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={hasTarget ? "获取方式见文档" : "此平台不需要账号"}
|
||||
disabled={!hasTarget}
|
||||
style={{ width: "80%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="账号名称" name="targetName">
|
||||
<Input style={{ width: "80%" }} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="订阅分类"
|
||||
name="cats"
|
||||
rules={[
|
||||
{
|
||||
required: Object.keys(categories).length > 0,
|
||||
message: "请至少选择一个分类进行订阅",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
style={{ width: "80%" }}
|
||||
mode="multiple"
|
||||
disabled={Object.keys(categories).length === 0}
|
||||
placeholder={
|
||||
Object.keys(categories).length > 0
|
||||
? "请选择要订阅的分类"
|
||||
: "本平台不支持分类"
|
||||
}
|
||||
>
|
||||
{Object.keys(categories).length > 0 &&
|
||||
Object.keys(categories).map((indexStr) => (
|
||||
<Select.Option key={indexStr} value={parseInt(indexStr)}>
|
||||
{categories[parseInt(indexStr)]}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="订阅Tag" name="tags">
|
||||
<InputTagCustom disabled={!enabledTag} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@ -1,151 +0,0 @@
|
||||
import { Input, Tag, Tooltip } from "antd";
|
||||
import { PresetColorType, PresetStatusColorType } from "antd/lib/_util/colors";
|
||||
import { LiteralUnion } from "antd/lib/_util/type";
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
|
||||
interface InputTagProp {
|
||||
value?: Array<string>;
|
||||
onChange?: (value: Array<string>) => void;
|
||||
color?: LiteralUnion<PresetColorType | PresetStatusColorType, string>;
|
||||
addText?: string;
|
||||
}
|
||||
export function InputTag(prop: InputTagProp) {
|
||||
const [value, setValue] = useState<Array<string>>(prop.value || []);
|
||||
const [inputVisible, setInputVisible] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [editInputIndex, setEditInputIndex] = useState(-1);
|
||||
const [editInputValue, setEditInputValue] = useState("");
|
||||
const inputRef = useRef(null as any);
|
||||
const editInputRef = useRef(null as any);
|
||||
useEffect(() => {
|
||||
if (prop.value) {
|
||||
setValue(prop.value);
|
||||
}
|
||||
}, [prop.value]);
|
||||
useEffect(() => {
|
||||
if (inputVisible) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputVisible]);
|
||||
useEffect(() => {
|
||||
if (editInputIndex !== -1) {
|
||||
editInputRef.current.focus();
|
||||
}
|
||||
}, [editInputIndex]);
|
||||
|
||||
const handleClose = (removedTag: string) => {
|
||||
const tags = value.filter((tag) => tag !== removedTag);
|
||||
setValue((_) => tags);
|
||||
if (prop.onChange) {
|
||||
prop.onChange(tags);
|
||||
}
|
||||
};
|
||||
|
||||
const showInput = () => {
|
||||
setInputVisible((_) => true);
|
||||
};
|
||||
|
||||
const handleInputConfirm = () => {
|
||||
if (inputValue && value.indexOf(inputValue) === -1) {
|
||||
const newVal = [...value, inputValue];
|
||||
setValue((_) => newVal);
|
||||
if (prop.onChange) {
|
||||
prop.onChange(newVal);
|
||||
}
|
||||
}
|
||||
setInputVisible((_) => false);
|
||||
setInputValue((_) => "");
|
||||
};
|
||||
|
||||
const handleEditInputChange = (e: any) => {
|
||||
setEditInputValue((_) => e.target.value);
|
||||
};
|
||||
|
||||
const handleEditInputConfirm = () => {
|
||||
const newTags = value.slice();
|
||||
newTags[editInputIndex] = editInputValue;
|
||||
setValue((_) => newTags);
|
||||
if (prop.onChange) {
|
||||
prop.onChange(newTags);
|
||||
}
|
||||
setEditInputIndex((_) => -1);
|
||||
setEditInputValue((_) => "");
|
||||
};
|
||||
|
||||
const handleInputChange = (e: any) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{value.map((tag, index) => {
|
||||
if (editInputIndex === index) {
|
||||
return (
|
||||
<Input
|
||||
ref={editInputRef}
|
||||
key={tag}
|
||||
size="small"
|
||||
value={editInputValue}
|
||||
onChange={handleEditInputChange}
|
||||
onBlur={handleEditInputConfirm}
|
||||
onPressEnter={handleInputConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isLongTag = tag.length > 20;
|
||||
const tagElem = (
|
||||
<Tag
|
||||
color={prop.color || "default"}
|
||||
style={{ userSelect: "none" }}
|
||||
key={tag}
|
||||
closable
|
||||
onClose={() => handleClose(tag)}
|
||||
>
|
||||
<span
|
||||
onDoubleClick={(e) => {
|
||||
setEditInputIndex((_) => index);
|
||||
setEditInputValue((_) => tag);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
|
||||
</span>
|
||||
</Tag>
|
||||
);
|
||||
return isLongTag ? (
|
||||
<Tooltip title={tag} key={tag}>
|
||||
{tagElem}
|
||||
</Tooltip>
|
||||
) : (
|
||||
tagElem
|
||||
);
|
||||
})}
|
||||
{inputVisible && (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ width: "78px", marginRight: "8px", verticalAlign: "top" }}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputConfirm}
|
||||
onPressEnter={handleInputConfirm}
|
||||
/>
|
||||
)}
|
||||
{!inputVisible && (
|
||||
<Tag
|
||||
className="site-tag-plus"
|
||||
onClick={showInput}
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "dashed thin",
|
||||
borderColor: "#bfbfbf",
|
||||
}}
|
||||
>
|
||||
<PlusOutlined /> {prop.addText || "Add Tag"}
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,190 +0,0 @@
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
message,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import Modal from "antd/lib/modal/Modal";
|
||||
import React, { useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { addSubscribe, delSubscribe } from "src/api/config";
|
||||
import { platformConfSelector } from "src/store/globalConfSlice";
|
||||
import {
|
||||
groupConfigSelector,
|
||||
updateGroupSubs,
|
||||
} from "src/store/groupConfigSlice";
|
||||
import { PlatformConfig, SubscribeConfig, SubscribeResp } from "src/utils/type";
|
||||
import { AddModal } from "./addSubsModal";
|
||||
|
||||
interface CopyModalProp {
|
||||
setShowModal: (modalShow: boolean) => void;
|
||||
showModal: boolean;
|
||||
config: SubscribeConfig;
|
||||
groups: SubscribeResp;
|
||||
currentGroupNumber: string;
|
||||
reload: () => void;
|
||||
}
|
||||
function CopyModal({
|
||||
setShowModal,
|
||||
config,
|
||||
currentGroupNumber,
|
||||
groups,
|
||||
showModal,
|
||||
reload,
|
||||
}: CopyModalProp) {
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const [selectedGroups, setSelectGroups] = useState<Array<string>>([]);
|
||||
const postReqs = async (
|
||||
selectedGroups: Array<string>,
|
||||
config: SubscribeConfig
|
||||
) => {
|
||||
for (let selectedGroup of selectedGroups) {
|
||||
await addSubscribe(selectedGroup, config);
|
||||
}
|
||||
};
|
||||
const handleOk = () => {
|
||||
if (selectedGroups.length === 0) {
|
||||
message.error("请至少选择一个目标群");
|
||||
} else {
|
||||
setConfirmLoading(true);
|
||||
postReqs(selectedGroups, config).then(() => {
|
||||
setConfirmLoading(false);
|
||||
setShowModal(false);
|
||||
return reload();
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
title="复制订阅"
|
||||
visible={showModal}
|
||||
confirmLoading={confirmLoading}
|
||||
onCancel={() => setShowModal(false)}
|
||||
onOk={handleOk}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
onChange={(value: Array<string>) => setSelectGroups(value)}
|
||||
style={{ width: "80%" }}
|
||||
>
|
||||
{Object.keys(groups)
|
||||
.filter((groupNumber) => groupNumber !== currentGroupNumber)
|
||||
.map((groupNumber) => (
|
||||
<Select.Option value={groupNumber} key={groupNumber}>
|
||||
{`${groupNumber} - ${groups[groupNumber].name}`}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
interface SubscribeCardProp {
|
||||
groupNumber: string;
|
||||
config: SubscribeConfig;
|
||||
}
|
||||
export function SubscribeCard({ groupNumber, config }: SubscribeCardProp) {
|
||||
const platformConfs = useSelector(platformConfSelector);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const platformConf = platformConfs[config.platformName] as PlatformConfig;
|
||||
const dispatcher = useDispatch();
|
||||
const groupSubscribes = useSelector(groupConfigSelector);
|
||||
const reload = () => dispatcher(updateGroupSubs());
|
||||
const handleDelete =
|
||||
(groupNumber: string, platformName: string, target: string) => () => {
|
||||
delSubscribe(groupNumber, platformName, target).then(() => {
|
||||
reload();
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Col span={8} key={`${config.platformName}-${config.target}`}>
|
||||
<Card
|
||||
title={`${platformConf.name} - ${config.targetName}`}
|
||||
actions={[
|
||||
<Tooltip title="编辑">
|
||||
<EditOutlined
|
||||
onClick={() => {
|
||||
setShowEditModal((state) => !state);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>,
|
||||
<Tooltip title="添加到其他群">
|
||||
<CopyOutlined
|
||||
onClick={() => {
|
||||
setShowModal((state) => !state);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>,
|
||||
<Popconfirm
|
||||
title={`确定要删除 ${platformConf.name} - ${config.targetName}`}
|
||||
onConfirm={handleDelete(
|
||||
groupNumber,
|
||||
config.platformName,
|
||||
config.target || "default"
|
||||
)}
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<DeleteOutlined />
|
||||
</Tooltip>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<Form labelCol={{ span: 4 }}>
|
||||
<Form.Item label="订阅帐号">
|
||||
{platformConf.hasTarget ? (
|
||||
config.target
|
||||
) : (
|
||||
<Tag color="default">无帐号</Tag>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item label="订阅类型">
|
||||
{Object.keys(platformConf.categories).length > 0 ? (
|
||||
config.cats.map((catKey: number) => (
|
||||
<Tag color="green" key={catKey}>
|
||||
{platformConf.categories[catKey]}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Tag color="default">不支持类型</Tag>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item label="订阅Tag">
|
||||
{platformConf.enabledTag ? (
|
||||
config.tags.length > 0 ? (
|
||||
config.tags.map((tag) => (
|
||||
<Tag color="green" key={tag}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Tag color="blue">全部标签</Tag>
|
||||
)
|
||||
) : (
|
||||
<Tag color="default">不支持Tag</Tag>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<CopyModal
|
||||
setShowModal={setShowModal}
|
||||
reload={reload}
|
||||
currentGroupNumber={groupNumber}
|
||||
showModal={showModal}
|
||||
config={config}
|
||||
groups={groupSubscribes}
|
||||
/>
|
||||
<AddModal
|
||||
showModal={showEditModal}
|
||||
setShowModal={setShowEditModal}
|
||||
groupNumber={groupNumber}
|
||||
refresh={reload}
|
||||
initVal={config}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
25
admin-frontend/src/features/auth/Auth.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks';
|
||||
import { login, selectIsFailed, selectIsLogin } from './authSlice';
|
||||
|
||||
export default function Auth() {
|
||||
const isLogin = useAppSelector(selectIsLogin);
|
||||
const dispatch = useAppDispatch();
|
||||
const { code } = useParams();
|
||||
const isFailed = useAppSelector(selectIsFailed);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLogin && code) {
|
||||
dispatch(login(code));
|
||||
}
|
||||
}, [isLogin, code]);
|
||||
|
||||
if (isLogin) {
|
||||
return <Navigate to="/home" />;
|
||||
}
|
||||
if (isFailed) {
|
||||
return <Navigate to="/unauthed" />;
|
||||
}
|
||||
return <div> login </div>;
|
||||
}
|
33
admin-frontend/src/features/auth/authQuery.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
BaseQueryFn, FetchArgs, fetchBaseQuery, FetchBaseQueryError,
|
||||
} from '@reduxjs/toolkit/dist/query';
|
||||
import { RootState } from '../../app/store';
|
||||
import { baseUrl } from '../../utils/urls';
|
||||
import { setLogout } from './authSlice';
|
||||
|
||||
const baseQuery = fetchBaseQuery({
|
||||
baseUrl,
|
||||
prepareHeaders: (headers, { getState }) => {
|
||||
const { token } = (getState() as RootState).auth;
|
||||
|
||||
if (token) {
|
||||
headers.set('authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return headers;
|
||||
},
|
||||
});
|
||||
|
||||
export const baseQueryWithAuth: BaseQueryFn<
|
||||
string | FetchArgs,
|
||||
unknown,
|
||||
FetchBaseQueryError
|
||||
> = async (args, api, extraOptions) => {
|
||||
const result = await baseQuery(args, api, extraOptions);
|
||||
if (result.error && result.error.status === 401) {
|
||||
api.dispatch(setLogout());
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export default baseQueryWithAuth;
|
70
admin-frontend/src/features/auth/authSlice.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import {
|
||||
CaseReducer, createAsyncThunk, createSlice, PayloadAction,
|
||||
} from '@reduxjs/toolkit';
|
||||
import { RootState } from '../../app/store';
|
||||
import { TokenResp } from '../../utils/type';
|
||||
import { authUrl } from '../../utils/urls';
|
||||
|
||||
export interface AuthStatus {
|
||||
login: boolean;
|
||||
token: string;
|
||||
failed: boolean;
|
||||
userType: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
login: false,
|
||||
failed: false,
|
||||
token: '',
|
||||
userType: '',
|
||||
id: 0,
|
||||
} as AuthStatus;
|
||||
|
||||
export const login = createAsyncThunk(
|
||||
'auth/login',
|
||||
async (code: string) => {
|
||||
const res = await fetch(`${authUrl}?token=${code}`);
|
||||
return (await res.json()) as TokenResp;
|
||||
},
|
||||
);
|
||||
|
||||
const setLoginReducer: CaseReducer<AuthStatus, PayloadAction<TokenResp>> = (state, action) => {
|
||||
if (action.payload.status === 200) {
|
||||
state.login = true;
|
||||
state.id = action.payload.id;
|
||||
state.userType = action.payload.type;
|
||||
state.token = action.payload.token;
|
||||
} else {
|
||||
state.login = false;
|
||||
state.failed = true;
|
||||
}
|
||||
};
|
||||
|
||||
export const setLogoutReducer: CaseReducer<AuthStatus> = (state) => {
|
||||
state.login = false;
|
||||
};
|
||||
|
||||
export const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
setLogin: setLoginReducer,
|
||||
setLogout: setLogoutReducer,
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder
|
||||
.addCase(login.pending, (state) => {
|
||||
state.failed = false;
|
||||
})
|
||||
.addCase(login.fulfilled, setLoginReducer)
|
||||
.addCase(login.rejected, setLogoutReducer);
|
||||
},
|
||||
});
|
||||
|
||||
export const { setLogin, setLogout } = authSlice.actions;
|
||||
|
||||
export const selectIsLogin = (state: RootState) => state.auth.login;
|
||||
export const selectIsFailed = (state: RootState) => state.auth.failed;
|
||||
|
||||
export default authSlice.reducer;
|
35
admin-frontend/src/features/globalConf/globalConfSlice.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { RootState } from '../../app/store';
|
||||
import { GlobalConf } from '../../utils/type';
|
||||
import { globalConfUrl } from '../../utils/urls';
|
||||
|
||||
const initialState = {
|
||||
loaded: false,
|
||||
platformConf: {},
|
||||
} as GlobalConf;
|
||||
|
||||
export const loadGlobalConf = createAsyncThunk(
|
||||
'globalConf/load',
|
||||
async () => {
|
||||
const res = await fetch(globalConfUrl);
|
||||
return (await res.json()) as GlobalConf;
|
||||
},
|
||||
);
|
||||
|
||||
export const globalConfSlice = createSlice({
|
||||
name: 'globalConf',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers(builder) {
|
||||
builder
|
||||
.addCase(loadGlobalConf.fulfilled, (state, payload) => {
|
||||
state.platformConf = payload.payload.platformConf;
|
||||
state.loaded = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default globalConfSlice.reducer;
|
||||
|
||||
export const selectGlobalConfLoaded = (state: RootState) => state.globalConf.loaded;
|
||||
export const selectPlatformConf = (state: RootState) => state.globalConf.platformConf;
|
@ -0,0 +1,50 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card, Typography, Grid, Button,
|
||||
} from '@arco-design/web-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetSubsQuery } from './subscribeConfigSlice';
|
||||
import SubscribeModal from './SubscribeModal';
|
||||
|
||||
export default function GroupManager() {
|
||||
const [modalGroupNumber, setModalGroupNumber] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const { data: subs } = useGetSubsQuery();
|
||||
|
||||
const handleAddSub = (groupNumber: string) => () => {
|
||||
setModalGroupNumber(groupNumber);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography.Title heading={4} style={{ margin: '15px' }}>群管理</Typography.Title>
|
||||
<div>
|
||||
{ subs && (
|
||||
<Grid.Row gutter={20}>
|
||||
{ Object.keys(subs).map(
|
||||
(groupNumber: string) => (
|
||||
<Grid.Col span={6} key={groupNumber}>
|
||||
<Card
|
||||
title={subs[groupNumber].name}
|
||||
actions={[
|
||||
<Link to={`/home/groups/${groupNumber}`}><Button>详情</Button></Link>,
|
||||
<Button type="primary" onClick={handleAddSub(groupNumber)}>添加</Button>,
|
||||
]}
|
||||
>
|
||||
<div>{groupNumber}</div>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
),
|
||||
)}
|
||||
</Grid.Row>
|
||||
)}
|
||||
</div>
|
||||
<SubscribeModal
|
||||
groupNumber={modalGroupNumber}
|
||||
visible={showModal}
|
||||
setVisible={setShowModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,121 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Button, Empty, Message, Popconfirm, Space, Table, Tag, Typography,
|
||||
} from '@arco-design/web-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useDeleteSubMutation, useGetSubsQuery } from './subscribeConfigSlice';
|
||||
import { useAppSelector } from '../../app/hooks';
|
||||
import { selectPlatformConf } from '../globalConf/globalConfSlice';
|
||||
import { SubscribeConfig } from '../../utils/type';
|
||||
import SubscribeModal from './SubscribeModal';
|
||||
|
||||
export default function SubscribeManager() {
|
||||
const { data: subs } = useGetSubsQuery();
|
||||
const [deleteSub, { isLoading: deleteIsLoading }] = useDeleteSubMutation();
|
||||
const { groupNumber } = useParams();
|
||||
const platformConf = useAppSelector(selectPlatformConf);
|
||||
|
||||
const isLoading = deleteIsLoading;
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [formInitVal, setFormInitVal] = useState(null as SubscribeConfig | null);
|
||||
|
||||
const handleNewSub = () => {
|
||||
setFormInitVal(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (sub: SubscribeConfig) => () => {
|
||||
setFormInitVal(sub);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '平台名称',
|
||||
dataIndex: 'platformName',
|
||||
render: (col: string, record: SubscribeConfig) => (
|
||||
<span>{platformConf[record.platformName].name}</span>
|
||||
),
|
||||
},
|
||||
{ title: '帐号名称', dataIndex: 'targetName' },
|
||||
{ title: '订阅帐号', dataIndex: 'target' },
|
||||
{
|
||||
title: '订阅分类',
|
||||
dataIndex: 'cats',
|
||||
render: (col: string[], record: SubscribeConfig) => (
|
||||
<span>
|
||||
<Space>
|
||||
{
|
||||
record.cats.map((catNumber: number) => (
|
||||
<Tag>{platformConf[record.platformName].categories[catNumber]}</Tag>
|
||||
))
|
||||
}
|
||||
</Space>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订阅标签',
|
||||
dataIndex: 'tags',
|
||||
render: (col: string[], record: SubscribeConfig) => (
|
||||
<span>
|
||||
<Space>
|
||||
{
|
||||
record.tags.length === 0 ? <Tag color="green">全部标签</Tag>
|
||||
: record.tags.map((tag: string) => (
|
||||
<Tag color="blue">{tag}</Tag>
|
||||
))
|
||||
}
|
||||
</Space>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'op',
|
||||
render: (_: null, record: SubscribeConfig) => (
|
||||
<Space>
|
||||
<Button type="text" onClick={handleEdit(record)}>编辑</Button>
|
||||
<Button type="text" status="success" onClick={() => Message.error('懒得写了')}>复制</Button>
|
||||
<Popconfirm
|
||||
title={`确认删除订阅 ${record.targetName} ?`}
|
||||
onOk={() => {
|
||||
deleteSub({
|
||||
groupNumber: parseInt(groupNumber || '0', 10),
|
||||
target: record.target,
|
||||
platformName: record.platformName,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button type="text" status="danger">删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (subs && groupNumber) {
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
<Typography.Title heading={3}>{subs[groupNumber].name}</Typography.Title>
|
||||
<Typography.Text type="secondary">{groupNumber}</Typography.Text>
|
||||
</span>
|
||||
<Button style={{ width: '90px', margin: '20px 10px' }} type="primary" onClick={handleNewSub}>添加</Button>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={subs[groupNumber].subscribes}
|
||||
rowKey={(record: SubscribeConfig) => `${record.platformName}-${record.target}`}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<SubscribeModal
|
||||
visible={showModal}
|
||||
setVisible={setShowModal}
|
||||
groupNumber={groupNumber}
|
||||
initval={formInitVal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Empty />;
|
||||
}
|
@ -0,0 +1,242 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Form, Input, InputTag, Modal, Select, Space, Tag,
|
||||
} from '@arco-design/web-react';
|
||||
import useForm from '@arco-design/web-react/es/Form/useForm';
|
||||
import { IconInfoCircle } from '@arco-design/web-react/icon';
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks';
|
||||
import { selectPlatformConf } from '../globalConf/globalConfSlice';
|
||||
import { CategoryConfig, SubscribeConfig } from '../../utils/type';
|
||||
import getTargetName from '../targetName/targetNameReq';
|
||||
import { useUpdateSubMutation, useNewSubMutation } from './subscribeConfigSlice';
|
||||
|
||||
function SubscribeTag({
|
||||
value, onChange, disabled,
|
||||
}: {
|
||||
value?: string[];
|
||||
onChange?: (arg0: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [valueState, setValueState] = useState(value || []);
|
||||
const handleSetValue = (newVal: string[]) => {
|
||||
setValueState(newVal);
|
||||
if (onChange) {
|
||||
onChange(newVal);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setValueState(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
if (disabled) {
|
||||
return <Tag color="gray">不支持标签</Tag>;
|
||||
}
|
||||
return (
|
||||
<Space>
|
||||
{ valueState.length === 0 && <Tag color="green">全部标签</Tag> }
|
||||
<InputTag
|
||||
allowClear
|
||||
placeholder="添加标签"
|
||||
value={value}
|
||||
onChange={handleSetValue}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
SubscribeTag.defaultProps = {
|
||||
value: [],
|
||||
onChange: null,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
interface SubscribeModalProp {
|
||||
visible: boolean;
|
||||
setVisible: (arg0: boolean) => void;
|
||||
groupNumber: string;
|
||||
initval?: SubscribeConfig | null;
|
||||
}
|
||||
|
||||
function SubscribeModal({
|
||||
visible, setVisible, groupNumber, initval,
|
||||
}: SubscribeModalProp) {
|
||||
const [form] = useForm();
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const platformConf = useAppSelector(selectPlatformConf);
|
||||
const [updateSub] = useUpdateSubMutation();
|
||||
const [newSub] = useNewSubMutation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onSubmit = () => {
|
||||
form.validate().then((value: SubscribeConfig) => {
|
||||
const newVal = { ...value };
|
||||
if (typeof newVal.tags !== 'object') {
|
||||
newVal.tags = [];
|
||||
}
|
||||
if (typeof newVal.cats !== 'object') {
|
||||
newVal.cats = [];
|
||||
}
|
||||
if (newVal.target === '') {
|
||||
newVal.target = 'default';
|
||||
}
|
||||
let postPromise: ReturnType<typeof updateSub>;
|
||||
if (initval) {
|
||||
postPromise = updateSub({
|
||||
groupNumber: parseInt(groupNumber, 10),
|
||||
sub: newVal,
|
||||
});
|
||||
} else {
|
||||
postPromise = newSub({
|
||||
groupNumber: parseInt(groupNumber, 10),
|
||||
sub: newVal,
|
||||
});
|
||||
}
|
||||
setConfirmLoading(true);
|
||||
postPromise.then(() => {
|
||||
setConfirmLoading(false);
|
||||
setVisible(false);
|
||||
form.clearFields();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const [hasTarget, setHasTarget] = useState(false);
|
||||
const [categories, setCategories] = useState({} as CategoryConfig);
|
||||
const [enableTags, setEnableTags] = useState(false);
|
||||
|
||||
const setPlatformStates = (platform: string) => {
|
||||
setHasTarget(platformConf[platform].hasTarget);
|
||||
setCategories(platformConf[platform].categories);
|
||||
setEnableTags(platformConf[platform].enabledTag);
|
||||
};
|
||||
|
||||
const handlePlatformSelected = (platform: string) => {
|
||||
setPlatformStates(platform);
|
||||
form.setFieldValue('cats', []);
|
||||
if (!platformConf[platform].hasTarget) {
|
||||
dispatch(getTargetName(platform, 'default')).then((res) => {
|
||||
form.setFieldsValue({
|
||||
targetName: res,
|
||||
target: '',
|
||||
});
|
||||
});
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
targetName: '',
|
||||
target: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initval) {
|
||||
const { platformName } = initval;
|
||||
setPlatformStates(platformName);
|
||||
form.setFieldsValue(initval);
|
||||
} else {
|
||||
form.clearFields();
|
||||
}
|
||||
}, [initval, form, platformConf]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="编辑订阅"
|
||||
visible={visible}
|
||||
onCancel={() => setVisible(false)}
|
||||
confirmLoading={confirmLoading}
|
||||
onOk={onSubmit}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
>
|
||||
<Form.Item label="平台" field="platformName">
|
||||
<Select placeholder="平台" onChange={handlePlatformSelected}>
|
||||
{ Object.keys(platformConf).map(
|
||||
(platformName: string) => (
|
||||
<Select.Option value={platformName} key={platformName}>
|
||||
{platformConf[platformName].name}
|
||||
</Select.Option>
|
||||
),
|
||||
) }
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="帐号"
|
||||
field="target"
|
||||
rules={[
|
||||
{ required: hasTarget, message: '请输入账号' },
|
||||
{
|
||||
validator: (value, callback) => new Promise<void>((resolve) => {
|
||||
dispatch(getTargetName(form.getFieldValue('platformName'), value))
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
form.setFieldsValue({
|
||||
targetName: res,
|
||||
});
|
||||
resolve();
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
targetName: '',
|
||||
});
|
||||
callback('账号不正确,请重新检查账号');
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
callback('服务器错误,请稍后再试');
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
disabled={!hasTarget}
|
||||
suffix={<IconInfoCircle />}
|
||||
placeholder={hasTarget ? '获取方式见文档' : '此平台不需要账号'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="帐号名称" field="targetName">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="订阅分类"
|
||||
field="cats"
|
||||
rules={[
|
||||
{
|
||||
required: Object.keys(categories).length > 0,
|
||||
message: '请至少选择一个分类进行订阅',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
disabled={Object.keys(categories).length === 0}
|
||||
placeholder={
|
||||
Object.keys(categories).length > 0
|
||||
? '请选择要订阅的分类'
|
||||
: '本平台不支持分类'
|
||||
}
|
||||
>
|
||||
{ Object.keys(categories).length > 0
|
||||
&& Object.keys(categories).map((indexStr) => (
|
||||
<Select.Option key={indexStr} value={parseInt(indexStr, 10)}>
|
||||
{ categories[parseInt(indexStr, 10)] }
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
</Form.Item>
|
||||
<Form.Item label="标签" field="tags">
|
||||
<SubscribeTag disabled={!enableTags} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
SubscribeModal.defaultProps = {
|
||||
initval: null,
|
||||
};
|
||||
export default SubscribeModal;
|
@ -0,0 +1,45 @@
|
||||
import { createApi } from '@reduxjs/toolkit/query/react';
|
||||
import {
|
||||
StatusResp, SubmitParam, SubscribeResp,
|
||||
} from '../../utils/type';
|
||||
import { baseQueryWithAuth } from '../auth/authQuery';
|
||||
|
||||
export const subscribeApi = createApi({
|
||||
reducerPath: 'subscribe',
|
||||
baseQuery: baseQueryWithAuth,
|
||||
tagTypes: ['Subscribe'],
|
||||
endpoints: (builder) => ({
|
||||
getSubs: builder.query<SubscribeResp, void>({
|
||||
query: () => '/subs',
|
||||
providesTags: ['Subscribe'],
|
||||
}),
|
||||
newSub: builder.mutation<StatusResp, SubmitParam>({
|
||||
query: ({ groupNumber, sub }) => ({
|
||||
method: 'POST',
|
||||
url: `/subs?groupNumber=${groupNumber}`,
|
||||
body: sub,
|
||||
}),
|
||||
invalidatesTags: ['Subscribe'],
|
||||
}),
|
||||
updateSub: builder.mutation<StatusResp, SubmitParam>({
|
||||
query: ({ groupNumber, sub }) => ({
|
||||
method: 'PATCH',
|
||||
url: `/subs?groupNumber=${groupNumber}`,
|
||||
body: sub,
|
||||
}),
|
||||
invalidatesTags: ['Subscribe'],
|
||||
}),
|
||||
deleteSub: builder.mutation<StatusResp,
|
||||
{ groupNumber: number; target: string; platformName: string }>({
|
||||
query: ({ groupNumber, target, platformName }) => ({
|
||||
method: 'DELETE',
|
||||
url: `/subs?groupNumber=${groupNumber}&target=${target}&platformName=${platformName}`,
|
||||
}),
|
||||
invalidatesTags: ['Subscribe'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetSubsQuery, useNewSubMutation, useDeleteSubMutation, useUpdateSubMutation,
|
||||
} = subscribeApi;
|
19
admin-frontend/src/features/targetName/targetNameReq.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { AppThunk } from '../../app/store';
|
||||
import { baseUrl } from '../../utils/urls';
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const getTargetName =
|
||||
(platformName: string, target: string): AppThunk<Promise<string>> => async (_, getState) => {
|
||||
const url = `${baseUrl}target_name?platformName=${platformName}&target=${target}`;
|
||||
const state = getState();
|
||||
const authToken = state.auth.token;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
const resObj = await res.json();
|
||||
return resObj.targetName as string;
|
||||
};
|
||||
|
||||
export default getTargetName;
|
14
admin-frontend/src/features/targetName/targetNameSlice.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { createApi } from '@reduxjs/toolkit/query/react';
|
||||
import baseQueryWithAuth from '../auth/authQuery';
|
||||
|
||||
export const targetNameApi = createApi({
|
||||
reducerPath: 'targetName',
|
||||
baseQuery: baseQueryWithAuth,
|
||||
endpoints: (builder) => ({
|
||||
getTargetName: builder.query<{targetName: string}, {target: string; platformName: string}>({
|
||||
query: () => '/target_name',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetTargetNameQuery } = targetNameApi;
|
32
admin-frontend/src/features/weightConfig/WeightManager.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
// import { WeightConfig } from '../../utils/type';
|
||||
// import { useGetWeightQuery, useUpdateWeightMutation } from './weightConfigSlice';
|
||||
//
|
||||
// export default function WeightManager() {
|
||||
// const { data: weight } = useGetWeightQuery();
|
||||
// const [updateWeight] = useUpdateWeightMutation();
|
||||
//
|
||||
// const doUpdate = () => {
|
||||
// const weightConfig: WeightConfig = {
|
||||
// default: 20,
|
||||
// time_config: [
|
||||
// {
|
||||
// start_time: '01:00',
|
||||
// end_time: '02:00',
|
||||
// weight: 50,
|
||||
// },
|
||||
// ],
|
||||
// };
|
||||
// updateWeight({ weight: weightConfig, platform_name: 'weibo', target: '' });
|
||||
// };
|
||||
// return (
|
||||
// <>
|
||||
// <div>{weight && JSON.stringify(weight)}</div>
|
||||
// <button type="button" onClick={doUpdate}> 123</button>
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
|
||||
export default function WeightConfig() {
|
||||
return <div>下个版本再写</div>;
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { createApi } from '@reduxjs/toolkit/query/react';
|
||||
import { PlatformWeightConfigResp, StatusResp } from '../../utils/type';
|
||||
import baseQueryWithAuth from '../auth/authQuery';
|
||||
|
||||
export const weightApi = createApi({
|
||||
reducerPath: 'weight',
|
||||
baseQuery: baseQueryWithAuth,
|
||||
tagTypes: ['Weight'],
|
||||
endpoints: (builder) => ({
|
||||
getWeight: builder.query<PlatformWeightConfigResp, void>({
|
||||
query: () => '/weight',
|
||||
providesTags: ['Weight'],
|
||||
}),
|
||||
updateWeight: builder.mutation<StatusResp,
|
||||
Pick<PlatformWeightConfigResp, 'platform_name' | 'target' | 'weight' >>({
|
||||
query: ({ platform_name: platformName, target, weight }) => ({
|
||||
method: 'PUT',
|
||||
url: `/weight?platform_name=${platformName}&target=${target}`,
|
||||
body: weight,
|
||||
}),
|
||||
invalidatesTags: ['Weight'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetWeightQuery, useUpdateWeightMutation,
|
||||
} = weightApi;
|
@ -1,20 +1,25 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Provider } from "react-redux";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
import store from "./store";
|
||||
import { injectStore } from "src/api/utils";
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import App from './App';
|
||||
import { persistor, store } from './app/store';
|
||||
import './index.css';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import '@arco-design/web-react/dist/css/arco.css';
|
||||
|
||||
injectStore(store);
|
||||
ReactDOM.render(
|
||||
// eslint-disable-next-line
|
||||
const container = document.getElementById('root')!;
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<App />
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
|
@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g fill="#764ABC"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.1 KiB |
52
admin-frontend/src/pages/Home.css
Normal file
@ -0,0 +1,52 @@
|
||||
.layout-collapse-demo {
|
||||
height: 100vh;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.layout-collapse-demo .arco-layout-header .logo {
|
||||
height: 32px;
|
||||
margin: 12px 8px;
|
||||
width: 150px;
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.layout-collapse-demo .arco-layout-header span {
|
||||
height: 100%;
|
||||
line-height: 100%;
|
||||
font-size: 20px;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.layout-collapse-demo .arco-layout-content .arco-layout-footer,
|
||||
.layout-collapse-demo .arco-layout-content .arco-layout-content {
|
||||
color: var(--color-white);
|
||||
/* text-align: center; */
|
||||
font-stretch: condensed;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* justify-content: center; */
|
||||
}
|
||||
|
||||
.layout-collapse-demo .arco-layout-footer {
|
||||
color: var(--color-text-2);
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout-collapse-demo .arco-layout-content .arco-layout-content {
|
||||
background: var(--color-bg-3);
|
||||
color: var(--color-text-2);
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-collapse-demo .arco-layout-header {
|
||||
height: 64px;
|
||||
line-height: 64px;
|
||||
background: var(--color-bg-3);
|
||||
}
|
118
admin-frontend/src/pages/Home.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import { Breadcrumb, Layout, Menu } from '@arco-design/web-react';
|
||||
import { IconRobot, IconDashboard } from '@arco-design/web-react/icon';
|
||||
import './Home.css';
|
||||
// import SubscribeManager from '../features/subsribeConfigManager/SubscribeManager';
|
||||
import {
|
||||
Link, Navigate, Outlet, useLocation, useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { useAppSelector } from '../app/hooks';
|
||||
import { selectIsLogin } from '../features/auth/authSlice';
|
||||
|
||||
export default function Home() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isLogin = useAppSelector(selectIsLogin);
|
||||
|
||||
const path = location.pathname;
|
||||
useEffect(() => {
|
||||
if (path === '/home') {
|
||||
navigate('/home/groups');
|
||||
}
|
||||
|
||||
if (path !== '/home/groups' && !path.startsWith('/home/groups/') && path !== '/home/weight') {
|
||||
navigate('/home/groups');
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
let currentKey = '';
|
||||
if (path === '/home/groups') {
|
||||
currentKey = 'groups';
|
||||
} else if (path.startsWith('/home/groups/')) {
|
||||
currentKey = 'subs';
|
||||
}
|
||||
|
||||
const [selectedTab, changeSelectTab] = useState(currentKey);
|
||||
|
||||
const handleTabSelect = (tab: string) => {
|
||||
changeSelectTab(tab);
|
||||
if (tab === 'groups') {
|
||||
navigate('/home/groups');
|
||||
} else if (tab === 'weight') {
|
||||
navigate('/home/weight');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLogin) {
|
||||
return <Navigate to="/unauthed" />;
|
||||
}
|
||||
|
||||
let breadcrumbContent: ReactNode;
|
||||
if (path === '/home/groups') {
|
||||
breadcrumbContent = (
|
||||
<Breadcrumb style={{ margin: '16px 0' }}>
|
||||
<Breadcrumb.Item>
|
||||
<IconRobot />
|
||||
订阅管理
|
||||
</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
);
|
||||
} else if (path.startsWith('/home/groups/')) {
|
||||
breadcrumbContent = (
|
||||
<Breadcrumb style={{ margin: '16px 0' }}>
|
||||
<Breadcrumb.Item>
|
||||
<Link to="/home/groups">
|
||||
<IconRobot />
|
||||
订阅管理
|
||||
</Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>
|
||||
群管理
|
||||
</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
);
|
||||
} else if (path === '/home/weight') {
|
||||
breadcrumbContent = (
|
||||
<Breadcrumb style={{ margin: '16px 0' }}>
|
||||
<Breadcrumb.Item>
|
||||
<IconDashboard />
|
||||
调度权重
|
||||
</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Layout className="layout-collapse-demo">
|
||||
<Layout.Header>
|
||||
<span>
|
||||
Nonebot Bison
|
||||
</span>
|
||||
</Layout.Header>
|
||||
<Layout className="layout-collapse-demo">
|
||||
<Layout.Sider>
|
||||
<Menu
|
||||
defaultSelectedKeys={[selectedTab]}
|
||||
onClickMenuItem={(key) => { handleTabSelect(key); }}
|
||||
>
|
||||
<Menu.Item key="groups">
|
||||
<IconRobot />
|
||||
订阅管理
|
||||
</Menu.Item>
|
||||
<Menu.Item key="weight">
|
||||
<IconDashboard />
|
||||
调度权重
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</Layout.Sider>
|
||||
<Layout.Content style={{ padding: '0 24px' }}>
|
||||
<Layout style={{ height: '100%' }}>
|
||||
{ breadcrumbContent }
|
||||
<Layout.Content style={{ margin: '10px', padding: '40px' }}>
|
||||
<Outlet />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
7
admin-frontend/src/pages/Unauthed.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Unauthed() {
|
||||
return (
|
||||
<div>not login</div>
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
.layout-side .user {
|
||||
height: 32px;
|
||||
margin: 16px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import { Button, Collapse, Empty, Row } from "antd";
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { AddModal } from "src/component/addSubsModal";
|
||||
import { SubscribeCard } from "src/component/subscribeCard";
|
||||
import {
|
||||
groupConfigSelector,
|
||||
updateGroupSubs,
|
||||
} from "src/store/groupConfigSlice";
|
||||
|
||||
interface ConfigPageProp {
|
||||
tab: string;
|
||||
}
|
||||
export function ConfigPage(prop: ConfigPageProp) {
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const [currentAddingGroupNumber, setCurrentAddingGroupNumber] = useState("");
|
||||
const configData = useSelector(groupConfigSelector);
|
||||
const dispatcher = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatcher(updateGroupSubs());
|
||||
}, [prop.tab, dispatcher]);
|
||||
const clickNew =
|
||||
(groupNumber: string) => (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setShowModal((_) => true);
|
||||
setCurrentAddingGroupNumber(groupNumber);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
if (Object.keys(configData).length === 0) {
|
||||
return <Empty />;
|
||||
} else {
|
||||
let groups: Array<ReactElement> = [];
|
||||
for (let key of Object.keys(configData)) {
|
||||
let value = configData[key];
|
||||
groups.push(
|
||||
<Collapse.Panel
|
||||
key={key}
|
||||
header={
|
||||
<span>
|
||||
{`${key} - ${value.name}`}
|
||||
<Button style={{ float: "right" }} onClick={clickNew(key)}>
|
||||
添加
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Row
|
||||
gutter={[
|
||||
{ xs: 8, sm: 16, md: 24, lg: 32 },
|
||||
{ xs: 8, sm: 16, md: 24, lg: 32 },
|
||||
]}
|
||||
align="middle"
|
||||
>
|
||||
{value.subscribes.map((subs, idx) => (
|
||||
<SubscribeCard key={idx} groupNumber={key} config={subs} />
|
||||
))}
|
||||
</Row>
|
||||
</Collapse.Panel>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Collapse>{groups}</Collapse>
|
||||
<AddModal
|
||||
groupNumber={currentAddingGroupNumber}
|
||||
showModal={showModal}
|
||||
refresh={() => dispatcher(updateGroupSubs())}
|
||||
setShowModal={(s: boolean) => setShowModal((_) => s)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
import { BugOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
import { Layout, Menu } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { loginSelector } from "src/store/loginSlice";
|
||||
import "./admin.css";
|
||||
import { ConfigPage } from "./configPage";
|
||||
|
||||
export function Admin() {
|
||||
const login = useSelector(loginSelector);
|
||||
const [tab, changeTab] = useState("manage");
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Layout.Sider className="layout-side">
|
||||
<div className="user"></div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
defaultSelectedKeys={[tab]}
|
||||
onClick={({ key }) => changeTab(key)}
|
||||
>
|
||||
<Menu.Item key="manage" icon={<SettingOutlined />}>
|
||||
订阅管理
|
||||
</Menu.Item>
|
||||
{login.type === "admin" && (
|
||||
<Menu.Item key="log" icon={<BugOutlined />}>
|
||||
查看日志
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
</Layout.Sider>
|
||||
<Layout.Content>
|
||||
<div style={{ margin: "24px", background: "#fff", minHeight: "640px" }}>
|
||||
{tab === "manage" ? <ConfigPage tab={tab} /> : null}
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
);
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useParams } from "react-router";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { login, loginSelector } from "src/store/loginSlice";
|
||||
|
||||
interface AuthParam {
|
||||
code: string;
|
||||
}
|
||||
export function Auth() {
|
||||
const { code } = useParams<AuthParam>();
|
||||
const dispatch = useDispatch();
|
||||
const loginState = useSelector(loginSelector);
|
||||
useEffect(() => {
|
||||
const loginFun = async () => {
|
||||
dispatch(login(code));
|
||||
};
|
||||
loginFun();
|
||||
}, [code, dispatch]);
|
||||
return (
|
||||
<>
|
||||
{loginState.login ? (
|
||||
<Redirect to={{ pathname: "/admin" }} />
|
||||
) : loginState.failed ? (
|
||||
<div>登录失败,请重新获取连接</div>
|
||||
) : (
|
||||
<div>Logining...</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import { ReportHandler } from "web-vitals";
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
import('web-vitals').then(({
|
||||
getCLS, getFID, getFCP, getLCP, getTTFB,
|
||||
}) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
|
@ -2,4 +2,4 @@
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import "@testing-library/jest-dom";
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
@ -1,44 +0,0 @@
|
||||
import {
|
||||
CaseReducer,
|
||||
createAsyncThunk,
|
||||
createSlice,
|
||||
PayloadAction,
|
||||
} from "@reduxjs/toolkit";
|
||||
import { getGlobalConf as getGlobalConfApi } from "src/api/config";
|
||||
import { GlobalConf } from "src/utils/type";
|
||||
import { RootState } from ".";
|
||||
|
||||
const initialState: GlobalConf = {
|
||||
platformConf: {},
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
const setGlobalConf: CaseReducer<GlobalConf, PayloadAction<GlobalConf>> = (
|
||||
_,
|
||||
action
|
||||
) => {
|
||||
return { ...action.payload, loaded: true };
|
||||
};
|
||||
|
||||
export const getGlobalConf = createAsyncThunk(
|
||||
"globalConf/set",
|
||||
getGlobalConfApi,
|
||||
{
|
||||
condition: (_, { getState }) =>
|
||||
!(getState() as RootState).globalConf.loaded,
|
||||
}
|
||||
);
|
||||
|
||||
export const globalConfSlice = createSlice({
|
||||
name: "globalConf",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(getGlobalConf.fulfilled, setGlobalConf);
|
||||
},
|
||||
});
|
||||
|
||||
export const platformConfSelector = (state: RootState) =>
|
||||
state.globalConf.platformConf;
|
||||
|
||||
export default globalConfSlice.reducer;
|
@ -1,36 +0,0 @@
|
||||
import {
|
||||
CaseReducer,
|
||||
createAsyncThunk,
|
||||
createSlice,
|
||||
PayloadAction,
|
||||
} from "@reduxjs/toolkit";
|
||||
import { SubscribeResp } from "src/utils/type";
|
||||
import { getSubscribe } from "src/api/config";
|
||||
import { RootState } from ".";
|
||||
const initialState: SubscribeResp = {};
|
||||
|
||||
const setSubs: CaseReducer<SubscribeResp, PayloadAction<SubscribeResp>> = (
|
||||
_,
|
||||
action
|
||||
) => {
|
||||
return action.payload;
|
||||
};
|
||||
|
||||
export const updateGroupSubs = createAsyncThunk(
|
||||
"groupConfig/update",
|
||||
getSubscribe
|
||||
);
|
||||
|
||||
export const groupConfigSlice = createSlice({
|
||||
name: "groupConfig",
|
||||
initialState,
|
||||
reducers: {
|
||||
setSubs,
|
||||
},
|
||||
extraReducers: (reducer) => {
|
||||
reducer.addCase(updateGroupSubs.fulfilled, setSubs);
|
||||
},
|
||||
});
|
||||
|
||||
export const groupConfigSelector = (state: RootState) => state.groupConfig;
|
||||
export default groupConfigSlice.reducer;
|
@ -1,5 +0,0 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||
import { AppDispatch, RootState } from ".";
|
||||
|
||||
export const useAppDispacher = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
@ -1,18 +0,0 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import loginSlice from "./loginSlice";
|
||||
import globalConfSlice from "./globalConfSlice";
|
||||
import groupConfigSlice from "./groupConfigSlice";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
login: loginSlice,
|
||||
globalConf: globalConfSlice,
|
||||
groupConfig: groupConfigSlice,
|
||||
},
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type Store = typeof store;
|
@ -1,121 +0,0 @@
|
||||
import {
|
||||
AnyAction,
|
||||
CaseReducer,
|
||||
createAsyncThunk,
|
||||
createSlice,
|
||||
PayloadAction,
|
||||
ThunkAction,
|
||||
} from "@reduxjs/toolkit";
|
||||
import jwt_decode from "jwt-decode";
|
||||
import { LoginStatus, TokenResp } from "src/utils/type";
|
||||
import { auth } from "src/api/config";
|
||||
import { RootState } from ".";
|
||||
|
||||
const initialState: LoginStatus = {
|
||||
login: false,
|
||||
type: "",
|
||||
name: "",
|
||||
id: "123",
|
||||
// groups: [],
|
||||
token: "",
|
||||
failed: false,
|
||||
};
|
||||
|
||||
interface storedInfo {
|
||||
type: string;
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const loginAction: CaseReducer<LoginStatus, PayloadAction<TokenResp>> = (
|
||||
_,
|
||||
action
|
||||
) => {
|
||||
return {
|
||||
login: true,
|
||||
failed: false,
|
||||
type: action.payload.type,
|
||||
name: action.payload.name,
|
||||
id: action.payload.id,
|
||||
token: action.payload.token,
|
||||
};
|
||||
};
|
||||
|
||||
export const login = createAsyncThunk(
|
||||
"auth/login",
|
||||
async (code: string) => {
|
||||
let res = await auth(code);
|
||||
if (res.status !== 200) {
|
||||
throw Error("Login Error");
|
||||
} else {
|
||||
localStorage.setItem(
|
||||
"loginInfo",
|
||||
JSON.stringify({
|
||||
type: res.type,
|
||||
name: res.name,
|
||||
id: res.id,
|
||||
})
|
||||
);
|
||||
localStorage.setItem("token", res.token);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
{
|
||||
condition: (_: string, { getState }) => {
|
||||
const { login } = getState() as { login: LoginStatus };
|
||||
return !login.login;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const loginSlice = createSlice({
|
||||
name: "auth",
|
||||
initialState,
|
||||
reducers: {
|
||||
doLogin: loginAction,
|
||||
doClearLogin: (state) => {
|
||||
state.login = false;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(login.fulfilled, loginAction);
|
||||
builder.addCase(login.rejected, (stat) => {
|
||||
stat.failed = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { doLogin, doClearLogin } = loginSlice.actions;
|
||||
|
||||
export const loadLoginState =
|
||||
(): ThunkAction<void, RootState, unknown, AnyAction> =>
|
||||
(dispatch, getState) => {
|
||||
if (getState().login.login) {
|
||||
return;
|
||||
}
|
||||
const infoJson = localStorage.getItem("loginInfo");
|
||||
const jwtToken = localStorage.getItem("token");
|
||||
if (infoJson && jwtToken) {
|
||||
const decodedJwt = jwt_decode(jwtToken) as { exp: number };
|
||||
if (decodedJwt.exp < Date.now() / 1000) {
|
||||
return;
|
||||
}
|
||||
const info = JSON.parse(infoJson) as storedInfo;
|
||||
const payload: TokenResp = {
|
||||
...info,
|
||||
status: 200,
|
||||
token: jwtToken,
|
||||
};
|
||||
dispatch(doLogin(payload));
|
||||
}
|
||||
};
|
||||
|
||||
export const clearLoginStatus =
|
||||
(): ThunkAction<void, RootState, unknown, AnyAction> => (dispatch) => {
|
||||
localStorage.removeItem("loginInfo");
|
||||
localStorage.removeItem("token");
|
||||
dispatch(doClearLogin());
|
||||
};
|
||||
export const loginSelector = (state: RootState) => state.login;
|
||||
|
||||
export default loginSlice.reducer;
|
@ -1,31 +1,10 @@
|
||||
interface QQGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LoginStatus {
|
||||
login: boolean;
|
||||
type: string;
|
||||
name: string;
|
||||
id: string;
|
||||
// groups: Array<QQGroup>
|
||||
export interface TokenResp {
|
||||
status: number;
|
||||
token: string;
|
||||
failed: boolean;
|
||||
type: string;
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type LoginContextType = {
|
||||
login: LoginStatus;
|
||||
save: (status: LoginStatus) => void;
|
||||
};
|
||||
|
||||
export interface SubscribeConfig {
|
||||
platformName: string;
|
||||
target: string;
|
||||
targetName: string;
|
||||
cats: Array<number>;
|
||||
tags: Array<string>;
|
||||
}
|
||||
|
||||
export interface GlobalConf {
|
||||
platformConf: AllPlatformConf;
|
||||
loaded: boolean;
|
||||
@ -47,12 +26,12 @@ export interface PlatformConfig {
|
||||
hasTarget: boolean;
|
||||
}
|
||||
|
||||
export interface TokenResp {
|
||||
status: number;
|
||||
token: string;
|
||||
type: string;
|
||||
id: string;
|
||||
name: string;
|
||||
export interface SubscribeConfig {
|
||||
platformName: string;
|
||||
target: string;
|
||||
targetName: string;
|
||||
cats: Array<number>;
|
||||
tags: Array<string>;
|
||||
}
|
||||
|
||||
export interface SubscribeGroupDetail {
|
||||
@ -64,6 +43,30 @@ export interface SubscribeResp {
|
||||
[idx: string]: SubscribeGroupDetail;
|
||||
}
|
||||
|
||||
export interface TargetNameResp {
|
||||
targetName: string;
|
||||
export interface StatusResp {
|
||||
status: number;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
export interface SubmitParam {
|
||||
groupNumber: number;
|
||||
sub: SubscribeConfig;
|
||||
}
|
||||
|
||||
export interface TimeWeightConfig {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface WeightConfig {
|
||||
default: number;
|
||||
time_config: TimeWeightConfig[];
|
||||
}
|
||||
|
||||
export interface PlatformWeightConfigResp {
|
||||
target: string;
|
||||
target_name: string;
|
||||
platform_name: string;
|
||||
weight: WeightConfig;
|
||||
}
|
||||
|
4
admin-frontend/src/utils/urls.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const baseUrl = '/bison/api/';
|
||||
export const authUrl = `${baseUrl}auth`;
|
||||
export const globalConfUrl = `${baseUrl}global_conf`;
|
||||
export const subsribeUrl = `${baseUrl}subs`;
|
@ -18,8 +18,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "./"
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
109
alembic.ini
Normal file
@ -0,0 +1,109 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = src/plugins/nonebot_bison/config/migrate
|
||||
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = ./src/plugins
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to src/plugins/nonebot_bison/config/migrate/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:src/plugins/nonebot_bison/config/migrate/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = sqlite:///data/data.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
hooks = pre-commit
|
||||
|
||||
pre-commit.type = console_scripts
|
||||
pre-commit.entrypoint = pre-commit
|
||||
pre-commit.options = run --files REVISION_SCRIPT_FILENAME
|
||||
pre-commit.cwd = %(here)s
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
14
codecov.yml
Normal file
@ -0,0 +1,14 @@
|
||||
coverage:
|
||||
status:
|
||||
project: off
|
||||
patch: off
|
||||
|
||||
flag_management:
|
||||
default_rules:
|
||||
carryforward: true
|
||||
statuses:
|
||||
- type: project
|
||||
target: auto
|
||||
threshold: 5%
|
||||
- type: patch
|
||||
target: 60%
|
@ -1,8 +1,33 @@
|
||||
# syntax=docker/dockerfile:1.2
|
||||
FROM python:3.9 as base
|
||||
FROM python:3.10-slim as base
|
||||
|
||||
FROM base as builder
|
||||
|
||||
ENV PYTHONFAULTHANDLER=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONHASHSEED=random \
|
||||
PIP_NO_CACHE_DIR=off \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=on \
|
||||
PIP_DEFAULT_TIMEOUT=100 \
|
||||
POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=false \
|
||||
PATH="$PATH:/runtime/bin" \
|
||||
PYTHONPATH="$PYTHONPATH:/runtime/lib/python3.10/site-packages" \
|
||||
# Versions:
|
||||
POETRY_VERSION=1.1.14
|
||||
RUN apt-get update && apt-get install -y build-essential unzip wget python-dev git
|
||||
RUN pip install "poetry==$POETRY_VERSION"
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY pyproject.toml poetry.lock /src/
|
||||
|
||||
RUN poetry export --without-hashes --no-interaction --no-ansi -f requirements.txt -o requirements.txt
|
||||
RUN pip install --prefix=/runtime --force-reinstall -r requirements.txt
|
||||
|
||||
FROM base as runtime
|
||||
|
||||
WORKDIR /app
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m pip install poetry && poetry config virtualenvs.create false
|
||||
RUN --mount=type=cache,target=/var/cache/apt \
|
||||
--mount=type=cache,target=/var/lib/apt \
|
||||
apt-get update && apt-get install -y xvfb fonts-noto-color-emoji ttf-unifont \
|
||||
@ -12,12 +37,12 @@ RUN --mount=type=cache,target=/var/cache/apt \
|
||||
libcairo2 libcups2 libdbus-1-3 libdrm2 libegl1 libgbm1 libglib2.0-0 libgtk-3-0 \
|
||||
libnspr4 libnss3 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
|
||||
libxdamage1 libxext6 libxfixes3 libxrandr2 libxshmfence1
|
||||
COPY ./pyproject.toml ./poetry.lock* /app/
|
||||
RUN --mount=type=cache,target=/root/.cache/pypoetry \
|
||||
poetry install --no-dev --no-root
|
||||
|
||||
COPY --from=builder /runtime /usr/local
|
||||
RUN playwright install chromium
|
||||
ADD src /app/src
|
||||
ADD bot.py /app/
|
||||
RUN echo 'DATASTORE_DATA_DIR=/data' > .env
|
||||
ENV HOST=0.0.0.0
|
||||
CMD ["python", "bot.py"]
|
||||
|
||||
|
@ -1,8 +1,33 @@
|
||||
# syntax=docker/dockerfile:1.2
|
||||
FROM python:3.9 as base
|
||||
FROM python:3.10-slim as base
|
||||
|
||||
FROM base as builder
|
||||
|
||||
ENV PYTHONFAULTHANDLER=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONHASHSEED=random \
|
||||
PIP_NO_CACHE_DIR=off \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=on \
|
||||
PIP_DEFAULT_TIMEOUT=100 \
|
||||
POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=false \
|
||||
PATH="$PATH:/runtime/bin" \
|
||||
PYTHONPATH="$PYTHONPATH:/runtime/lib/python3.10/site-packages" \
|
||||
# Versions:
|
||||
POETRY_VERSION=1.1.14
|
||||
RUN apt-get update && apt-get install -y build-essential unzip wget python-dev git
|
||||
RUN pip install "poetry==$POETRY_VERSION"
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY pyproject.toml poetry.lock /src/
|
||||
|
||||
RUN poetry add nonebot-plugin-sentry && poetry export --without-hashes --no-interaction --no-ansi -f requirements.txt -o requirements.txt
|
||||
RUN pip install --prefix=/runtime --force-reinstall -r requirements.txt
|
||||
|
||||
FROM base as runtime
|
||||
|
||||
WORKDIR /app
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m pip install poetry && poetry config virtualenvs.create false
|
||||
RUN --mount=type=cache,target=/var/cache/apt \
|
||||
--mount=type=cache,target=/var/lib/apt \
|
||||
apt-get update && apt-get install -y xvfb fonts-noto-color-emoji ttf-unifont \
|
||||
@ -12,15 +37,13 @@ RUN --mount=type=cache,target=/var/cache/apt \
|
||||
libcairo2 libcups2 libdbus-1-3 libdrm2 libegl1 libgbm1 libglib2.0-0 libgtk-3-0 \
|
||||
libnspr4 libnss3 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
|
||||
libxdamage1 libxext6 libxfixes3 libxrandr2 libxshmfence1
|
||||
COPY ./pyproject.toml ./poetry.lock* ./bot.py /app/
|
||||
RUN --mount=type=cache,target=/root/.cache/pypoetry \
|
||||
poetry add nonebot-plugin-sentry && \
|
||||
sed '/nonebot.load_builtin_plugins("echo")/a nonebot.load_plugin("nonebot_plugin_sentry")' -i bot.py
|
||||
RUN --mount=type=cache,target=/root/.cache/pypoetry \
|
||||
poetry install --no-dev --no-root
|
||||
|
||||
COPY --from=builder /runtime /usr/local
|
||||
RUN playwright install chromium
|
||||
ADD src /app/src
|
||||
ADD bot.py /app/
|
||||
RUN echo 'DATASTORE_DATA_DIR=/data' > .env && sed '/nonebot.load_builtin_plugins("echo")/a nonebot.load_plugin("nonebot_plugin_sentry")' -i bot.py
|
||||
ENV HOST=0.0.0.0
|
||||
CMD ["python", "bot.py"]
|
||||
|
||||
# vim: set ft=dockerfile:
|
||||
# vim: ft=dockerfile
|
||||
|
1157
poetry.lock
generated
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "nonebot-bison"
|
||||
version = "0.5.4"
|
||||
version = "0.6.1"
|
||||
description = "Subscribe message from social medias"
|
||||
authors = ["felinae98 <felinae225@qq.com>"]
|
||||
license = "MIT"
|
||||
@ -23,8 +23,8 @@ classifiers = [
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9"
|
||||
nonebot2 = ">=2.0.0-beta.2"
|
||||
python = ">=3.10,<4.0.0"
|
||||
nonebot2 = "^2.0.0-rc.1"
|
||||
httpx = ">=0.16.1"
|
||||
bs4 = "^0.0.1"
|
||||
tinydb = "^4.3.0"
|
||||
@ -36,7 +36,9 @@ pyjwt = "^2.1.0"
|
||||
aiofiles = "^0.8.0"
|
||||
python-socketio = "^5.4.0"
|
||||
nonebot-adapter-onebot = "^2.0.0-beta.1"
|
||||
nonebot-plugin-htmlrender = "^0.0.4"
|
||||
nonebot-plugin-htmlrender = "^0.1.1"
|
||||
nonebot-plugin-datastore = "^0.4.0"
|
||||
alembic = "^1.7.6"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
ipdb = "^0.13.4"
|
||||
@ -49,6 +51,7 @@ black = "^22.1.0"
|
||||
isort = "^5.10.1"
|
||||
pre-commit = "^2.17.0"
|
||||
flaky = "^3.7.0"
|
||||
sqlalchemy-stubs = "^0.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
@ -63,7 +66,7 @@ asyncio_mode = "auto"
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ["py39", "py310"]
|
||||
target-version = ["py310"]
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
'''
|
||||
|
@ -1,5 +1,8 @@
|
||||
from nonebot.plugin import require
|
||||
|
||||
from . import (
|
||||
admin_page,
|
||||
bootstrap,
|
||||
config,
|
||||
config_manager,
|
||||
platform,
|
||||
@ -11,6 +14,20 @@ from . import (
|
||||
)
|
||||
from .plugin_config import plugin_config
|
||||
|
||||
require("nonebot_plugin_localstore")
|
||||
|
||||
__help__version__ = "0.4.3"
|
||||
__help__plugin__name__ = "nonebot_bison"
|
||||
__usage__ = f"本bot可以提供b站、微博等社交媒体的消息订阅,详情请查看本bot文档,或者{'at本bot' if plugin_config.bison_to_me else '' }发送“添加订阅”订阅第一个帐号,发送“查询订阅”或“删除订阅”管理订阅"
|
||||
|
||||
__all__ = [
|
||||
"admin_page",
|
||||
"config",
|
||||
"config_manager",
|
||||
"post",
|
||||
"scheduler",
|
||||
"send",
|
||||
"platform",
|
||||
"types",
|
||||
"utils",
|
||||
]
|
||||
|
@ -14,6 +14,7 @@ from nonebot.rule import to_me
|
||||
from nonebot.typing import T_State
|
||||
|
||||
from ..plugin_config import plugin_config
|
||||
from ..types import WeightConfig
|
||||
from .api import (
|
||||
add_group_sub,
|
||||
auth,
|
||||
@ -21,8 +22,10 @@ from .api import (
|
||||
get_global_conf,
|
||||
get_subs_info,
|
||||
get_target_name,
|
||||
get_weight_config,
|
||||
test,
|
||||
update_group_sub,
|
||||
update_weigth_config,
|
||||
)
|
||||
from .jwt import load_jwt
|
||||
from .token_manager import token_manager as tm
|
||||
@ -32,6 +35,7 @@ GLOBAL_CONF_URL = f"{URL_BASE}api/global_conf"
|
||||
AUTH_URL = f"{URL_BASE}api/auth"
|
||||
SUBSCRIBE_URL = f"{URL_BASE}api/subs"
|
||||
GET_TARGET_NAME_URL = f"{URL_BASE}api/target_name"
|
||||
WEIGHT_URL = f"{URL_BASE}api/weight"
|
||||
TEST_URL = f"{URL_BASE}test"
|
||||
|
||||
STATIC_PATH = (Path(__file__).parent / "dist").resolve()
|
||||
@ -66,7 +70,7 @@ def register_router_fastapi(driver: Driver, socketio):
|
||||
return obj
|
||||
|
||||
async def check_group_permission(
|
||||
groupNumber: str, token_obj: dict = Depends(get_jwt_obj)
|
||||
groupNumber: int, token_obj: dict = Depends(get_jwt_obj)
|
||||
):
|
||||
groups = token_obj["groups"]
|
||||
for group in groups:
|
||||
@ -74,6 +78,10 @@ def register_router_fastapi(driver: Driver, socketio):
|
||||
return
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
async def check_is_superuser(token_obj: dict = Depends(get_jwt_obj)):
|
||||
if token_obj.get("type") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@dataclass
|
||||
class AddSubscribeReq:
|
||||
platformName: str
|
||||
@ -99,7 +107,7 @@ def register_router_fastapi(driver: Driver, socketio):
|
||||
return await get_target_name(platformName, target, jwt_obj)
|
||||
|
||||
@app.post(SUBSCRIBE_URL, dependencies=[Depends(check_group_permission)])
|
||||
async def _add_group_subs(groupNumber: str, req: AddSubscribeReq):
|
||||
async def _add_group_subs(groupNumber: int, req: AddSubscribeReq):
|
||||
return await add_group_sub(
|
||||
group_number=groupNumber,
|
||||
platform_name=req.platformName,
|
||||
@ -110,7 +118,7 @@ def register_router_fastapi(driver: Driver, socketio):
|
||||
)
|
||||
|
||||
@app.patch(SUBSCRIBE_URL, dependencies=[Depends(check_group_permission)])
|
||||
async def _update_group_subs(groupNumber: str, req: AddSubscribeReq):
|
||||
async def _update_group_subs(groupNumber: int, req: AddSubscribeReq):
|
||||
return await update_group_sub(
|
||||
group_number=groupNumber,
|
||||
platform_name=req.platformName,
|
||||
@ -121,9 +129,17 @@ def register_router_fastapi(driver: Driver, socketio):
|
||||
)
|
||||
|
||||
@app.delete(SUBSCRIBE_URL, dependencies=[Depends(check_group_permission)])
|
||||
async def _del_group_subs(groupNumber: str, target: str, platformName: str):
|
||||
async def _del_group_subs(groupNumber: int, target: str, platformName: str):
|
||||
return await del_group_sub(groupNumber, platformName, target)
|
||||
|
||||
@app.get(WEIGHT_URL, dependencies=[Depends(check_is_superuser)])
|
||||
async def _get_weight_config():
|
||||
return await get_weight_config()
|
||||
|
||||
@app.put(WEIGHT_URL, dependencies=[Depends(check_is_superuser)])
|
||||
async def _update_weight_config(platform_name: str, target: str, req: WeightConfig):
|
||||
return await update_weigth_config(platform_name, target, req)
|
||||
|
||||
app.mount(URL_BASE, SinglePageApplication(directory=static_path), name="bison")
|
||||
|
||||
|
||||
|
@ -1,8 +1,16 @@
|
||||
import nonebot
|
||||
from nonebot.adapters.onebot.v11.bot import Bot
|
||||
|
||||
from ..config import Config, NoSuchSubscribeException, NoSuchUserException
|
||||
from ..config import (
|
||||
NoSuchSubscribeException,
|
||||
NoSuchTargetException,
|
||||
NoSuchUserException,
|
||||
config,
|
||||
)
|
||||
from ..config.db_config import SubscribeDupException
|
||||
from ..platform import check_sub_target, platform_manager
|
||||
from ..types import Target as T_Target
|
||||
from ..types import WeightConfig
|
||||
from .jwt import pack_jwt
|
||||
from .token_manager import token_manager
|
||||
|
||||
@ -45,7 +53,8 @@ async def auth(token: str):
|
||||
groups = await bot.call_api("get_group_list")
|
||||
if str(qq) in nonebot.get_driver().config.superusers:
|
||||
jwt_obj = {
|
||||
"id": str(qq),
|
||||
"id": qq,
|
||||
"type": "admin",
|
||||
"groups": list(
|
||||
map(
|
||||
lambda info: {
|
||||
@ -59,23 +68,23 @@ async def auth(token: str):
|
||||
ret_obj = {
|
||||
"type": "admin",
|
||||
"name": nickname,
|
||||
"id": str(qq),
|
||||
"id": qq,
|
||||
"token": pack_jwt(jwt_obj),
|
||||
}
|
||||
return {"status": 200, **ret_obj}
|
||||
if admin_groups := await get_admin_groups(int(qq)):
|
||||
jwt_obj = {"id": str(qq), "groups": admin_groups}
|
||||
jwt_obj = {"id": str(qq), "type": "user", "groups": admin_groups}
|
||||
ret_obj = {
|
||||
"type": "user",
|
||||
"name": nickname,
|
||||
"id": str(qq),
|
||||
"id": qq,
|
||||
"token": pack_jwt(jwt_obj),
|
||||
}
|
||||
return {"status": 200, **ret_obj}
|
||||
else:
|
||||
return {"status": 400, "type": "", "name": "", "id": "", "token": ""}
|
||||
return {"status": 400, "type": "", "name": "", "id": 0, "token": ""}
|
||||
else:
|
||||
return {"status": 400, "type": "", "name": "", "id": "", "token": ""}
|
||||
return {"status": 400, "type": "", "name": "", "id": 0, "token": ""}
|
||||
|
||||
|
||||
async def get_subs_info(jwt_obj: dict):
|
||||
@ -83,17 +92,17 @@ async def get_subs_info(jwt_obj: dict):
|
||||
res = {}
|
||||
for group in groups:
|
||||
group_id = group["id"]
|
||||
config = Config()
|
||||
raw_subs = await config.list_subscribe(group_id, "group")
|
||||
subs = list(
|
||||
map(
|
||||
lambda sub: {
|
||||
"platformName": sub["target_type"],
|
||||
"target": sub["target"],
|
||||
"targetName": sub["target_name"],
|
||||
"cats": sub["cats"],
|
||||
"tags": sub["tags"],
|
||||
"platformName": sub.target.platform_name,
|
||||
"targetName": sub.target.target_name,
|
||||
"cats": sub.categories,
|
||||
"tags": sub.tags,
|
||||
"target": sub.target.target,
|
||||
},
|
||||
config.list_subscribe(group_id, "group"),
|
||||
raw_subs,
|
||||
)
|
||||
)
|
||||
res[group_id] = {"name": group["name"], "subscribes": subs}
|
||||
@ -105,42 +114,64 @@ async def get_target_name(platform_name: str, target: str, jwt_obj: dict):
|
||||
|
||||
|
||||
async def add_group_sub(
|
||||
group_number: str,
|
||||
group_number: int,
|
||||
platform_name: str,
|
||||
target: str,
|
||||
target_name: str,
|
||||
cats: list[int],
|
||||
tags: list[str],
|
||||
):
|
||||
config = Config()
|
||||
config.add_subscribe(
|
||||
int(group_number), "group", target, target_name, platform_name, cats, tags
|
||||
)
|
||||
return {"status": 200, "msg": ""}
|
||||
|
||||
|
||||
async def del_group_sub(group_number: str, platform_name: str, target: str):
|
||||
config = Config()
|
||||
try:
|
||||
config.del_subscribe(int(group_number), "group", target, platform_name)
|
||||
await config.add_subscribe(
|
||||
int(group_number),
|
||||
"group",
|
||||
T_Target(target),
|
||||
target_name,
|
||||
platform_name,
|
||||
cats,
|
||||
tags,
|
||||
)
|
||||
return {"status": 200, "msg": ""}
|
||||
except SubscribeDupException:
|
||||
return {"status": 403, "msg": ""}
|
||||
|
||||
|
||||
async def del_group_sub(group_number: int, platform_name: str, target: str):
|
||||
try:
|
||||
await config.del_subscribe(int(group_number), "group", target, platform_name)
|
||||
except (NoSuchUserException, NoSuchSubscribeException):
|
||||
return {"status": 400, "msg": "删除错误"}
|
||||
return {"status": 200, "msg": ""}
|
||||
|
||||
|
||||
async def update_group_sub(
|
||||
group_number: str,
|
||||
group_number: int,
|
||||
platform_name: str,
|
||||
target: str,
|
||||
target_name: str,
|
||||
cats: list[int],
|
||||
tags: list[str],
|
||||
):
|
||||
config = Config()
|
||||
try:
|
||||
config.update_subscribe(
|
||||
await config.update_subscribe(
|
||||
int(group_number), "group", target, target_name, platform_name, cats, tags
|
||||
)
|
||||
except (NoSuchUserException, NoSuchSubscribeException):
|
||||
return {"status": 400, "msg": "更新错误"}
|
||||
return {"status": 200, "msg": ""}
|
||||
|
||||
|
||||
async def get_weight_config():
|
||||
return await config.get_all_weight_config()
|
||||
|
||||
|
||||
async def update_weigth_config(
|
||||
platform_name: str, target: str, weight_config: WeightConfig
|
||||
):
|
||||
try:
|
||||
await config.update_time_weight_config(
|
||||
T_Target(target), platform_name, weight_config
|
||||
)
|
||||
except NoSuchTargetException:
|
||||
return {"status": 400, "msg": "该订阅不存在"}
|
||||
return {"status": 200, "msg": ""}
|
||||
|
20
src/plugins/nonebot_bison/bootstrap.py
Normal file
@ -0,0 +1,20 @@
|
||||
from nonebot import get_driver
|
||||
from nonebot.log import logger
|
||||
|
||||
from .config.config_legacy import start_up as legacy_db_startup
|
||||
from .config.db import upgrade_db
|
||||
from .scheduler.aps import start_scheduler
|
||||
from .scheduler.manager import init_scheduler
|
||||
|
||||
|
||||
@get_driver().on_startup
|
||||
async def bootstrap():
|
||||
# legacy db
|
||||
legacy_db_startup()
|
||||
# new db
|
||||
await upgrade_db()
|
||||
# init scheduler
|
||||
await init_scheduler()
|
||||
# start scheduler
|
||||
start_scheduler()
|
||||
logger.info("nonebot-bison bootstrap done")
|
3
src/plugins/nonebot_bison/config/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .db import DATA
|
||||
from .db_config import config
|
||||
from .utils import NoSuchSubscribeException, NoSuchTargetException, NoSuchUserException
|
@ -1,21 +1,25 @@
|
||||
import json
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
from typing import DefaultDict, Literal, Mapping, TypedDict
|
||||
|
||||
import nonebot
|
||||
from nonebot.log import logger
|
||||
from tinydb import Query, TinyDB
|
||||
|
||||
from .platform import platform_manager
|
||||
from .plugin_config import plugin_config
|
||||
from .types import Target, User
|
||||
from .utils import Singleton
|
||||
from ..platform import platform_manager
|
||||
from ..plugin_config import plugin_config
|
||||
from ..types import Target, User
|
||||
from ..utils import Singleton
|
||||
from .utils import NoSuchSubscribeException, NoSuchUserException
|
||||
|
||||
supported_target_type = platform_manager.keys()
|
||||
|
||||
|
||||
def get_config_path() -> str:
|
||||
def get_config_path() -> tuple[str, str]:
|
||||
if plugin_config.bison_config_path:
|
||||
data_dir = plugin_config.bison_config_path
|
||||
else:
|
||||
@ -25,17 +29,30 @@ def get_config_path() -> str:
|
||||
os.makedirs(data_dir)
|
||||
old_path = path.join(data_dir, "hk_reporter.json")
|
||||
new_path = path.join(data_dir, "bison.json")
|
||||
deprecated_maker_path = path.join(data_dir, "bison.json.deprecated")
|
||||
if os.path.exists(old_path) and not os.path.exists(new_path):
|
||||
os.rename(old_path, new_path)
|
||||
return new_path
|
||||
return new_path, deprecated_maker_path
|
||||
|
||||
|
||||
class NoSuchUserException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoSuchSubscribeException(Exception):
|
||||
pass
|
||||
def drop():
|
||||
if plugin_config.bison_config_path:
|
||||
data_dir = plugin_config.bison_config_path
|
||||
else:
|
||||
working_dir = os.getcwd()
|
||||
data_dir = path.join(working_dir, "data")
|
||||
old_path = path.join(data_dir, "bison.json")
|
||||
deprecated_marker_path = path.join(data_dir, "bison.json.deprecated")
|
||||
if os.path.exists(old_path):
|
||||
config.db.close()
|
||||
config.available = False
|
||||
with open(deprecated_marker_path, "w") as file:
|
||||
content = {
|
||||
"migration_time": datetime.now().isoformat(),
|
||||
}
|
||||
file.write(json.dumps(content))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class SubscribeContent(TypedDict):
|
||||
@ -47,24 +64,35 @@ class SubscribeContent(TypedDict):
|
||||
|
||||
|
||||
class ConfigContent(TypedDict):
|
||||
user: str
|
||||
user: int
|
||||
user_type: Literal["group", "private"]
|
||||
subs: list[SubscribeContent]
|
||||
|
||||
|
||||
class Config(metaclass=Singleton):
|
||||
"Dropping it!"
|
||||
|
||||
migrate_version = 2
|
||||
|
||||
def __init__(self):
|
||||
self.db = TinyDB(get_config_path(), encoding="utf-8")
|
||||
self.kv_config = self.db.table("kv")
|
||||
self.user_target = self.db.table("user_target")
|
||||
self.target_user_cache: dict[str, defaultdict[Target, list[User]]] = {}
|
||||
self.target_user_cat_cache = {}
|
||||
self.target_user_tag_cache = {}
|
||||
self.target_list = {}
|
||||
self.next_index: DefaultDict[str, int] = defaultdict(lambda: 0)
|
||||
self._do_init()
|
||||
|
||||
def _do_init(self):
|
||||
path, deprecated_marker_path = get_config_path()
|
||||
if Path(deprecated_marker_path).exists():
|
||||
self.available = False
|
||||
elif Path(path).exists():
|
||||
self.available = True
|
||||
self.db = TinyDB(path, encoding="utf-8")
|
||||
self.kv_config = self.db.table("kv")
|
||||
self.user_target = self.db.table("user_target")
|
||||
self.target_user_cache: dict[str, defaultdict[Target, list[User]]] = {}
|
||||
self.target_user_cat_cache = {}
|
||||
self.target_user_tag_cache = {}
|
||||
self.target_list = {}
|
||||
self.next_index: DefaultDict[str, int] = defaultdict(lambda: 0)
|
||||
else:
|
||||
self.available = False
|
||||
|
||||
def add_subscribe(
|
||||
self, user, user_type, target, target_name, target_type, cats, tags
|
||||
@ -220,6 +248,8 @@ class Config(metaclass=Singleton):
|
||||
|
||||
def start_up():
|
||||
config = Config()
|
||||
if not config.available:
|
||||
return
|
||||
if not (search_res := config.kv_config.search(Query().name == "version")):
|
||||
config.kv_config.insert({"name": "version", "value": config.migrate_version})
|
||||
elif search_res[0].get("value") < config.migrate_version:
|
||||
@ -240,4 +270,4 @@ def start_up():
|
||||
config.update_send_cache()
|
||||
|
||||
|
||||
nonebot.get_driver().on_startup(start_up)
|
||||
config = Config()
|
109
src/plugins/nonebot_bison/config/db.py
Normal file
@ -0,0 +1,109 @@
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.environment import EnvironmentContext
|
||||
from alembic.script.base import ScriptDirectory
|
||||
from nonebot.log import logger
|
||||
from nonebot_plugin_datastore import PluginData, db
|
||||
from nonebot_plugin_datastore.db import get_engine
|
||||
from sqlalchemy.engine.base import Connection
|
||||
from sqlalchemy.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .config_legacy import ConfigContent, config, drop
|
||||
from .db_model import Base, Subscribe, Target, User
|
||||
|
||||
DATA = PluginData("bison")
|
||||
|
||||
|
||||
async def data_migrate():
|
||||
if config.available:
|
||||
logger.warning("You are still using legacy db, migrating to sqlite")
|
||||
all_subs: list[ConfigContent] = list(
|
||||
map(lambda item: ConfigContent(**item), config.get_all_subscribe().all())
|
||||
)
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
user_to_create = []
|
||||
subscribe_to_create = []
|
||||
platform_target_map: dict[str, tuple[Target, str, int]] = {}
|
||||
for user in all_subs:
|
||||
db_user = User(uid=user["user"], type=user["user_type"])
|
||||
user_to_create.append(db_user)
|
||||
user_sub_set = set()
|
||||
for sub in user["subs"]:
|
||||
target = sub["target"]
|
||||
platform_name = sub["target_type"]
|
||||
target_name = sub["target_name"]
|
||||
key = f"{target}-{platform_name}"
|
||||
if key in user_sub_set:
|
||||
# a user subscribe a target twice
|
||||
logger.error(
|
||||
f"用户 {user['user_type']}-{user['user']} 订阅了 {platform_name}-{target_name} 两次,"
|
||||
"随机采用了一个订阅"
|
||||
)
|
||||
continue
|
||||
user_sub_set.add(key)
|
||||
if key in platform_target_map.keys():
|
||||
target_obj, ext_user_type, ext_user = platform_target_map[key]
|
||||
if target_obj.target_name != target_name:
|
||||
# GG
|
||||
logger.error(
|
||||
f"你的旧版本数据库中存在数据不一致问题,请完成迁移后执行重新添加{platform_name}平台的{target}"
|
||||
f"它的名字可能为{target_obj.target_name}或{target_name}"
|
||||
)
|
||||
|
||||
else:
|
||||
target_obj = Target(
|
||||
platform_name=platform_name,
|
||||
target_name=target_name,
|
||||
target=target,
|
||||
)
|
||||
platform_target_map[key] = (
|
||||
target_obj,
|
||||
user["user_type"],
|
||||
user["user"],
|
||||
)
|
||||
subscribe_obj = Subscribe(
|
||||
user=db_user,
|
||||
target=target_obj,
|
||||
categories=sub["cats"],
|
||||
tags=sub["tags"],
|
||||
)
|
||||
subscribe_to_create.append(subscribe_obj)
|
||||
sess.add_all(
|
||||
user_to_create
|
||||
+ list(map(lambda x: x[0], platform_target_map.values()))
|
||||
+ subscribe_to_create
|
||||
)
|
||||
await sess.commit()
|
||||
drop()
|
||||
logger.info("migrate success")
|
||||
|
||||
|
||||
async def upgrade_db():
|
||||
alembic_cfg = Config()
|
||||
alembic_cfg.set_main_option(
|
||||
"script_location", str(Path(__file__).parent.joinpath("migrate"))
|
||||
)
|
||||
|
||||
script = ScriptDirectory.from_config(alembic_cfg)
|
||||
engine = db.get_engine()
|
||||
env = EnvironmentContext(alembic_cfg, script)
|
||||
|
||||
def migrate_fun(revision, context):
|
||||
return script._upgrade_revs("head", revision)
|
||||
|
||||
def do_run_migration(connection: Connection):
|
||||
env.configure(
|
||||
connection,
|
||||
target_metadata=Base.metadata,
|
||||
fn=migrate_fun,
|
||||
render_as_batch=True,
|
||||
)
|
||||
with env.begin_transaction():
|
||||
env.run_migrations()
|
||||
logger.info("Finish auto migrate")
|
||||
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run_migration)
|
||||
|
||||
await data_migrate()
|
305
src/plugins/nonebot_bison/config/db_config.py
Normal file
@ -0,0 +1,305 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, time
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from nonebot_plugin_datastore.db import get_engine
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio.session import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.sql.expression import delete, select
|
||||
from sqlalchemy.sql.functions import func
|
||||
|
||||
from ..types import Category, PlatformWeightConfigResp, Tag
|
||||
from ..types import Target as T_Target
|
||||
from ..types import TimeWeightConfig
|
||||
from ..types import User as T_User
|
||||
from ..types import UserSubInfo, WeightConfig
|
||||
from .db_model import ScheduleTimeWeight, Subscribe, Target, User
|
||||
from .utils import NoSuchTargetException
|
||||
|
||||
|
||||
def _get_time():
|
||||
dt = datetime.now()
|
||||
cur_time = time(hour=dt.hour, minute=dt.minute, second=dt.second)
|
||||
return cur_time
|
||||
|
||||
|
||||
class SubscribeDupException(Exception):
|
||||
...
|
||||
|
||||
|
||||
class DBConfig:
|
||||
def __init__(self):
|
||||
self.add_target_hook: Optional[Callable[[str, T_Target], Awaitable]] = None
|
||||
self.delete_target_hook: Optional[Callable[[str, T_Target], Awaitable]] = None
|
||||
|
||||
def register_add_target_hook(self, fun: Callable[[str, T_Target], Awaitable]):
|
||||
self.add_target_hook = fun
|
||||
|
||||
def register_delete_target_hook(self, fun: Callable[[str, T_Target], Awaitable]):
|
||||
self.delete_target_hook = fun
|
||||
|
||||
async def add_subscribe(
|
||||
self,
|
||||
user: int,
|
||||
user_type: str,
|
||||
target: T_Target,
|
||||
target_name: str,
|
||||
platform_name: str,
|
||||
cats: list[Category],
|
||||
tags: list[Tag],
|
||||
):
|
||||
async with AsyncSession(get_engine()) as session:
|
||||
db_user_stmt = (
|
||||
select(User).where(User.uid == user).where(User.type == user_type)
|
||||
)
|
||||
db_user: Optional[User] = await session.scalar(db_user_stmt)
|
||||
if not db_user:
|
||||
db_user = User(uid=user, type=user_type)
|
||||
session.add(db_user)
|
||||
db_target_stmt = (
|
||||
select(Target)
|
||||
.where(Target.platform_name == platform_name)
|
||||
.where(Target.target == target)
|
||||
)
|
||||
db_target: Optional[Target] = await session.scalar(db_target_stmt)
|
||||
if not db_target:
|
||||
db_target = Target(
|
||||
target=target, platform_name=platform_name, target_name=target_name
|
||||
)
|
||||
if self.add_target_hook:
|
||||
await self.add_target_hook(platform_name, target)
|
||||
else:
|
||||
db_target.target_name = target_name # type: ignore
|
||||
subscribe = Subscribe(
|
||||
categories=cats,
|
||||
tags=tags,
|
||||
user=db_user,
|
||||
target=db_target,
|
||||
)
|
||||
session.add(subscribe)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as e:
|
||||
if len(e.args) > 0 and "UNIQUE constraint failed" in e.args[0]:
|
||||
raise SubscribeDupException()
|
||||
raise e
|
||||
|
||||
async def list_subscribe(self, user: int, user_type: str) -> list[Subscribe]:
|
||||
async with AsyncSession(get_engine()) as session:
|
||||
query_stmt = (
|
||||
select(Subscribe)
|
||||
.where(User.type == user_type, User.uid == user)
|
||||
.join(User)
|
||||
.options(selectinload(Subscribe.target)) # type:ignore
|
||||
)
|
||||
subs: list[Subscribe] = (await session.scalars(query_stmt)).all()
|
||||
return subs
|
||||
|
||||
async def del_subscribe(
|
||||
self, user: int, user_type: str, target: str, platform_name: str
|
||||
):
|
||||
async with AsyncSession(get_engine()) as session:
|
||||
user_obj = await session.scalar(
|
||||
select(User).where(User.uid == user, User.type == user_type)
|
||||
)
|
||||
target_obj = await session.scalar(
|
||||
select(Target).where(
|
||||
Target.platform_name == platform_name, Target.target == target
|
||||
)
|
||||
)
|
||||
await session.execute(
|
||||
delete(Subscribe).where(
|
||||
Subscribe.user == user_obj, Subscribe.target == target_obj
|
||||
)
|
||||
)
|
||||
target_count = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Subscribe)
|
||||
.where(Subscribe.target == target_obj)
|
||||
)
|
||||
if target_count == 0:
|
||||
# delete empty target
|
||||
# await session.delete(target_obj)
|
||||
if self.delete_target_hook:
|
||||
await self.delete_target_hook(platform_name, T_Target(target))
|
||||
await session.commit()
|
||||
|
||||
async def update_subscribe(
|
||||
self,
|
||||
user: int,
|
||||
user_type: str,
|
||||
target: str,
|
||||
target_name: str,
|
||||
platform_name: str,
|
||||
cats: list,
|
||||
tags: list,
|
||||
):
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
subscribe_obj: Subscribe = await sess.scalar(
|
||||
select(Subscribe)
|
||||
.where(
|
||||
User.uid == user,
|
||||
User.type == user_type,
|
||||
Target.target == target,
|
||||
Target.platform_name == platform_name,
|
||||
)
|
||||
.join(User)
|
||||
.join(Target)
|
||||
.options(selectinload(Subscribe.target)) # type:ignore
|
||||
)
|
||||
subscribe_obj.tags = tags # type:ignore
|
||||
subscribe_obj.categories = cats # type:ignore
|
||||
subscribe_obj.target.target_name = target_name
|
||||
await sess.commit()
|
||||
|
||||
async def get_platform_target(self, platform_name: str) -> list[Target]:
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
subq = select(Subscribe.target_id).distinct().subquery()
|
||||
query = (
|
||||
select(Target).join(subq).where(Target.platform_name == platform_name)
|
||||
)
|
||||
return (await sess.scalars(query)).all()
|
||||
|
||||
async def get_time_weight_config(
|
||||
self, target: T_Target, platform_name: str
|
||||
) -> WeightConfig:
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
time_weight_conf: list[ScheduleTimeWeight] = (
|
||||
await sess.scalars(
|
||||
select(ScheduleTimeWeight)
|
||||
.where(
|
||||
Target.platform_name == platform_name, Target.target == target
|
||||
)
|
||||
.join(Target)
|
||||
)
|
||||
).all()
|
||||
targetObj: Target = await sess.scalar(
|
||||
select(Target).where(
|
||||
Target.platform_name == platform_name, Target.target == target
|
||||
)
|
||||
)
|
||||
return WeightConfig(
|
||||
default=targetObj.default_schedule_weight,
|
||||
time_config=[
|
||||
TimeWeightConfig(
|
||||
start_time=time_conf.start_time,
|
||||
end_time=time_conf.end_time,
|
||||
weight=time_conf.weight,
|
||||
)
|
||||
for time_conf in time_weight_conf
|
||||
],
|
||||
)
|
||||
|
||||
async def update_time_weight_config(
|
||||
self, target: T_Target, platform_name: str, conf: WeightConfig
|
||||
):
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
targetObj: Target = await sess.scalar(
|
||||
select(Target).where(
|
||||
Target.platform_name == platform_name, Target.target == target
|
||||
)
|
||||
)
|
||||
if not targetObj:
|
||||
raise NoSuchTargetException()
|
||||
target_id = targetObj.id
|
||||
targetObj.default_schedule_weight = conf.default
|
||||
delete_statement = delete(ScheduleTimeWeight).where(
|
||||
ScheduleTimeWeight.target_id == target_id
|
||||
)
|
||||
await sess.execute(delete_statement)
|
||||
for time_conf in conf.time_config:
|
||||
new_conf = ScheduleTimeWeight(
|
||||
start_time=time_conf.start_time,
|
||||
end_time=time_conf.end_time,
|
||||
weight=time_conf.weight,
|
||||
target=targetObj,
|
||||
)
|
||||
sess.add(new_conf)
|
||||
|
||||
await sess.commit()
|
||||
|
||||
async def get_current_weight_val(self, platform_list: list[str]) -> dict[str, int]:
|
||||
res = {}
|
||||
cur_time = _get_time()
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
targets: list[Target] = (
|
||||
await sess.scalars(
|
||||
select(Target)
|
||||
.where(Target.platform_name.in_(platform_list))
|
||||
.options(selectinload(Target.time_weight))
|
||||
)
|
||||
).all()
|
||||
for target in targets:
|
||||
key = f"{target.platform_name}-{target.target}"
|
||||
weight = target.default_schedule_weight
|
||||
for time_conf in target.time_weight:
|
||||
if (
|
||||
time_conf.start_time <= cur_time
|
||||
and time_conf.end_time > cur_time
|
||||
):
|
||||
weight = time_conf.weight
|
||||
break
|
||||
res[key] = weight
|
||||
return res
|
||||
|
||||
async def get_platform_target_subscribers(
|
||||
self, platform_name: str, target: T_Target
|
||||
) -> list[UserSubInfo]:
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
query = (
|
||||
select(Subscribe)
|
||||
.join(Target)
|
||||
.where(Target.platform_name == platform_name, Target.target == target)
|
||||
.options(selectinload(Subscribe.user))
|
||||
)
|
||||
subsribes: list[Subscribe] = (await sess.scalars(query)).all()
|
||||
return list(
|
||||
map(
|
||||
lambda subscribe: UserSubInfo(
|
||||
T_User(subscribe.user.uid, subscribe.user.type),
|
||||
subscribe.categories,
|
||||
subscribe.tags,
|
||||
),
|
||||
subsribes,
|
||||
)
|
||||
)
|
||||
|
||||
async def get_all_weight_config(
|
||||
self,
|
||||
) -> dict[str, dict[str, PlatformWeightConfigResp]]:
|
||||
res: dict[str, dict[str, PlatformWeightConfigResp]] = defaultdict(dict)
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
query = select(Target)
|
||||
targets: list[Target] = (await sess.scalars(query)).all()
|
||||
query = select(ScheduleTimeWeight).options(
|
||||
selectinload(ScheduleTimeWeight.target)
|
||||
)
|
||||
time_weights: list[ScheduleTimeWeight] = (await sess.scalars(query)).all()
|
||||
|
||||
for target in targets:
|
||||
platform_name = target.platform_name
|
||||
if platform_name not in res.keys():
|
||||
res[platform_name][target.target] = PlatformWeightConfigResp(
|
||||
target=T_Target(target.target),
|
||||
target_name=target.target_name,
|
||||
platform_name=platform_name,
|
||||
weight=WeightConfig(
|
||||
default=target.default_schedule_weight, time_config=[]
|
||||
),
|
||||
)
|
||||
|
||||
for time_weight_config in time_weights:
|
||||
platform_name = time_weight_config.target.platform_name
|
||||
target = time_weight_config.target.target
|
||||
res[platform_name][target].weight.time_config.append(
|
||||
TimeWeightConfig(
|
||||
start_time=time_weight_config.start_time,
|
||||
end_time=time_weight_config.end_time,
|
||||
weight=time_weight_config.weight,
|
||||
)
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
config = DBConfig()
|
61
src/plugins/nonebot_bison/config/db_model.py
Normal file
@ -0,0 +1,61 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql.schema import Column, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.sql.sqltypes import JSON, Integer, String, Time
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "user"
|
||||
__table_args__ = (UniqueConstraint("type", "uid", name="unique-user-constraint"),)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
type = Column(String(20), nullable=False)
|
||||
uid = Column(Integer, nullable=False)
|
||||
|
||||
subscribes = relationship("Subscribe", back_populates="user")
|
||||
|
||||
|
||||
class Target(Base):
|
||||
__tablename__ = "target"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("target", "platform_name", name="unique-target-constraint"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
platform_name = Column(String(20), nullable=False)
|
||||
target = Column(String(1024), nullable=False)
|
||||
target_name = Column(String(1024), nullable=False)
|
||||
default_schedule_weight = Column(Integer, default=10)
|
||||
|
||||
subscribes = relationship("Subscribe", back_populates="target")
|
||||
time_weight = relationship("ScheduleTimeWeight", back_populates="target")
|
||||
|
||||
|
||||
class ScheduleTimeWeight(Base):
|
||||
__tablename__ = "schedule_time_weight"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
target_id = Column(Integer, ForeignKey(Target.id))
|
||||
start_time = Column(Time)
|
||||
end_time = Column(Time)
|
||||
weight = Column(Integer)
|
||||
|
||||
target = relationship("Target", back_populates="time_weight")
|
||||
|
||||
|
||||
class Subscribe(Base):
|
||||
__tablename__ = "subscribe"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("target_id", "user_id", name="unique-subscribe-constraint"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
target_id = Column(Integer, ForeignKey(Target.id))
|
||||
user_id = Column(Integer, ForeignKey(User.id))
|
||||
categories = Column(JSON)
|
||||
tags = Column(JSON)
|
||||
|
||||
target = relationship("Target", back_populates="subscribes")
|
||||
user = relationship("User", back_populates="subscribes")
|
1
src/plugins/nonebot_bison/config/migrate/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
113
src/plugins/nonebot_bison/config/migrate/env.py
Normal file
@ -0,0 +1,113 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy.engine.base import Connection
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name:
|
||||
fileConfig(config.config_file_name) # type:ignore
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
|
||||
import nonebot
|
||||
|
||||
try:
|
||||
nonebot.get_driver()
|
||||
__as_plugin = True
|
||||
target_metadata = None
|
||||
except:
|
||||
__as_plugin = False
|
||||
nonebot.init()
|
||||
from nonebot_bison.config.db_model import Base
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migration(connection: Connection):
|
||||
if __as_plugin:
|
||||
context.configure(connection=connection)
|
||||
else:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True,
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_async():
|
||||
|
||||
from nonebot_plugin_datastore.db import get_engine
|
||||
|
||||
connectable = get_engine()
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migration)
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
if not __as_plugin:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
do_run_migration(connection)
|
||||
else:
|
||||
# asyncio.run(run_migrations_async())
|
||||
asyncio.create_task(run_migrations_async())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
src/plugins/nonebot_bison/config/migrate/script.py.mako
Normal file
@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
@ -0,0 +1,60 @@
|
||||
"""init db
|
||||
|
||||
Revision ID: 0571870f5222
|
||||
Revises:
|
||||
Create Date: 2022-03-21 19:18:13.762626
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0571870f5222"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"target",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("platform_name", sa.String(length=20), nullable=False),
|
||||
sa.Column("target", sa.String(length=1024), nullable=False),
|
||||
sa.Column("target_name", sa.String(length=1024), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
"user",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("type", sa.String(length=20), nullable=False),
|
||||
sa.Column("uid", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
"subscribe",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("target_id", sa.Integer(), nullable=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("categories", sa.String(length=1024), nullable=True),
|
||||
sa.Column("tags", sa.String(length=1024), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["target_id"],
|
||||
["target.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("subscribe")
|
||||
op.drop_table("user")
|
||||
op.drop_table("target")
|
||||
# ### end Alembic commands ###
|
@ -0,0 +1,53 @@
|
||||
"""alter type
|
||||
|
||||
Revision ID: 4a46ba54a3f3
|
||||
Revises: c97c445e2bdb
|
||||
Create Date: 2022-03-27 21:50:10.911649
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "4a46ba54a3f3"
|
||||
down_revision = "c97c445e2bdb"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("subscribe", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"categories",
|
||||
existing_type=sa.VARCHAR(length=1024),
|
||||
type_=sa.JSON(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"tags",
|
||||
existing_type=sa.VARCHAR(length=1024),
|
||||
type_=sa.JSON(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("subscribe", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"tags",
|
||||
existing_type=sa.JSON(),
|
||||
type_=sa.VARCHAR(length=1024),
|
||||
existing_nullable=True,
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"categories",
|
||||
existing_type=sa.JSON(),
|
||||
type_=sa.VARCHAR(length=1024),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
@ -0,0 +1,51 @@
|
||||
"""add time-weight table
|
||||
|
||||
Revision ID: 5f3370328e44
|
||||
Revises: a333d6224193
|
||||
Create Date: 2022-05-31 22:05:13.235981
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "5f3370328e44"
|
||||
down_revision = "a333d6224193"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"schedule_time_weight",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("target_id", sa.Integer(), nullable=True),
|
||||
sa.Column("start_time", sa.Time(), nullable=True),
|
||||
sa.Column("end_time", sa.Time(), nullable=True),
|
||||
sa.Column("weight", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["target_id"],
|
||||
["target.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
with op.batch_alter_table("target", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("default_schedule_weight", sa.Integer(), nullable=True)
|
||||
)
|
||||
batch_op.drop_column("last_schedule_time")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("target", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("last_schedule_time", sa.DATETIME(), nullable=True)
|
||||
)
|
||||
batch_op.drop_column("default_schedule_weight")
|
||||
|
||||
op.drop_table("schedule_time_weight")
|
||||
# ### end Alembic commands ###
|
@ -0,0 +1,33 @@
|
||||
"""add last scheduled time
|
||||
|
||||
Revision ID: a333d6224193
|
||||
Revises: 4a46ba54a3f3
|
||||
Create Date: 2022-03-29 21:01:38.213153
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a333d6224193"
|
||||
down_revision = "4a46ba54a3f3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("target", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("last_schedule_time", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("target", schema=None) as batch_op:
|
||||
batch_op.drop_column("last_schedule_time")
|
||||
|
||||
# ### end Alembic commands ###
|
@ -0,0 +1,47 @@
|
||||
"""add constraint
|
||||
|
||||
Revision ID: c97c445e2bdb
|
||||
Revises: 0571870f5222
|
||||
Create Date: 2022-03-26 19:46:50.910721
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c97c445e2bdb"
|
||||
down_revision = "0571870f5222"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("subscribe", schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint(
|
||||
"unique-subscribe-constraint", ["target_id", "user_id"]
|
||||
)
|
||||
|
||||
with op.batch_alter_table("target", schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint(
|
||||
"unique-target-constraint", ["target", "platform_name"]
|
||||
)
|
||||
|
||||
with op.batch_alter_table("user", schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint("unique-user-constraint", ["type", "uid"])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("user", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("unique-user-constraint", type_="unique")
|
||||
|
||||
with op.batch_alter_table("target", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("unique-target-constraint", type_="unique")
|
||||
|
||||
with op.batch_alter_table("subscribe", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("unique-subscribe-constraint", type_="unique")
|
||||
|
||||
# ### end Alembic commands ###
|
10
src/plugins/nonebot_bison/config/utils.py
Normal file
@ -0,0 +1,10 @@
|
||||
class NoSuchUserException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoSuchSubscribeException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoSuchTargetException(Exception):
|
||||
pass
|
@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional, Type
|
||||
from typing import Optional, Type, cast
|
||||
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters.onebot.v11 import Bot, Event, MessageEvent
|
||||
@ -9,14 +9,14 @@ from nonebot.adapters.onebot.v11.message import Message
|
||||
from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER
|
||||
from nonebot.internal.params import ArgStr
|
||||
from nonebot.internal.rule import Rule
|
||||
from nonebot.log import logger
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.params import Depends, EventPlainText, EventToMe
|
||||
from nonebot.permission import SUPERUSER
|
||||
from nonebot.rule import to_me
|
||||
from nonebot.typing import T_State
|
||||
|
||||
from .config import Config
|
||||
from .config import config
|
||||
from .config.db_config import SubscribeDupException
|
||||
from .platform import Platform, check_sub_target, platform_manager
|
||||
from .plugin_config import plugin_config
|
||||
from .types import Category, Target, User
|
||||
@ -201,20 +201,24 @@ def do_add_sub(add_sub: Type[Matcher]):
|
||||
|
||||
@add_sub.got("tags", _gen_prompt_template("{_prompt}"), [Depends(parser_tags)])
|
||||
async def add_sub_process(event: Event, state: T_State):
|
||||
config = Config()
|
||||
user = state.get("target_user_info")
|
||||
user = cast(User, state.get("target_user_info"))
|
||||
assert isinstance(user, User)
|
||||
config.add_subscribe(
|
||||
# state.get("_user_id") or event.group_id,
|
||||
# user_type="group",
|
||||
user=user.user,
|
||||
user_type=user.user_type,
|
||||
target=state["id"],
|
||||
target_name=state["name"],
|
||||
target_type=state["platform"],
|
||||
cats=state.get("cats", []),
|
||||
tags=state.get("tags", []),
|
||||
)
|
||||
try:
|
||||
await config.add_subscribe(
|
||||
# state.get("_user_id") or event.group_id,
|
||||
# user_type="group",
|
||||
user=user.user,
|
||||
user_type=user.user_type,
|
||||
target=state["id"],
|
||||
target_name=state["name"],
|
||||
platform_name=state["platform"],
|
||||
cats=state.get("cats", []),
|
||||
tags=state.get("tags", []),
|
||||
)
|
||||
except SubscribeDupException:
|
||||
await add_sub.finish(f"添加 {state['name']} 失败: 已存在该订阅")
|
||||
except Exception as e:
|
||||
await add_sub.finish(f"添加 {state['name']} 失败: {e}")
|
||||
await add_sub.finish("添加 {} 成功".format(state["name"]))
|
||||
|
||||
|
||||
@ -223,10 +227,9 @@ def do_query_sub(query_sub: Type[Matcher]):
|
||||
|
||||
@query_sub.handle()
|
||||
async def _(state: T_State):
|
||||
config: Config = Config()
|
||||
user_info = state["target_user_info"]
|
||||
assert isinstance(user_info, User)
|
||||
sub_list = config.list_subscribe(
|
||||
sub_list = await config.list_subscribe(
|
||||
# state.get("_user_id") or event.group_id, "group"
|
||||
user_info.user,
|
||||
user_info.user_type,
|
||||
@ -234,17 +237,20 @@ def do_query_sub(query_sub: Type[Matcher]):
|
||||
res = "订阅的帐号为:\n"
|
||||
for sub in sub_list:
|
||||
res += "{} {} {}".format(
|
||||
sub["target_type"], sub["target_name"], sub["target"]
|
||||
# sub["target_type"], sub["target_name"], sub["target"]
|
||||
sub.target.platform_name,
|
||||
sub.target.target_name,
|
||||
sub.target.target,
|
||||
)
|
||||
platform = platform_manager[sub["target_type"]]
|
||||
platform = platform_manager[sub.target.platform_name]
|
||||
if platform.categories:
|
||||
res += " [{}]".format(
|
||||
", ".join(
|
||||
map(lambda x: platform.categories[Category(x)], sub["cats"])
|
||||
map(lambda x: platform.categories[Category(x)], sub.categories)
|
||||
)
|
||||
)
|
||||
if platform.enable_tag:
|
||||
res += " {}".format(", ".join(sub["tags"]))
|
||||
res += " {}".format(", ".join(sub.tags))
|
||||
res += "\n"
|
||||
await query_sub.finish(Message(await parse_text(res)))
|
||||
|
||||
@ -254,11 +260,10 @@ def do_del_sub(del_sub: Type[Matcher]):
|
||||
|
||||
@del_sub.handle()
|
||||
async def send_list(bot: Bot, event: Event, state: T_State):
|
||||
config: Config = Config()
|
||||
user_info = state["target_user_info"]
|
||||
assert isinstance(user_info, User)
|
||||
try:
|
||||
sub_list = config.list_subscribe(
|
||||
sub_list = await config.list_subscribe(
|
||||
# state.get("_user_id") or event.group_id, "group"
|
||||
user_info.user,
|
||||
user_info.user_type,
|
||||
@ -271,21 +276,27 @@ def do_del_sub(del_sub: Type[Matcher]):
|
||||
state["sub_table"] = {}
|
||||
for index, sub in enumerate(sub_list, 1):
|
||||
state["sub_table"][index] = {
|
||||
"target_type": sub["target_type"],
|
||||
"target": sub["target"],
|
||||
"platform_name": sub.target.platform_name,
|
||||
"target": sub.target.target,
|
||||
}
|
||||
res += "{} {} {} {}\n".format(
|
||||
index, sub["target_type"], sub["target_name"], sub["target"]
|
||||
index,
|
||||
sub.target.platform_name,
|
||||
sub.target.target_name,
|
||||
sub.target.target,
|
||||
)
|
||||
platform = platform_manager[sub["target_type"]]
|
||||
platform = platform_manager[sub.target.platform_name]
|
||||
if platform.categories:
|
||||
res += " [{}]".format(
|
||||
", ".join(
|
||||
map(lambda x: platform.categories[Category(x)], sub["cats"])
|
||||
map(
|
||||
lambda x: platform.categories[Category(x)],
|
||||
sub.categories,
|
||||
)
|
||||
)
|
||||
)
|
||||
if platform.enable_tag:
|
||||
res += " {}".format(", ".join(sub["tags"]))
|
||||
res += " {}".format(", ".join(sub.tags))
|
||||
res += "\n"
|
||||
res += "请输入要删除的订阅的序号\n输入'取消'中止"
|
||||
await bot.send(event=event, message=Message(await parse_text(res)))
|
||||
@ -297,10 +308,9 @@ def do_del_sub(del_sub: Type[Matcher]):
|
||||
await del_sub.finish("删除中止")
|
||||
try:
|
||||
index = int(user_msg)
|
||||
config = Config()
|
||||
user_info = state["target_user_info"]
|
||||
assert isinstance(user_info, User)
|
||||
config.del_subscribe(
|
||||
await config.del_subscribe(
|
||||
# state.get("_user_id") or event.group_id,
|
||||
# "group",
|
||||
user_info.user,
|
||||
|
@ -7,9 +7,17 @@ from nonebot.plugin import require
|
||||
from ..post import Post
|
||||
from ..types import Category, RawPost, Target
|
||||
from ..utils import http_client
|
||||
from ..utils.scheduler_config import SchedulerConfig
|
||||
from .platform import CategoryNotSupport, NewMessage, StatusChange
|
||||
|
||||
|
||||
class ArknightsSchedConf(SchedulerConfig):
|
||||
|
||||
name = "arknights"
|
||||
schedule_type = "interval"
|
||||
schedule_setting = {"seconds": 30}
|
||||
|
||||
|
||||
class Arknights(NewMessage):
|
||||
|
||||
categories = {1: "游戏公告"}
|
||||
@ -18,8 +26,7 @@ class Arknights(NewMessage):
|
||||
enable_tag = False
|
||||
enabled = True
|
||||
is_common = False
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"seconds": 30}
|
||||
scheduler = ArknightsSchedConf
|
||||
has_target = False
|
||||
|
||||
async def get_target_name(self, _: Target) -> str:
|
||||
@ -91,8 +98,7 @@ class AkVersion(StatusChange):
|
||||
enable_tag = False
|
||||
enabled = True
|
||||
is_common = False
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"seconds": 30}
|
||||
scheduler = ArknightsSchedConf
|
||||
has_target = False
|
||||
|
||||
async def get_target_name(self, _: Target) -> str:
|
||||
@ -147,8 +153,7 @@ class MonsterSiren(NewMessage):
|
||||
enable_tag = False
|
||||
enabled = True
|
||||
is_common = False
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"seconds": 30}
|
||||
scheduler = ArknightsSchedConf
|
||||
has_target = False
|
||||
|
||||
async def get_target_name(self, _: Target) -> str:
|
||||
@ -199,8 +204,7 @@ class TerraHistoricusComic(NewMessage):
|
||||
enable_tag = False
|
||||
enabled = True
|
||||
is_common = False
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"seconds": 30}
|
||||
scheduler = ArknightsSchedConf
|
||||
has_target = False
|
||||
|
||||
async def get_target_name(self, _: Target) -> str:
|
||||
|
@ -1,14 +1,54 @@
|
||||
import functools
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import httpx
|
||||
from nonebot.log import logger
|
||||
|
||||
from ..post import Post
|
||||
from ..types import Category, RawPost, Tag, Target
|
||||
from ..utils import http_client
|
||||
from ..utils import SchedulerConfig
|
||||
from ..utils.http import http_args
|
||||
from .platform import CategoryNotSupport, NewMessage, StatusChange
|
||||
|
||||
|
||||
class Bilibili(NewMessage):
|
||||
class BilibiliSchedConf(SchedulerConfig):
|
||||
|
||||
name = "bilibili.com"
|
||||
schedule_type = "interval"
|
||||
schedule_setting = {"seconds": 10}
|
||||
|
||||
|
||||
from .platform import CategoryNotSupport, NewMessage, StatusChange
|
||||
|
||||
|
||||
class _BilibiliClient:
|
||||
|
||||
_http_client: httpx.AsyncClient
|
||||
_client_refresh_time: Optional[datetime]
|
||||
cookie_expire_time = timedelta(hours=5)
|
||||
|
||||
async def _init_session(self):
|
||||
self._http_client = httpx.AsyncClient(**http_args)
|
||||
res = await self._http_client.get("https://www.bilibili.com/")
|
||||
if res.status_code != 200:
|
||||
logger.warning("unable to refresh temp cookie")
|
||||
else:
|
||||
self._client_refresh_time = datetime.now()
|
||||
|
||||
async def _refresh_client(self):
|
||||
if (
|
||||
getattr(self, "_client_refresh_time", None) is None
|
||||
or datetime.now() - self._client_refresh_time
|
||||
> self.cookie_expire_time # type:ignore
|
||||
or self._http_client is None
|
||||
):
|
||||
await self._init_session()
|
||||
|
||||
|
||||
class Bilibili(_BilibiliClient, NewMessage):
|
||||
|
||||
categories = {
|
||||
1: "一般动态",
|
||||
@ -22,45 +62,50 @@ class Bilibili(NewMessage):
|
||||
enable_tag = True
|
||||
enabled = True
|
||||
is_common = True
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"seconds": 10}
|
||||
scheduler = BilibiliSchedConf
|
||||
name = "B站"
|
||||
has_target = True
|
||||
parse_target_promot = "请输入用户主页的链接"
|
||||
|
||||
def ensure_client(fun: Callable): # type:ignore
|
||||
@functools.wraps(fun)
|
||||
async def wrapped(self, *args, **kwargs):
|
||||
await self._refresh_client()
|
||||
return await fun(self, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
@ensure_client
|
||||
async def get_target_name(self, target: Target) -> Optional[str]:
|
||||
async with http_client() as client:
|
||||
res = await client.get(
|
||||
"https://api.bilibili.com/x/space/acc/info", params={"mid": target}
|
||||
)
|
||||
res_data = json.loads(res.text)
|
||||
if res_data["code"]:
|
||||
return None
|
||||
return res_data["data"]["name"]
|
||||
res = await self._http_client.get(
|
||||
"https://api.bilibili.com/x/space/acc/info", params={"mid": target}
|
||||
)
|
||||
res_data = json.loads(res.text)
|
||||
if res_data["code"]:
|
||||
return None
|
||||
return res_data["data"]["name"]
|
||||
|
||||
async def parse_target(self, target_text: str) -> Target:
|
||||
if re.match(r"\d+", target_text):
|
||||
return Target(target_text)
|
||||
elif match := re.match(
|
||||
r"(?:https?://)?space\.bilibili\.com/(\d+)", target_text
|
||||
):
|
||||
return Target(match.group(1))
|
||||
elif m := re.match(r"(?:https?://)?space\.bilibili\.com/(\d+)", target_text):
|
||||
return Target(m.group(1))
|
||||
else:
|
||||
raise self.ParseTargetException()
|
||||
|
||||
@ensure_client
|
||||
async def get_sub_list(self, target: Target) -> list[RawPost]:
|
||||
async with http_client() as client:
|
||||
params = {"host_uid": target, "offset": 0, "need_top": 0}
|
||||
res = await client.get(
|
||||
"https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/space_history",
|
||||
params=params,
|
||||
timeout=4.0,
|
||||
)
|
||||
res_dict = json.loads(res.text)
|
||||
if res_dict["code"] == 0:
|
||||
return res_dict["data"].get("cards")
|
||||
else:
|
||||
return []
|
||||
params = {"host_uid": target, "offset": 0, "need_top": 0}
|
||||
res = await self._http_client.get(
|
||||
"https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/space_history",
|
||||
params=params,
|
||||
timeout=4.0,
|
||||
)
|
||||
res_dict = json.loads(res.text)
|
||||
if res_dict["code"] == 0:
|
||||
return res_dict["data"].get("cards")
|
||||
else:
|
||||
return []
|
||||
|
||||
def get_id(self, post: RawPost) -> Any:
|
||||
return post["desc"]["dynamic_id"]
|
||||
@ -157,7 +202,7 @@ class Bilibili(NewMessage):
|
||||
return Post("bilibili", text=text, url=url, pics=pic, target_name=target_name)
|
||||
|
||||
|
||||
class Bilibililive(StatusChange):
|
||||
class Bilibililive(_BilibiliClient, StatusChange):
|
||||
# Author : Sichongzou
|
||||
# Date : 2022-5-18 8:54
|
||||
# Description : bilibili开播提醒
|
||||
@ -167,41 +212,48 @@ class Bilibililive(StatusChange):
|
||||
enable_tag = False
|
||||
enabled = True
|
||||
is_common = True
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"seconds": 10}
|
||||
scheduler = BilibiliSchedConf
|
||||
name = "Bilibili直播"
|
||||
has_target = True
|
||||
|
||||
async def get_target_name(self, target: Target) -> Optional[str]:
|
||||
async with http_client() as client:
|
||||
res = await client.get(
|
||||
"https://api.bilibili.com/x/space/acc/info", params={"mid": target}
|
||||
)
|
||||
res_data = json.loads(res.text)
|
||||
if res_data["code"]:
|
||||
return None
|
||||
return res_data["data"]["name"]
|
||||
def ensure_client(fun: Callable): # type:ignore
|
||||
@functools.wraps(fun)
|
||||
async def wrapped(self, *args, **kwargs):
|
||||
await self._refresh_client()
|
||||
return await fun(self, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
@ensure_client
|
||||
async def get_target_name(self, target: Target) -> Optional[str]:
|
||||
res = await self._http_client.get(
|
||||
"https://api.bilibili.com/x/space/acc/info", params={"mid": target}
|
||||
)
|
||||
res_data = json.loads(res.text)
|
||||
if res_data["code"]:
|
||||
return None
|
||||
return res_data["data"]["name"]
|
||||
|
||||
@ensure_client
|
||||
async def get_status(self, target: Target):
|
||||
async with http_client() as client:
|
||||
params = {"mid": target}
|
||||
res = await client.get(
|
||||
"https://api.bilibili.com/x/space/acc/info",
|
||||
params=params,
|
||||
timeout=4.0,
|
||||
)
|
||||
res_dict = json.loads(res.text)
|
||||
if res_dict["code"] == 0:
|
||||
info = {}
|
||||
info["uid"] = res_dict["data"]["mid"]
|
||||
info["uname"] = res_dict["data"]["name"]
|
||||
info["live_state"] = res_dict["data"]["live_room"]["liveStatus"]
|
||||
info["room_id"] = res_dict["data"]["live_room"]["roomid"]
|
||||
info["title"] = res_dict["data"]["live_room"]["title"]
|
||||
info["cover"] = res_dict["data"]["live_room"]["cover"]
|
||||
return info
|
||||
else:
|
||||
raise self.FetchError(res.text)
|
||||
params = {"mid": target}
|
||||
res = await self._http_client.get(
|
||||
"https://api.bilibili.com/x/space/acc/info",
|
||||
params=params,
|
||||
timeout=4.0,
|
||||
)
|
||||
res_dict = json.loads(res.text)
|
||||
if res_dict["code"] == 0:
|
||||
info = {}
|
||||
info["uid"] = res_dict["data"]["mid"]
|
||||
info["uname"] = res_dict["data"]["name"]
|
||||
info["live_state"] = res_dict["data"]["live_room"]["liveStatus"]
|
||||
info["room_id"] = res_dict["data"]["live_room"]["roomid"]
|
||||
info["title"] = res_dict["data"]["live_room"]["title"]
|
||||
info["cover"] = res_dict["data"]["live_room"]["cover"]
|
||||
return info
|
||||
else:
|
||||
raise self.FetchError()
|
||||
|
||||
def compare_status(self, target: Target, old_status, new_status) -> list[RawPost]:
|
||||
if (
|
||||
@ -225,3 +277,95 @@ class Bilibililive(StatusChange):
|
||||
target_name=target_name,
|
||||
compress=True,
|
||||
)
|
||||
|
||||
|
||||
class BilibiliBangumi(_BilibiliClient, StatusChange):
|
||||
|
||||
categories = {}
|
||||
platform_name = "bilibili-bangumi"
|
||||
enable_tag = False
|
||||
enabled = True
|
||||
is_common = True
|
||||
scheduler = BilibiliSchedConf
|
||||
name = "Bilibili剧集"
|
||||
has_target = True
|
||||
parse_target_promot = "请输入剧集主页"
|
||||
|
||||
_url = "https://api.bilibili.com/pgc/review/user"
|
||||
|
||||
def ensure_client(fun: Callable): # type:ignore
|
||||
@functools.wraps(fun)
|
||||
async def wrapped(self, *args, **kwargs):
|
||||
await self._refresh_client()
|
||||
return await fun(self, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
@ensure_client
|
||||
async def get_target_name(self, target: Target) -> Optional[str]:
|
||||
res = await self._http_client.get(self._url, params={"media_id": target})
|
||||
res_data = res.json()
|
||||
if res_data["code"]:
|
||||
return None
|
||||
return res_data["result"]["media"]["title"]
|
||||
|
||||
async def parse_target(self, target_string: str) -> Target:
|
||||
if re.match(r"\d+", target_string):
|
||||
return Target(target_string)
|
||||
elif m := re.match(r"md(\d+)", target_string):
|
||||
return Target(m.group(1))
|
||||
elif m := re.match(
|
||||
r"(?:https?://)?www\.bilibili\.com/bangumi/media/md(\d+)/", target_string
|
||||
):
|
||||
return Target(m.group(1))
|
||||
raise self.ParseTargetException()
|
||||
|
||||
@ensure_client
|
||||
async def get_status(self, target: Target):
|
||||
res = await self._http_client.get(
|
||||
self._url,
|
||||
params={"media_id": target},
|
||||
timeout=4.0,
|
||||
)
|
||||
res_dict = res.json()
|
||||
if res_dict["code"] == 0:
|
||||
return {
|
||||
"index": res_dict["result"]["media"]["new_ep"]["index"],
|
||||
"index_show": res_dict["result"]["media"]["new_ep"]["index"],
|
||||
"season_id": res_dict["result"]["media"]["season_id"],
|
||||
}
|
||||
else:
|
||||
raise self.FetchError
|
||||
|
||||
def compare_status(self, target: Target, old_status, new_status) -> list[RawPost]:
|
||||
if new_status["index"] != old_status["index"]:
|
||||
return [new_status]
|
||||
else:
|
||||
return []
|
||||
|
||||
@ensure_client
|
||||
async def parse(self, raw_post: RawPost) -> Post:
|
||||
detail_res = await self._http_client.get(
|
||||
f'http://api.bilibili.com/pgc/view/web/season?season_id={raw_post["season_id"]}'
|
||||
)
|
||||
detail_dict = detail_res.json()
|
||||
lastest_episode = None
|
||||
for episode in detail_dict["result"]["episodes"][::-1]:
|
||||
if episode["badge"] in ("", "会员"):
|
||||
lastest_episode = episode
|
||||
break
|
||||
if not lastest_episode:
|
||||
lastest_episode = detail_dict["result"]["episodes"]
|
||||
|
||||
url = lastest_episode["link"]
|
||||
pic = [lastest_episode["cover"]]
|
||||
target_name = detail_dict["result"]["season_title"]
|
||||
text = lastest_episode["share_copy"]
|
||||
return Post(
|
||||
self.name,
|
||||
text=text,
|
||||
url=url,
|
||||
pics=pic,
|
||||
target_name=target_name,
|
||||
compress=True,
|
||||
)
|
||||
|
@ -2,7 +2,7 @@ from typing import Any
|
||||
|
||||
from ..post import Post
|
||||
from ..types import RawPost, Target
|
||||
from ..utils import http_client
|
||||
from ..utils import http_client, scheduler
|
||||
from .platform import NewMessage
|
||||
|
||||
|
||||
@ -14,8 +14,8 @@ class FF14(NewMessage):
|
||||
enable_tag = False
|
||||
enabled = True
|
||||
is_common = False
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"seconds": 60}
|
||||
scheduler_class = "ff14"
|
||||
scheduler = scheduler("interval", {"seconds": 60})
|
||||
has_target = False
|
||||
|
||||
async def get_target_name(self, _: Target) -> str:
|
||||
|
@ -7,9 +7,31 @@ from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
|
||||
from ..post import Post
|
||||
from ..types import Category, RawPost, Target
|
||||
from ..utils import scheduler
|
||||
from .platform import CategoryNotSupport, NewMessage
|
||||
|
||||
|
||||
def _format_text(rawtext: str, mode: int) -> str:
|
||||
"""处理BeautifulSoup生成的string中奇怪的回车+连续空格
|
||||
mode 0:处理标题
|
||||
mode 1:处理版本资讯类推文
|
||||
mode 2:处理快讯类推文"""
|
||||
match mode:
|
||||
case 0:
|
||||
ftext = re.sub(r"\n\s*", " ", rawtext)
|
||||
case 1:
|
||||
ftext = re.sub(r"[\n\s*]", "", rawtext)
|
||||
case 2:
|
||||
ftext = re.sub(r"\r\n", "", rawtext)
|
||||
return ftext
|
||||
|
||||
|
||||
def _stamp_date(rawdate: str) -> int:
|
||||
"""将时间转化为时间戳yyyy-mm-dd->timestamp"""
|
||||
time_stamp = int(time.mktime(time.strptime(rawdate, "%Y-%m-%d")))
|
||||
return time_stamp
|
||||
|
||||
|
||||
class McbbsNews(NewMessage):
|
||||
categories = {1: "Java版本资讯", 2: "基岩版本资讯", 3: "快讯", 4: "基岩快讯", 5: "周边消息"}
|
||||
enable_tag = False
|
||||
@ -17,8 +39,7 @@ class McbbsNews(NewMessage):
|
||||
name = "MCBBS幻翼块讯"
|
||||
enabled = True
|
||||
is_common = False
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"hours": 1}
|
||||
scheduler = scheduler("interval", {"hours": 1})
|
||||
has_target = False
|
||||
|
||||
async def get_target_name(self, _: Target) -> str:
|
||||
|
@ -3,10 +3,17 @@ from typing import Any, Optional
|
||||
|
||||
from ..post import Post
|
||||
from ..types import RawPost, Target
|
||||
from ..utils import http_client
|
||||
from ..utils import SchedulerConfig, http_client
|
||||
from .platform import NewMessage
|
||||
|
||||
|
||||
class NcmSchedConf(SchedulerConfig):
|
||||
|
||||
name = "music.163.com"
|
||||
schedule_type = "interval"
|
||||
schedule_setting = {"minutes": 1}
|
||||
|
||||
|
||||
class NcmArtist(NewMessage):
|
||||
|
||||
categories = {}
|
||||
@ -14,8 +21,7 @@ class NcmArtist(NewMessage):
|
||||
enable_tag = False
|
||||
enabled = True
|
||||
is_common = True
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"minutes": 1}
|
||||
scheduler = NcmSchedConf
|
||||
name = "网易云-歌手"
|
||||
has_target = True
|
||||
parse_target_promot = "请输入歌手主页(包含数字ID)的链接"
|
||||
|
@ -4,6 +4,7 @@ from typing import Any, Optional
|
||||
from ..post import Post
|
||||
from ..types import RawPost, Target
|
||||
from ..utils import http_client
|
||||
from .ncm_artist import NcmSchedConf
|
||||
from .platform import NewMessage
|
||||
|
||||
|
||||
@ -14,8 +15,7 @@ class NcmRadio(NewMessage):
|
||||
enable_tag = False
|
||||
enabled = True
|
||||
is_common = False
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"minutes": 10}
|
||||
scheduler = NcmSchedConf
|
||||
name = "网易云-电台"
|
||||
has_target = True
|
||||
parse_target_promot = "请输入主播电台主页(包含数字ID)的链接"
|
||||
|
@ -4,7 +4,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Collection, Literal, Optional
|
||||
from typing import Any, Collection, Literal, Optional, Type
|
||||
|
||||
import httpx
|
||||
from nonebot.log import logger
|
||||
@ -12,6 +12,7 @@ from nonebot.log import logger
|
||||
from ..plugin_config import plugin_config
|
||||
from ..post import Post
|
||||
from ..types import Category, RawPost, Tag, Target, User, UserSubInfo
|
||||
from ..utils.scheduler_config import SchedulerConfig
|
||||
|
||||
|
||||
class CategoryNotSupport(Exception):
|
||||
@ -39,8 +40,7 @@ class RegistryABCMeta(RegistryMeta, ABC):
|
||||
|
||||
class Platform(metaclass=RegistryABCMeta, base=True):
|
||||
|
||||
schedule_type: Literal["date", "interval", "cron"]
|
||||
schedule_kw: dict
|
||||
scheduler: Type[SchedulerConfig]
|
||||
is_common: bool
|
||||
enabled: bool
|
||||
name: str
|
||||
@ -169,9 +169,7 @@ class Platform(metaclass=RegistryABCMeta, base=True):
|
||||
self, target: Target, new_posts: list[RawPost], users: list[UserSubInfo]
|
||||
) -> list[tuple[User, list[Post]]]:
|
||||
res: list[tuple[User, list[Post]]] = []
|
||||
for user, category_getter, tag_getter in users:
|
||||
required_tags = tag_getter(target) if self.enable_tag else []
|
||||
cats = category_getter(target)
|
||||
for user, cats, required_tags in users:
|
||||
user_raw_post = await self.filter_user_custom(
|
||||
new_posts, cats, required_tags
|
||||
)
|
||||
@ -372,11 +370,11 @@ class NoTargetGroup(Platform, abstract=True):
|
||||
|
||||
def __init__(self, platform_list: list[Platform]):
|
||||
self.platform_list = platform_list
|
||||
self.platform_name = platform_list[0].platform_name
|
||||
name = self.DUMMY_STR
|
||||
self.categories = {}
|
||||
categories_keys = set()
|
||||
self.schedule_type = platform_list[0].schedule_type
|
||||
self.schedule_kw = platform_list[0].schedule_kw
|
||||
self.scheduler = platform_list[0].scheduler
|
||||
for platform in platform_list:
|
||||
if platform.has_target:
|
||||
raise RuntimeError(
|
||||
@ -395,10 +393,7 @@ class NoTargetGroup(Platform, abstract=True):
|
||||
)
|
||||
categories_keys |= platform_category_key_set
|
||||
self.categories.update(platform.categories)
|
||||
if (
|
||||
platform.schedule_kw != self.schedule_kw
|
||||
or platform.schedule_type != self.schedule_type
|
||||
):
|
||||
if platform.scheduler != self.scheduler:
|
||||
raise RuntimeError(
|
||||
"Platform scheduler for {} not fit".format(self.platform_name)
|
||||
)
|
||||
|
@ -6,7 +6,7 @@ from bs4 import BeautifulSoup as bs
|
||||
|
||||
from ..post import Post
|
||||
from ..types import RawPost, Target
|
||||
from ..utils import http_client
|
||||
from ..utils import http_client, scheduler
|
||||
from .platform import NewMessage
|
||||
|
||||
|
||||
@ -18,8 +18,7 @@ class Rss(NewMessage):
|
||||
name = "Rss"
|
||||
enabled = True
|
||||
is_common = True
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"seconds": 30}
|
||||
scheduler = scheduler("interval", {"seconds": 30})
|
||||
has_target = True
|
||||
|
||||
async def get_target_name(self, target: Target) -> Optional[str]:
|
||||
|
@ -1,5 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
@ -8,10 +9,16 @@ from nonebot.log import logger
|
||||
|
||||
from ..post import Post
|
||||
from ..types import *
|
||||
from ..utils import http_client
|
||||
from ..utils import SchedulerConfig, http_client
|
||||
from .platform import NewMessage
|
||||
|
||||
|
||||
class WeiboSchedConf(SchedulerConfig):
|
||||
name = "weibo.com"
|
||||
schedule_type = "interval"
|
||||
schedule_setting = {"seconds": 3}
|
||||
|
||||
|
||||
class Weibo(NewMessage):
|
||||
|
||||
categories = {
|
||||
@ -25,8 +32,7 @@ class Weibo(NewMessage):
|
||||
name = "新浪微博"
|
||||
enabled = True
|
||||
is_common = True
|
||||
schedule_type = "interval"
|
||||
schedule_kw = {"seconds": 3}
|
||||
scheduler = WeiboSchedConf
|
||||
has_target = True
|
||||
parse_target_promot = "请输入用户主页(包含数字UID)的链接"
|
||||
|
||||
|
1
src/plugins/nonebot_bison/scheduler/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .manager import *
|
31
src/plugins/nonebot_bison/scheduler/aps.py
Normal file
@ -0,0 +1,31 @@
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from nonebot.log import LoguruHandler
|
||||
|
||||
from ..plugin_config import plugin_config
|
||||
from ..send import do_send_msgs
|
||||
|
||||
aps = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||
|
||||
|
||||
class CustomLogHandler(LoguruHandler):
|
||||
def filter(self, record: logging.LogRecord):
|
||||
return record.msg != (
|
||||
'Execution of job "%s" '
|
||||
"skipped: maximum number of running instances reached (%d)"
|
||||
)
|
||||
|
||||
|
||||
if plugin_config.bison_use_queue:
|
||||
aps.add_job(do_send_msgs, "interval", seconds=0.3, coalesce=True)
|
||||
|
||||
aps_logger = logging.getLogger("apscheduler")
|
||||
aps_logger.setLevel(30)
|
||||
aps_logger.handlers.clear()
|
||||
aps_logger.addHandler(CustomLogHandler())
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
aps.configure({"apscheduler.timezone": "Asia/Shanghai"})
|
||||
aps.start()
|
54
src/plugins/nonebot_bison/scheduler/manager.py
Normal file
@ -0,0 +1,54 @@
|
||||
from typing import Type
|
||||
|
||||
from ..config import config
|
||||
from ..config.db_model import Target
|
||||
from ..platform import platform_manager
|
||||
from ..types import Target as T_Target
|
||||
from ..utils import SchedulerConfig
|
||||
from .scheduler import Scheduler
|
||||
|
||||
scheduler_dict: dict[Type[SchedulerConfig], Scheduler] = {}
|
||||
|
||||
|
||||
async def init_scheduler():
|
||||
_schedule_class_dict: dict[Type[SchedulerConfig], list[Target]] = {}
|
||||
_schedule_class_platform_dict: dict[Type[SchedulerConfig], list[str]] = {}
|
||||
for platform in platform_manager.values():
|
||||
scheduler_config = platform.scheduler
|
||||
if not hasattr(scheduler_config, "name") or not scheduler_config.name:
|
||||
scheduler_config.name = f"AnonymousScheduleConfig[{platform.platform_name}]"
|
||||
|
||||
platform_name = platform.platform_name
|
||||
targets = await config.get_platform_target(platform_name)
|
||||
if scheduler_config not in _schedule_class_dict:
|
||||
_schedule_class_dict[scheduler_config] = targets
|
||||
else:
|
||||
_schedule_class_dict[scheduler_config].extend(targets)
|
||||
if scheduler_config not in _schedule_class_platform_dict:
|
||||
_schedule_class_platform_dict[scheduler_config] = [platform_name]
|
||||
else:
|
||||
_schedule_class_platform_dict[scheduler_config].append(platform_name)
|
||||
for scheduler_config, target_list in _schedule_class_dict.items():
|
||||
schedulable_args = []
|
||||
for target in target_list:
|
||||
schedulable_args.append((target.platform_name, T_Target(target.target)))
|
||||
platform_name_list = _schedule_class_platform_dict[scheduler_config]
|
||||
scheduler_dict[scheduler_config] = Scheduler(
|
||||
scheduler_config, schedulable_args, platform_name_list
|
||||
)
|
||||
|
||||
|
||||
async def handle_insert_new_target(platform_name: str, target: T_Target):
|
||||
platform = platform_manager[platform_name]
|
||||
scheduler_obj = scheduler_dict[platform.scheduler]
|
||||
scheduler_obj.insert_new_schedulable(platform_name, target)
|
||||
|
||||
|
||||
async def handle_delete_target(platform_name: str, target: T_Target):
|
||||
platform = platform_manager[platform_name]
|
||||
scheduler_obj = scheduler_dict[platform.scheduler]
|
||||
scheduler_obj.delete_schedulable(platform_name, target)
|
||||
|
||||
|
||||
config.register_add_target_hook(handle_insert_new_target)
|
||||
config.register_delete_target_hook(handle_delete_target)
|
@ -6,7 +6,7 @@ from nonebot import get_driver
|
||||
from nonebot.adapters.onebot.v11.bot import Bot
|
||||
from nonebot.log import LoguruHandler, logger
|
||||
|
||||
from .config import Config
|
||||
from .config import config
|
||||
from .platform import platform_manager
|
||||
from .plugin_config import plugin_config
|
||||
from .send import do_send_msgs, send_msgs
|
||||
@ -37,7 +37,6 @@ async def _start():
|
||||
|
||||
|
||||
async def fetch_and_send(target_type: str):
|
||||
config = Config()
|
||||
target = config.get_next_target(target_type)
|
||||
if not target:
|
||||
return
|
130
src/plugins/nonebot_bison/scheduler/scheduler.py
Normal file
@ -0,0 +1,130 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Type
|
||||
|
||||
import nonebot
|
||||
from nonebot.adapters.onebot.v11.bot import Bot
|
||||
from nonebot.log import logger
|
||||
|
||||
from ..config import config
|
||||
from ..platform import platform_manager
|
||||
from ..platform.platform import Platform
|
||||
from ..send import send_msgs
|
||||
from ..types import Target
|
||||
from ..utils import SchedulerConfig
|
||||
from .aps import aps
|
||||
|
||||
|
||||
@dataclass
|
||||
class Schedulable:
|
||||
platform_name: str
|
||||
target: Target
|
||||
current_weight: int
|
||||
|
||||
|
||||
class Scheduler:
|
||||
|
||||
schedulable_list: list[Schedulable]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheduler_config: Type[SchedulerConfig],
|
||||
schedulables: list[tuple[str, Target]],
|
||||
platform_name_list: list[str],
|
||||
):
|
||||
self.name = scheduler_config.name
|
||||
if not scheduler_config:
|
||||
logger.error(f"scheduler config [{self.name}] not found, exiting")
|
||||
raise RuntimeError(f"{self.name} not found")
|
||||
self.scheduler_config = scheduler_config
|
||||
self.schedulable_list = []
|
||||
for platform_name, target in schedulables:
|
||||
self.schedulable_list.append(
|
||||
Schedulable(
|
||||
platform_name=platform_name, target=target, current_weight=0
|
||||
)
|
||||
)
|
||||
self.platform_name_list = platform_name_list
|
||||
self.pre_weight_val = 0 # 轮调度中“本轮”增加权重和的初值
|
||||
logger.info(
|
||||
f"register scheduler for {self.name} with {self.scheduler_config.schedule_type} {self.scheduler_config.schedule_setting}"
|
||||
)
|
||||
aps.add_job(
|
||||
self.exec_fetch,
|
||||
self.scheduler_config.schedule_type,
|
||||
**self.scheduler_config.schedule_setting,
|
||||
)
|
||||
|
||||
async def get_next_schedulable(self) -> Optional[Schedulable]:
|
||||
if not self.schedulable_list:
|
||||
return None
|
||||
cur_weight = await config.get_current_weight_val(self.platform_name_list)
|
||||
weight_sum = self.pre_weight_val
|
||||
self.pre_weight_val = 0
|
||||
cur_max_schedulable = None
|
||||
for schedulable in self.schedulable_list:
|
||||
schedulable.current_weight += cur_weight[
|
||||
f"{schedulable.platform_name}-{schedulable.target}"
|
||||
]
|
||||
weight_sum += cur_weight[
|
||||
f"{schedulable.platform_name}-{schedulable.target}"
|
||||
]
|
||||
if (
|
||||
not cur_max_schedulable
|
||||
or cur_max_schedulable.current_weight < schedulable.current_weight
|
||||
):
|
||||
cur_max_schedulable = schedulable
|
||||
assert cur_max_schedulable
|
||||
cur_max_schedulable.current_weight -= weight_sum
|
||||
return cur_max_schedulable
|
||||
|
||||
async def exec_fetch(self):
|
||||
if not (schedulable := await self.get_next_schedulable()):
|
||||
return
|
||||
logger.debug(
|
||||
f"scheduler {self.name} fetching next target: [{schedulable.platform_name}]{schedulable.target}"
|
||||
)
|
||||
send_userinfo_list = await config.get_platform_target_subscribers(
|
||||
schedulable.platform_name, schedulable.target
|
||||
)
|
||||
to_send = await platform_manager[schedulable.platform_name].do_fetch_new_post(
|
||||
schedulable.target, send_userinfo_list
|
||||
)
|
||||
if not to_send:
|
||||
return
|
||||
bot = nonebot.get_bot()
|
||||
assert isinstance(bot, Bot)
|
||||
for user, send_list in to_send:
|
||||
for send_post in send_list:
|
||||
logger.info("send to {}: {}".format(user, send_post))
|
||||
if not bot:
|
||||
logger.warning("no bot connected")
|
||||
else:
|
||||
await send_msgs(
|
||||
bot,
|
||||
user.user,
|
||||
user.user_type,
|
||||
await send_post.generate_messages(),
|
||||
)
|
||||
|
||||
def insert_new_schedulable(self, platform_name: str, target: Target):
|
||||
self.pre_weight_val += 1000
|
||||
self.schedulable_list.append(Schedulable(platform_name, target, 1000))
|
||||
logger.info(
|
||||
f"insert [{platform_name}]{target} to Schduler({self.scheduler_config.name})"
|
||||
)
|
||||
|
||||
def delete_schedulable(self, platform_name, target: Target):
|
||||
if not self.schedulable_list:
|
||||
return
|
||||
to_find_idx = None
|
||||
for idx, schedulable in enumerate(self.schedulable_list):
|
||||
if (
|
||||
schedulable.platform_name == platform_name
|
||||
and schedulable.target == target
|
||||
):
|
||||
to_find_idx = idx
|
||||
break
|
||||
if to_find_idx is not None:
|
||||
deleted_schdulable = self.schedulable_list.pop(to_find_idx)
|
||||
self.pre_weight_val -= deleted_schdulable.current_weight
|
||||
return
|
@ -1,5 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Literal, NamedTuple, NewType
|
||||
from datetime import time
|
||||
from typing import Any, Literal, NamedTuple, NewType
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
RawPost = NewType("RawPost", Any)
|
||||
Target = NewType("Target", str)
|
||||
@ -13,7 +16,32 @@ class User:
|
||||
user_type: Literal["group", "private"]
|
||||
|
||||
|
||||
@dataclass(eq=True, frozen=True)
|
||||
class PlatformTarget:
|
||||
target: str
|
||||
platform_name: str
|
||||
target_name: str
|
||||
|
||||
|
||||
class UserSubInfo(NamedTuple):
|
||||
user: User
|
||||
category_getter: Callable[[Target], list[Category]]
|
||||
tag_getter: Callable[[Target], list[Tag]]
|
||||
categories: list[Category]
|
||||
tags: list[Tag]
|
||||
|
||||
|
||||
class TimeWeightConfig(BaseModel):
|
||||
start_time: time
|
||||
end_time: time
|
||||
weight: int
|
||||
|
||||
|
||||
class WeightConfig(BaseModel):
|
||||
default: int
|
||||
time_config: list[TimeWeightConfig]
|
||||
|
||||
|
||||
class PlatformWeightConfigResp(BaseModel):
|
||||
target: Target
|
||||
target_name: str
|
||||
platform_name: str
|
||||
weight: WeightConfig
|
||||
|
@ -10,8 +10,16 @@ from nonebot.plugin import require
|
||||
|
||||
from ..plugin_config import plugin_config
|
||||
from .http import http_client
|
||||
from .scheduler_config import SchedulerConfig, scheduler
|
||||
|
||||
__all__ = ["http_client", "Singleton", "parse_text", "html_to_text"]
|
||||
__all__ = [
|
||||
"http_client",
|
||||
"Singleton",
|
||||
"parse_text",
|
||||
"html_to_text",
|
||||
"SchedulerConfig",
|
||||
"scheduler",
|
||||
]
|
||||
|
||||
|
||||
class Singleton(type):
|
||||
|
@ -4,8 +4,9 @@ import httpx
|
||||
|
||||
from ..plugin_config import plugin_config
|
||||
|
||||
http_client = functools.partial(
|
||||
httpx.AsyncClient,
|
||||
proxies=plugin_config.bison_proxy or None,
|
||||
headers={"user-agent": plugin_config.bison_ua},
|
||||
)
|
||||
http_args = {
|
||||
"proxies": plugin_config.bison_proxy or None,
|
||||
"headers": {"user-agent": plugin_config.bison_ua},
|
||||
}
|
||||
|
||||
http_client = functools.partial(httpx.AsyncClient, **http_args)
|
||||
|
24
src/plugins/nonebot_bison/utils/scheduler_config.py
Normal file
@ -0,0 +1,24 @@
|
||||
from typing import Literal, Type
|
||||
|
||||
|
||||
class SchedulerConfig:
|
||||
|
||||
schedule_type: Literal["date", "interval", "cron"]
|
||||
schedule_setting: dict
|
||||
name: str
|
||||
|
||||
def __str__(self):
|
||||
return f"[{self.name}]-{self.name}-{self.schedule_setting}"
|
||||
|
||||
|
||||
def scheduler(
|
||||
schedule_type: Literal["date", "interval", "cron"], schedule_setting: dict
|
||||
) -> Type[SchedulerConfig]:
|
||||
return type(
|
||||
"AnonymousScheduleConfig",
|
||||
(SchedulerConfig,),
|
||||
{
|
||||
"schedule_type": schedule_type,
|
||||
"schedule_setting": schedule_setting,
|
||||
},
|
||||
)
|
@ -7,24 +7,24 @@ if typing.TYPE_CHECKING:
|
||||
import sys
|
||||
|
||||
sys.path.append("./src/plugins")
|
||||
import nonebot_bison
|
||||
from nonebot_bison.config import Config
|
||||
from nonebot_bison.config.config_legacy import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(app: App):
|
||||
def config_legacy(app: App, use_legacy_config):
|
||||
from nonebot_bison import config
|
||||
from nonebot_bison.config import config_legacy as config
|
||||
|
||||
config.start_up()
|
||||
return config.Config()
|
||||
|
||||
|
||||
def test_create_and_get(config: "Config", app: App):
|
||||
def test_create_and_get(config_legacy: "Config", app: App):
|
||||
from nonebot_bison import types
|
||||
from nonebot_bison.types import Target
|
||||
|
||||
config.add_subscribe(
|
||||
user="123",
|
||||
config_legacy.add_subscribe(
|
||||
user=123,
|
||||
user_type="group",
|
||||
target="weibo_id",
|
||||
target_name="weibo_name",
|
||||
@ -32,14 +32,14 @@ def test_create_and_get(config: "Config", app: App):
|
||||
cats=[],
|
||||
tags=[],
|
||||
)
|
||||
confs = config.list_subscribe("123", "group")
|
||||
confs = config_legacy.list_subscribe(123, "group")
|
||||
assert len(confs) == 1
|
||||
assert config.target_user_cache["weibo"][Target("weibo_id")] == [
|
||||
types.User("123", "group")
|
||||
assert config_legacy.target_user_cache["weibo"][Target("weibo_id")] == [
|
||||
types.User(123, "group")
|
||||
]
|
||||
assert confs[0]["cats"] == []
|
||||
config.update_subscribe(
|
||||
user="123",
|
||||
config_legacy.update_subscribe(
|
||||
user=123,
|
||||
user_type="group",
|
||||
target="weibo_id",
|
||||
target_name="weibo_name",
|
||||
@ -47,6 +47,6 @@ def test_create_and_get(config: "Config", app: App):
|
||||
cats=["1"],
|
||||
tags=[],
|
||||
)
|
||||
confs = config.list_subscribe("123", "group")
|
||||
confs = config_legacy.list_subscribe(123, "group")
|
||||
assert len(confs) == 1
|
||||
assert confs[0]["cats"] == ["1"]
|
159
tests/config/test_config_operation.py
Normal file
@ -0,0 +1,159 @@
|
||||
import pytest
|
||||
from nonebug.app import App
|
||||
from sqlalchemy.ext.asyncio.session import AsyncSession
|
||||
from sqlalchemy.sql.functions import func
|
||||
from sqlmodel.sql.expression import select
|
||||
|
||||
|
||||
async def test_add_subscribe(app: App, init_scheduler):
|
||||
|
||||
from nonebot_bison.config.db_config import config
|
||||
from nonebot_bison.config.db_model import Subscribe, Target, User
|
||||
from nonebot_bison.types import Target as TTarget
|
||||
from nonebot_plugin_datastore.db import get_engine
|
||||
|
||||
await config.add_subscribe(
|
||||
user=123,
|
||||
user_type="group",
|
||||
target=TTarget("weibo_id"),
|
||||
target_name="weibo_name",
|
||||
platform_name="weibo",
|
||||
cats=[],
|
||||
tags=[],
|
||||
)
|
||||
await config.add_subscribe(
|
||||
user=234,
|
||||
user_type="group",
|
||||
target=TTarget("weibo_id"),
|
||||
target_name="weibo_name",
|
||||
platform_name="weibo",
|
||||
cats=[],
|
||||
tags=[],
|
||||
)
|
||||
confs = await config.list_subscribe(123, "group")
|
||||
assert len(confs) == 1
|
||||
conf: Subscribe = confs[0]
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
related_user_obj = await sess.scalar(
|
||||
select(User).where(User.id == conf.user_id)
|
||||
)
|
||||
related_target_obj = await sess.scalar(
|
||||
select(Target).where(Target.id == conf.target_id)
|
||||
)
|
||||
assert related_user_obj.uid == 123
|
||||
assert related_target_obj.target_name == "weibo_name"
|
||||
assert related_target_obj.target == "weibo_id"
|
||||
assert conf.target.target == "weibo_id"
|
||||
assert conf.categories == []
|
||||
|
||||
await config.update_subscribe(
|
||||
user=123,
|
||||
user_type="group",
|
||||
target=TTarget("weibo_id"),
|
||||
platform_name="weibo",
|
||||
target_name="weibo_name2",
|
||||
cats=[1],
|
||||
tags=["tag"],
|
||||
)
|
||||
confs = await config.list_subscribe(123, "group")
|
||||
assert len(confs) == 1
|
||||
conf: Subscribe = confs[0]
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
related_user_obj = await sess.scalar(
|
||||
select(User).where(User.id == conf.user_id)
|
||||
)
|
||||
related_target_obj = await sess.scalar(
|
||||
select(Target).where(Target.id == conf.target_id)
|
||||
)
|
||||
assert related_user_obj.uid == 123
|
||||
assert related_target_obj.target_name == "weibo_name2"
|
||||
assert related_target_obj.target == "weibo_id"
|
||||
assert conf.target.target == "weibo_id"
|
||||
assert conf.categories == [1]
|
||||
assert conf.tags == ["tag"]
|
||||
|
||||
|
||||
async def test_add_dup_sub(init_scheduler):
|
||||
|
||||
from nonebot_bison.config.db_config import SubscribeDupException, config
|
||||
from nonebot_bison.types import Target as TTarget
|
||||
|
||||
await config.add_subscribe(
|
||||
user=123,
|
||||
user_type="group",
|
||||
target=TTarget("weibo_id"),
|
||||
target_name="weibo_name",
|
||||
platform_name="weibo",
|
||||
cats=[],
|
||||
tags=[],
|
||||
)
|
||||
|
||||
with pytest.raises(SubscribeDupException):
|
||||
await config.add_subscribe(
|
||||
user=123,
|
||||
user_type="group",
|
||||
target=TTarget("weibo_id"),
|
||||
target_name="weibo_name",
|
||||
platform_name="weibo",
|
||||
cats=[],
|
||||
tags=[],
|
||||
)
|
||||
|
||||
|
||||
async def test_del_subsribe(init_scheduler):
|
||||
from nonebot_bison.config.db_config import config
|
||||
from nonebot_bison.config.db_model import Subscribe, Target, User
|
||||
from nonebot_bison.types import Target as TTarget
|
||||
from nonebot_plugin_datastore.db import get_engine
|
||||
|
||||
await config.add_subscribe(
|
||||
user=123,
|
||||
user_type="group",
|
||||
target=TTarget("weibo_id"),
|
||||
target_name="weibo_name",
|
||||
platform_name="weibo",
|
||||
cats=[],
|
||||
tags=[],
|
||||
)
|
||||
await config.del_subscribe(
|
||||
user=123,
|
||||
user_type="group",
|
||||
target=TTarget("weibo_id"),
|
||||
platform_name="weibo",
|
||||
)
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
assert (await sess.scalar(select(func.count()).select_from(Subscribe))) == 0
|
||||
assert (await sess.scalar(select(func.count()).select_from(Target))) == 1
|
||||
|
||||
await config.add_subscribe(
|
||||
user=123,
|
||||
user_type="group",
|
||||
target=TTarget("weibo_id"),
|
||||
target_name="weibo_name",
|
||||
platform_name="weibo",
|
||||
cats=[],
|
||||
tags=[],
|
||||
)
|
||||
|
||||
await config.add_subscribe(
|
||||
user=124,
|
||||
user_type="group",
|
||||
target=TTarget("weibo_id"),
|
||||
target_name="weibo_name_new",
|
||||
platform_name="weibo",
|
||||
cats=[],
|
||||
tags=[],
|
||||
)
|
||||
|
||||
await config.del_subscribe(
|
||||
user=123,
|
||||
user_type="group",
|
||||
target=TTarget("weibo_id"),
|
||||
platform_name="weibo",
|
||||
)
|
||||
|
||||
async with AsyncSession(get_engine()) as sess:
|
||||
assert (await sess.scalar(select(func.count()).select_from(Subscribe))) == 1
|
||||
assert (await sess.scalar(select(func.count()).select_from(Target))) == 1
|
||||
target: Target = await sess.scalar(select(Target))
|
||||
assert target.target_name == "weibo_name_new"
|