同步主分支

This commit is contained in:
Azide 2022-10-16 01:13:36 +08:00
commit 265a7dc07a
129 changed files with 24004 additions and 5972 deletions

View File

@ -5,7 +5,7 @@ inputs:
python-version:
description: Python version
required: false
default: "3.9"
default: "3.10"
runs:
using: "composite"

View File

@ -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
View File

@ -133,6 +133,7 @@ ENV/
env.bak/
venv.bak/
pythonenv*
venv_test/
# Spyder project settings
.spyderproject

View File

@ -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'

View File

@ -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
### 新功能

View 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": {}
}
}
}

View File

@ -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

View File

@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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);
}
}

View File

@ -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();
});

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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);
}
);

View 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;

View 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>
>;

View File

@ -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>
);
}

View File

@ -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>
)}
</>
);
}

View File

@ -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>
);
}

View 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>;
}

View 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;

View 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;

View 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;

View File

@ -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}
/>
</>
);
}

View File

@ -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 />;
}

View File

@ -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;

View File

@ -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;

View 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;

View 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;

View 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>;
}

View File

@ -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;

View File

@ -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

View File

@ -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

View 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);
}

View 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>
);
}

View File

@ -0,0 +1,7 @@
import React from 'react';
export default function Unauthed() {
return (
<div>not login</div>
);
}

View File

@ -1,5 +0,0 @@
.layout-side .user {
height: 32px;
margin: 16px;
background: rgba(255, 255, 255, 0.3);
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}

View File

@ -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>
)}
</>
);
}

View File

@ -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);

View File

@ -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';

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View 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`;

View File

@ -18,8 +18,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./"
"jsx": "react-jsx"
},
"include": [
"src"

File diff suppressed because it is too large Load Diff

109
alembic.ini Normal file
View 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
View 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%

View File

@ -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"]

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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 = '''
'''

View File

@ -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",
]

View File

@ -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")

View File

@ -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": ""}

View 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")

View File

@ -0,0 +1,3 @@
from .db import DATA
from .db_config import config
from .utils import NoSuchSubscribeException, NoSuchTargetException, NoSuchUserException

View File

@ -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()

View 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()

View 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()

View 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")

View File

@ -0,0 +1 @@
Generic single-database configuration.

View 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()

View 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"}

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -0,0 +1,10 @@
class NoSuchUserException(Exception):
pass
class NoSuchSubscribeException(Exception):
pass
class NoSuchTargetException(Exception):
pass

View File

@ -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,

View File

@ -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:

View File

@ -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,
)

View File

@ -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:

View File

@ -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:

View File

@ -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的链接"

View File

@ -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的链接"

View File

@ -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)
)

View File

@ -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]:

View File

@ -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的链接"

View File

@ -0,0 +1 @@
from .manager import *

View 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()

View 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)

View File

@ -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

View 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

View File

@ -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

View File

@ -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):

View File

@ -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)

View 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,
},
)

View File

@ -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"]

View 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"

Some files were not shown because too many files have changed in this diff Show More