├── .dockerignore ├── .editorconfig ├── .env.example ├── .flake8 ├── .github └── workflows │ └── deploy_docs.yml ├── .gitignore ├── .quartenv.example ├── Dockerfile ├── README.md ├── alembic.ini ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── admin.py │ ├── push.py │ ├── subscribe.py │ └── user.py ├── bot │ ├── __init__.py │ ├── admin.py │ ├── bind_jwc │ │ └── __init__.py │ ├── code_runner.py │ ├── course_schedule │ │ ├── __init__.py │ │ └── parse.py │ ├── credit │ │ ├── __init__.py │ │ └── service.py │ ├── deposit_ics.py │ ├── ecard │ │ ├── __init__.py │ │ └── service.py │ ├── hitokoto.py │ ├── man.py │ ├── nlp │ │ └── __init__.py │ ├── notice_handler.py │ ├── preprocessor.py │ ├── remind │ │ ├── __init__.py │ │ ├── commands.py │ │ └── nlp.py │ ├── request_handler.py │ ├── rss │ │ └── __init__.py │ ├── save_chat_records.py │ ├── score.py │ ├── subscribe │ │ └── __init__.py │ └── switch.py ├── config.py ├── constants │ └── dean.py ├── env.py ├── exceptions.py ├── libs │ ├── __init__.py │ ├── aio.py │ ├── cache.py │ ├── gino.py │ ├── qqai_async │ │ ├── README.md │ │ ├── __init__.py │ │ └── aaiasr.py │ ├── roconfig.py │ └── scheduler │ │ ├── __init__.py │ │ ├── command.py │ │ └── exception.py ├── models │ ├── __init__.py │ ├── base.py │ ├── chat_records.py │ ├── course.py │ ├── score.py │ ├── subscribe.py │ └── user.py ├── services │ ├── __init__.py │ ├── course │ │ └── parse.py │ ├── credit │ │ └── parse.py │ ├── ecard │ │ └── parse.py │ ├── score │ │ ├── __init__.py │ │ ├── parse.py │ │ └── utils.py │ └── subscribe │ │ ├── __init__.py │ │ ├── dean.py │ │ ├── school_notice.py │ │ └── wrapper.py └── utils │ ├── __init__.py │ ├── api.py │ ├── bot.py │ ├── rss.py │ ├── str_.py │ └── tools.py ├── docker-compose.yml ├── docs ├── .markdownlint.json ├── .vuepress │ ├── README.md │ ├── components │ │ ├── ChatMessage.vue │ │ ├── PanelView.vue │ │ ├── Terminal.vue │ │ └── WindowJitte.vue │ ├── config.js │ ├── public │ │ ├── CNAME │ │ ├── favicon.ico │ │ └── logo.jpg │ └── styles │ │ ├── index.styl │ │ └── palette.styl ├── README.md ├── deploy │ ├── README.md │ ├── set-env.md │ ├── set-qq.md │ └── update.md └── guide │ ├── README.md │ ├── bind.md │ ├── code_runner.md │ ├── course_schedule.md │ ├── credit.md │ ├── ecard.md │ ├── hitokoto.md │ ├── remind.md │ ├── rss.md │ ├── score.md │ └── subscribe.md ├── log.py ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ ├── 4101e812bbb4_fix_score.py │ └── ead93f48a985_init.py ├── package.json ├── poetry.lock ├── pyproject.toml ├── run.py ├── shelltools.py ├── tests ├── audio_test.py ├── data │ └── record │ │ └── 777D935D643B0300777D935D643B0300.silk └── dwz_test.py └── web ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .umirc.ts ├── README.md ├── mock └── .gitkeep ├── package.json ├── src ├── components │ ├── Agreement.jsx │ ├── Loading.jsx │ └── icons │ │ ├── RSS.jsx │ │ └── rss.svg ├── pages │ ├── Login.jsx │ ├── Login.less │ ├── UserCenter.jsx │ ├── UserCenter.less │ ├── config.js │ ├── index.less │ ├── index.tsx │ └── tools.js └── styles │ └── parameter.less ├── tsconfig.json └── typings.d.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | data 2 | web 3 | tests 4 | 5 | .ipynb_checkpoints 6 | *.pyc 7 | *.pyo 8 | *.pyd 9 | .mypy_cache 10 | .pytest_cache 11 | 12 | # Git 13 | .git 14 | .gitignore 15 | 16 | # Docker 17 | docker-compose.yml 18 | 19 | # Byte-compiled / optimized / DLL files 20 | __pycache__/ 21 | */__pycache__/ 22 | */*/__pycache__/ 23 | */*/*/__pycache__/ 24 | *.py[cod] 25 | */*.py[cod] 26 | */*/*.py[cod] 27 | */*/*/*.py[cod] 28 | 29 | # PyCharm 30 | .idea 31 | 32 | .vscode 33 | README.md 34 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | # The JSON files contain newlines inconsistently 13 | [*.json] 14 | insert_final_newline = ignore 15 | 16 | # Minified JavaScript files shouldn't be changed 17 | [**.min.js] 18 | indent_style = ignore 19 | insert_final_newline = ignore 20 | 21 | # Makefiles always use tabs for indentation 22 | [Makefile] 23 | indent_style = tab 24 | 25 | # Batch files use tabs for indentation 26 | [*.bat] 27 | indent_style = tab 28 | 29 | [*.md] 30 | trim_trailing_whitespace = false 31 | 32 | # Matches the exact files either package.json or .travis.yml 33 | [{package.json,.travis.yml}] 34 | indent_size = 2 35 | 36 | [*.py] 37 | indent_size = 4 38 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | HOST=0.0.0.0 2 | PORT=8080 3 | DEBUG=False 4 | # 加密的key 5 | SECRET=xxxx 6 | 7 | # 管理员qq,逗号分隔 8 | SUPERUSERS=xxxx,xxxx 9 | CAPTCHA_BACKEND="keras" 10 | # RSS 更新频率 11 | SUBSCIBE_INTERVAL=600 12 | 13 | # 缓存教务处登录 SESSION 的时间(单位 s) 14 | CACHE_SESSION_TIMEOUT=1800 15 | 16 | # WEB 页面地址 17 | WEB_URL=xxx 18 | # RSSHUB 的地址 19 | RSSHUB_URL=xxx 20 | 21 | # 第三方服务相关 22 | # 腾讯AI 语音识别调用时候会用到 23 | QQAI_APPID=xxx 24 | QQAI_APPKEY=xxx 25 | GLOT_IO_TOKEN=xxx 26 | 27 | # cqhttp docker 启动相关 28 | # 要登陆的 QQ 号 29 | COOLQ_ACCOUNT=xxxxx 30 | # vnc 密码 31 | VNC_PASSWD=xxx 32 | 33 | # 数据库相关信息 34 | POSTGRES_USER=user 35 | POSTGRES_PASSWORD=password 36 | POSTGRES_DB=qqrobot 37 | REDIS_PASSWORD=xxxxxx 38 | 39 | # docker port 相关 40 | DATABASE_HOST=database 41 | DATABASE_PORT=5432 42 | REDIS_PORT=6379 43 | CQHTTP_PORT=9000 44 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | ignore = 4 | E121,E123,E125,E126,E128, 5 | E24,E201,E202,E203,E211,E221,E222,E226,E231,E241,E251,E271,E272, 6 | E303, 7 | E402, 8 | E501, 9 | E701,E704, 10 | W503,W504, 11 | F403, 12 | exclude = 13 | migrations/*, 14 | web/* 15 | per-file-ignores = 16 | app/api/__init__.py: F401 17 | -------------------------------------------------------------------------------- /.github/workflows/deploy_docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v1 14 | - name: Set up Node 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 10.x 18 | - name: Get yarn cache 19 | id: yarn-cache 20 | run: echo "::set-output name=dir::$(yarn cache dir)" 21 | - name: Restore Cache 22 | uses: actions/cache@v1 23 | with: 24 | path: ${{ steps.yarn-cache.outputs.dir }} 25 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 26 | restore-keys: ${{ runner.os }}-yarn- 27 | - name: Install 28 | run: yarn install 29 | - name: Build 30 | run: yarn docs:build 31 | - name: Deploy 32 | uses: peaceiris/actions-gh-pages@v3 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | publish_branch: gh-pages 36 | publish_dir: ./docs/.vuepress/dist 37 | allow_empty_commit: false 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _web 2 | test.py 3 | app/bot/plugins/course_schedule/test.py 4 | logs 5 | .vscode 6 | data 7 | 8 | .quartenv 9 | .env.local 10 | 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | pip-wheel-metadata/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | db.sqlite3-journal 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # celery beat schedule file 104 | celerybeat-schedule 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 137 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 138 | .idea/ 139 | # User-specific stuff 140 | .idea/**/workspace.xml 141 | .idea/**/tasks.xml 142 | .idea/**/usage.statistics.xml 143 | .idea/**/dictionaries 144 | .idea/**/shelf 145 | 146 | # Generated files 147 | .idea/**/contentModel.xml 148 | 149 | # Sensitive or high-churn files 150 | .idea/**/dataSources/ 151 | .idea/**/dataSources.ids 152 | .idea/**/dataSources.local.xml 153 | .idea/**/sqlDataSources.xml 154 | .idea/**/dynamic.xml 155 | .idea/**/uiDesigner.xml 156 | .idea/**/dbnavigator.xml 157 | 158 | # Gradle 159 | .idea/**/gradle.xml 160 | .idea/**/libraries 161 | 162 | # Gradle and Maven with auto-import 163 | # When using Gradle or Maven with auto-import, you should exclude module files, 164 | # since they will be recreated, and may cause churn. Uncomment if using 165 | # auto-import. 166 | # .idea/modules.xml 167 | # .idea/*.iml 168 | # .idea/modules 169 | # *.iml 170 | # *.ipr 171 | 172 | # CMake 173 | cmake-build-*/ 174 | 175 | # Mongo Explorer plugin 176 | .idea/**/mongoSettings.xml 177 | 178 | # File-based project format 179 | *.iws 180 | 181 | # IntelliJ 182 | out/ 183 | 184 | # mpeltonen/sbt-idea plugin 185 | .idea_modules/ 186 | 187 | # JIRA plugin 188 | atlassian-ide-plugin.xml 189 | 190 | # Cursive Clojure plugin 191 | .idea/replstate.xml 192 | 193 | # Crashlytics plugin (for Android Studio and IntelliJ) 194 | com_crashlytics_export_strings.xml 195 | crashlytics.properties 196 | crashlytics-build.properties 197 | fabric.properties 198 | 199 | # Editor-based Rest Client 200 | .idea/httpRequests 201 | 202 | # Android studio 3.1+ serialized cache file 203 | .idea/caches/build_file_checksums.ser 204 | 205 | # Windows thumbnail cache files 206 | Thumbs.db 207 | Thumbs.db:encryptable 208 | ehthumbs.db 209 | ehthumbs_vista.db 210 | 211 | # Dump file 212 | *.stackdump 213 | 214 | # Folder config file 215 | [Dd]esktop.ini 216 | 217 | # Recycle Bin used on file shares 218 | $RECYCLE.BIN/ 219 | 220 | # Windows Installer files 221 | *.cab 222 | *.msi 223 | *.msix 224 | *.msm 225 | *.msp 226 | 227 | # Windows shortcuts 228 | *.lnk 229 | 230 | # ----- Node ----- 231 | 232 | # Logs 233 | logs 234 | *.log 235 | npm-debug.log* 236 | yarn-debug.log* 237 | yarn-error.log* 238 | 239 | # Runtime data 240 | pids 241 | *.pid 242 | *.seed 243 | *.pid.lock 244 | 245 | # Directory for instrumented libs generated by jscoverage/JSCover 246 | lib-cov 247 | 248 | # Coverage directory used by tools like istanbul 249 | coverage 250 | 251 | # nyc test coverage 252 | .nyc_output 253 | 254 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 255 | .grunt 256 | 257 | # Bower dependency directory (https://bower.io/) 258 | bower_components 259 | 260 | # node-waf configuration 261 | .lock-wscript 262 | 263 | # Compiled binary addons (https://nodejs.org/api/addons.html) 264 | build/Release 265 | 266 | # Dependency directories 267 | node_modules/ 268 | jspm_packages/ 269 | 270 | # TypeScript v1 declaration files 271 | typings/ 272 | 273 | # Optional npm cache directory 274 | .npm 275 | 276 | # Optional eslint cache 277 | .eslintcache 278 | 279 | # Optional REPL history 280 | .node_repl_history 281 | 282 | # Output of 'npm pack' 283 | *.tgz 284 | 285 | # Yarn Integrity file 286 | .yarn-integrity 287 | 288 | # dotenv environment variables file 289 | .env 290 | 291 | # parcel-bundler cache (https://parceljs.org/) 292 | .cache 293 | 294 | # next.js build output 295 | .next 296 | 297 | # nuxt.js build output 298 | .nuxt 299 | 300 | # vuepress build output 301 | .vuepress/dist 302 | 303 | # Serverless directories 304 | .serverless 305 | -------------------------------------------------------------------------------- /.quartenv.example: -------------------------------------------------------------------------------- 1 | # asgi 位置 2 | QUART_APP="run:create_app()" 3 | # 是否开启 DEBUG 模式 4 | QUART_DEBUG=True 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.2-buster 2 | 3 | ARG DEBIAN_FRONTEND=noninteractive 4 | ARG PIP_DISABLE_PIP_VERSION_CHECK=1 5 | ARG PIP_NO_CACHE_DIR=1 6 | 7 | RUN sed -i 's|http://deb.debian.org|https://mirrors.aliyun.com|g' /etc/apt/sources.list 8 | RUN sed -i 's|http://security.debian.org|https://mirrors.aliyun.com|g' /etc/apt/sources.list 9 | 10 | RUN python3 -m pip config set global.index-url https://mirrors.aliyun.com/pypi/simple 11 | 12 | RUN python3 -m pip install poetry 13 | 14 | # Only copying these files here in order to take advantage of Docker cache. We only want the 15 | # next stage (poetry install) to run if these files change, but not the rest of the app. 16 | COPY pyproject.toml poetry.lock /qbot/ 17 | WORKDIR /qbot 18 | 19 | # Currently poetry install is significantly slower than pip install, so we're creating a 20 | # requirements.txt output and running pip install with it. 21 | # Follow this issue: https://github.com/python-poetry/poetry/issues/338 22 | # Setting --without-hashes because of this issue: https://github.com/pypa/pip/issues/4995 23 | RUN poetry config virtualenvs.create false \ 24 | && poetry export --without-hashes -f requirements.txt \ 25 | | poetry run pip install -r /dev/stdin \ 26 | && poetry debug 27 | 28 | COPY . /qbot/ 29 | 30 | # Because initially we only copy the lock and pyproject file, we can only install the dependencies 31 | # in the RUN above, as the `packages` portion of the pyproject.toml file is not 32 | # available at this point. Now, after the whole package has been copied in, we run `poetry install` 33 | # again to only install packages, scripts, etc. (and thus it should be very quick). 34 | # See this issue for more context: https://github.com/python-poetry/poetry/issues/1899 35 | RUN poetry install --no-interaction --no-dev 36 | 37 | VOLUME /qbot /coolq 38 | 39 | ENTRYPOINT ["poetry", "run"] 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iswust_bot 2 | 3 | [![Deploy Docs](https://github.com/BudyLab/iswust_bot/workflows/Deploy%20Docs/badge.svg)](https://github.com/BudyLab/iswust_bot/actions?query=workflow%3A%22Deploy+Docs%22) 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 5 | 6 | 整合了很多功能的教务机器人,[文档 - 开始使用](https://bot.artin.li/) 7 | 8 | --- 9 | 10 | ## 配置 11 | 12 | 查看文档中 [deploy](https://bot.artin.li/deploy/) 一节的内容。 13 | 14 | ## 开发 15 | 16 | 如果想在本地开发还是需要安装开发环境的,用以 ide 的提示之类的。 17 | 18 | ```sh 19 | poetry install 20 | ``` 21 | 22 | ## 语音识别 23 | 24 | 识别使用的是 的 API,你需要自己去申请一个密钥,填入 .env 即可。 25 | 26 | ### docker 相关命令 27 | 28 | #### 更新 poetry 依赖 29 | 30 | ```sh 31 | docker-compose exec nonebot poetry install --no-interaction --no-dev 32 | ``` 33 | 34 | #### 查看运行日志 35 | 36 | ```sh 37 | docker-compose logs -f --tail 10 nonebot 38 | ``` 39 | 40 | #### 执行数据库 migrate 41 | 42 | ```sh 43 | # 如果 container 已经在运行中的话,可以使用 `exec`: 44 | docker-compose exec nonebot alembic revision --autogenerate -m 'init' 45 | # 没运行的话可以执行: 46 | docker-compose run --rm nonebot alembic revision --autogenerate -m 'init' 47 | docker-compose run --rm nonebot alembic revision --autogenerate -m 'add score id' 48 | ``` 49 | 50 | #### 报错 `Target database is not up to date.` 51 | 52 | ```sh 53 | # 同上所述,container 运行中可以使用: 54 | docker-compose exec nonebot alembic stamp heads 55 | # 否则: 56 | docker-compose run --rm nonebot alembic stamp heads 57 | ``` 58 | 59 | #### 升级到最新数据库 60 | 61 | ```sh 62 | # 如果 container 已经在运行中的话,可以使用 `exec`: 63 | docker-compose exec nonebot alembic upgrade head 64 | # 否则: 65 | docker-compose run --rm nonebot alembic upgrade head 66 | ``` 67 | 68 | #### 删除本地数据库 69 | 70 | 先停止数据库,然后删除 `volume`: 71 | 72 | ```sh 73 | docker-compose rm -s -v database 74 | docker volume rm iswust_nonebot_database_data 75 | # 再启动 76 | docker-compose up -d database 77 | docker-compose exec nonebot alembic upgrade head 78 | ``` 79 | 80 | 或者直接删除所有东西,包括 `container`,`volume`: 81 | 82 | ```sh 83 | docker-compose down -v --remove-orphans 84 | ``` 85 | 86 | #### 重启 nonebot 87 | 88 | ```sh 89 | docker-compose restart nonebot 90 | ``` 91 | 92 | ## 致谢 93 | 94 | - Nonebot 95 | - 奶茶机器人 96 | - ELF_RSS 97 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | 39 | [post_write_hooks] 40 | # post_write_hooks defines scripts or Python functions that are run 41 | # on newly generated revision scripts. See the documentation for further 42 | # detail and examples 43 | 44 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 45 | # hooks=black 46 | # black.type=console_scripts 47 | # black.entrypoint=black 48 | # black.options=-l 79 49 | 50 | # Logging configuration 51 | [loggers] 52 | keys = root,sqlalchemy,alembic 53 | 54 | [handlers] 55 | keys = console 56 | 57 | [formatters] 58 | keys = generic 59 | 60 | [logger_root] 61 | level = WARN 62 | handlers = console 63 | qualname = 64 | 65 | [logger_sqlalchemy] 66 | level = WARN 67 | handlers = 68 | qualname = sqlalchemy.engine 69 | 70 | [logger_alembic] 71 | level = INFO 72 | handlers = 73 | qualname = alembic 74 | 75 | [handler_console] 76 | class = StreamHandler 77 | args = (sys.stderr,) 78 | level = NOTSET 79 | formatter = generic 80 | 81 | [formatter_generic] 82 | format = %(levelname)-5.5s [%(name)s] %(message)s 83 | datefmt = %H:%M:%S 84 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from loguru import logger 3 | 4 | import nonebot as nb 5 | from nonebot import default_config 6 | from quart import Quart 7 | 8 | __all__ = ["init"] 9 | 10 | 11 | def load_config(): 12 | from .libs.roconfig import Configuration 13 | from .config import Config 14 | 15 | conf = Configuration() 16 | conf.add_object(default_config) 17 | conf.add_object(Config) 18 | logger.info(conf.to_dict()) 19 | return conf.to_config() 20 | 21 | 22 | def init_bot() -> nb.NoneBot: 23 | config = load_config() 24 | nb.init(config) 25 | bot = nb.get_bot() 26 | 27 | nb.load_builtin_plugins() 28 | nb.load_plugins(path.join(path.dirname(__file__), "bot"), "app.bot") 29 | 30 | from .libs.gino import init_db 31 | from .libs.scheduler import init_scheduler 32 | 33 | bot.server_app.before_serving(init_db) 34 | bot.server_app.before_serving(init_scheduler) 35 | 36 | return bot 37 | 38 | 39 | def register_blueprint(app: Quart): 40 | from .utils.tools import load_modules 41 | 42 | load_modules("app.api") 43 | from .api import api as api_blueprint 44 | 45 | app.register_blueprint(api_blueprint) 46 | 47 | 48 | def init_shell(app: Quart): 49 | from .libs.gino import db 50 | from app.models.user import User 51 | from app.models.course import CourseStudent 52 | from app.models.chat_records import ChatRecords 53 | from app.models.subscribe import SubContent, SubUser 54 | 55 | @app.shell_context_processor 56 | def _(): 57 | return { 58 | "db": db, 59 | "User": User, 60 | "CourseStudent": CourseStudent, 61 | "ChatRecords": ChatRecords, 62 | "SubContent": SubContent, 63 | "SubUser": SubUser, 64 | } 65 | 66 | 67 | def init(mode: str = "bot") -> Quart: 68 | from .env import load_env 69 | 70 | load_env(mode) 71 | 72 | if mode == "bot": 73 | _bot = init_bot() 74 | app = _bot.asgi 75 | register_blueprint(app) 76 | else: 77 | app = Quart(__name__) 78 | init_shell(app) 79 | return app 80 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from quart import Blueprint 2 | 3 | api = Blueprint("api", __name__, url_prefix="/api") 4 | -------------------------------------------------------------------------------- /app/api/admin.py: -------------------------------------------------------------------------------- 1 | from . import api 2 | 3 | 4 | @api.route("/admin") 5 | async def admin(): 6 | return "This is the admin page." 7 | -------------------------------------------------------------------------------- /app/api/push.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | 4 | import nonebot as nb 5 | from loguru import logger 6 | from nonebot import CQHttpError 7 | from quart import jsonify, request 8 | 9 | from app.utils.api import check_args, to_token 10 | from . import api 11 | 12 | 13 | @api.route("/push", methods=["POST"]) 14 | async def push(): 15 | bot = nb.get_bot() 16 | 17 | query: dict = json.loads(await request.get_data()) 18 | qq_: Optional[str] = query.get("qq") 19 | msg_: Optional[str] = query.get("msg") 20 | token_: Optional[str] = query.get("token") 21 | 22 | result, msg = check_args(qq=qq_, msg=msg_, token=token_) 23 | 24 | rcode_ = 403 25 | rmsg_ = msg 26 | 27 | if result and qq_: 28 | encrypt_qq = to_token(qq_) 29 | 30 | logger.info(f"qq: {qq_} msg: {msg_} token: {token_} encrypt_qq: {encrypt_qq}") 31 | if token_ == encrypt_qq: 32 | try: 33 | await bot.send_private_msg(user_id=qq_, message=msg_) 34 | rcode_ = 200 35 | rmsg_ = "发送成功" 36 | except CQHttpError: 37 | rcode_ = 500 38 | rmsg_ = "向用户发消息失败!" 39 | bot._server_app.config["JSONIFY_MIMETYPE"] = "text/html" 40 | else: 41 | rmsg_ = "验证信息错误" 42 | logger.info(f"rcode_: {rcode_} rmsg_: {rmsg_}") 43 | return jsonify(code=rcode_, msg=rmsg_) 44 | -------------------------------------------------------------------------------- /app/api/subscribe.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import defaultdict 3 | from typing import Optional 4 | 5 | from quart import abort, request 6 | from quart.views import MethodView 7 | 8 | from app.services.subscribe.wrapper import SubWrapper, judge_sub 9 | from app.utils.api import check_args, false_ret, to_token, true_ret 10 | from app.utils.bot import qq2event 11 | 12 | from . import api 13 | 14 | 15 | class SubsAPI(MethodView): 16 | async def get(self): 17 | query: dict = request.args 18 | qq: Optional[str] = query.get("qq") 19 | token: Optional[str] = query.get("token") 20 | 21 | _, msg = check_args(qq=qq, token=token) 22 | if not _: 23 | return false_ret(msg=msg) 24 | 25 | if to_token(qq) != token: 26 | abort(403) 27 | 28 | user_subs = await SubWrapper.get_user_sub(qq2event(qq)) # type: ignore 29 | available_subs = SubWrapper.get_subs() 30 | result = defaultdict(dict) 31 | for k, v in available_subs.items(): 32 | result[k]["name"] = v 33 | if user_subs.get(k): 34 | result[k]["enable"] = True 35 | else: 36 | result[k]["enable"] = False 37 | return true_ret(data=result) 38 | 39 | async def post(self): 40 | query: dict = request.args 41 | qq: Optional[str] = query.get("qq") 42 | token: Optional[str] = query.get("token") 43 | 44 | data = await request.get_data() 45 | 46 | _, msg = check_args(qq=qq, token=token) 47 | if not _: 48 | return false_ret(msg=msg) 49 | 50 | if to_token(qq) != token: 51 | abort(403) 52 | data = json.loads(data) 53 | 54 | error_key = [] 55 | process_info = defaultdict(dict) 56 | for key, enable in data.items(): 57 | SubC = judge_sub(key) 58 | if not SubC: 59 | error_key.append(key) 60 | continue 61 | if enable: 62 | result, p_msg = await SubC.add_sub(qq2event(qq), key) # type: ignore 63 | else: 64 | result, p_msg = await SubC.del_sub(qq2event(qq), key) # type: ignore 65 | process_info[key]["result"] = result 66 | process_info[key]["msg"] = p_msg 67 | if error_key: 68 | return false_ret(data=error_key, msg="error keys") 69 | return true_ret(data=process_info) 70 | 71 | 72 | api.add_url_rule("/subs", view_func=SubsAPI.as_view("subs")) 73 | -------------------------------------------------------------------------------- /app/api/user.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | from auth_swust import Login 4 | from loguru import logger 5 | from nonebot import get_bot 6 | from quart import abort, request 7 | 8 | from app.libs.aio import run_sync_func 9 | from app.models.user import User 10 | from app.utils.bot import qq2event, get_user_center 11 | from app.utils.api import false_ret, true_ret, check_args, to_token 12 | 13 | from . import api 14 | 15 | 16 | @api.route("/user/bind", methods=["POST"]) 17 | async def bind(): 18 | res = await request.get_json() 19 | qq = res.get("qq") 20 | username = res.get("username") 21 | password = res.get("password") 22 | token = res.get("token") 23 | 24 | logger.debug("username:{} password:{} token:{}".format(username, password, token)) 25 | 26 | result, msg = check_args(qq=qq, username=username, password=password, token=token) 27 | 28 | if to_token(qq) != token: 29 | abort(403) 30 | 31 | if not result: 32 | return false_ret(msg=msg) 33 | 34 | logger.info("qq{}正在请求绑定!".format(qq)) 35 | # 是否已经绑定 36 | user = await User.get(str(qq)) 37 | 38 | _bot = get_bot() 39 | if user is None: 40 | logger.info("qq{}是新用户,正在尝试登录教务处...".format(qq)) 41 | u = Login(username, password) 42 | is_log, log_resp = await run_sync_func(u.try_login) 43 | if is_log: 44 | user_info = await run_sync_func(log_resp.json) 45 | logger.debug(user_info) 46 | await logger.complete() 47 | 48 | await User.add( 49 | qq=qq, 50 | student_id=username, 51 | password=password, 52 | cookies=pickle.dumps(u.get_cookies()), 53 | ) 54 | logger.info("qq{}绑定成功!".format(qq)) 55 | await _bot.send(qq2event(qq), "教务处绑定成功!") 56 | await _bot.send(qq2event(qq), "可以向我发送 帮助 来继续使用~") 57 | await _bot.send(qq2event(qq), "点击 https://bot.artin.li 来查看帮助~") 58 | await _bot.send( 59 | qq2event(qq), f"点击个人中心可以配置更多: {get_user_center(qq2event(qq))}" 60 | ) 61 | return true_ret("qq绑定成功!") 62 | else: 63 | logger.info("qq{}绑定失败!".format(qq)) 64 | await _bot.send(qq2event(qq), "教务处绑定失败!") 65 | return false_ret("qq绑定失败!失败原因是{}".format(log_resp)) 66 | return false_ret("该qq已经绑定了!") 67 | 68 | 69 | @api.route("/user/unbind") 70 | async def unbind(): 71 | qq = request.args.get("qq") 72 | token = request.args.get("token") 73 | result, msg = check_args(qq=qq, token=token) 74 | 75 | if to_token(qq) != token: 76 | abort(403) 77 | if not result: 78 | return false_ret(msg=msg) 79 | 80 | logger.info("qq {}正在请求解绑!".format(qq)) 81 | 82 | try: 83 | await User.unbind(qq) 84 | logger.info("qq {}请求解绑成功!".format(qq)) 85 | return true_ret(msg="解除绑定成功!") 86 | except Exception as e: 87 | logger.exception(e) 88 | 89 | return false_ret(msg="没有这个用户!") 90 | -------------------------------------------------------------------------------- /app/bot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/app/bot/__init__.py -------------------------------------------------------------------------------- /app/bot/admin.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | 3 | import nonebot.permission as perm 4 | from nonebot import CommandGroup, CommandSession, get_bot, on_command 5 | from nonebot.command import CommandManager 6 | 7 | from app.libs.cache import cache 8 | 9 | 10 | @on_command("get_configs", aliases=["设置"], permission=perm.SUPERUSER) 11 | async def get_config(session: CommandSession): 12 | await session.send(f"{pprint.pformat(get_bot().config.__dict__, indent=2)}") 13 | 14 | 15 | @on_command("get_commands", aliases=["命令"], permission=perm.SUPERUSER) 16 | async def get_command(session: CommandSession): 17 | await session.send(f"{pprint.pformat(CommandManager._commands, indent=2)}") 18 | await session.send(f"{pprint.pformat(CommandManager._aliases, indent=2)}") 19 | 20 | 21 | cc = CommandGroup("cache", permission=perm.SUPERUSER) 22 | 23 | 24 | @cc.command("set") 25 | async def _(session: CommandSession): 26 | data = session.current_arg 27 | try: 28 | key, value = data.split() 29 | key = key.strip() 30 | value = value.strip() 31 | await cache.set(key, value) 32 | except Exception: 33 | await session.send("输入有误") 34 | 35 | 36 | @cc.command("get") 37 | async def _(session: CommandSession): 38 | data = session.current_arg 39 | data = data.strip() 40 | value = await cache.get(data) 41 | await session.send(value) 42 | -------------------------------------------------------------------------------- /app/bot/bind_jwc/__init__.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from nonebot import CommandSession, on_command 4 | 5 | from app.config import Config 6 | from app.models.user import User 7 | from app.utils.api import to_token 8 | from base64 import b64encode 9 | 10 | __plugin_name__ = "绑定教务处" 11 | __plugin_short_description__ = "命令:bind/unbind" 12 | 13 | __plugin_usage__ = r""" 14 | 帮助链接:https://bot.artin.li/guide/bind.html 15 | 16 | 对我发以下关键词绑定教务处: 17 | - bind 18 | - 绑定 19 | - 绑定教务处 20 | 21 | 取消绑定教务处 22 | 使用方法:向我发送以下指令。 23 | - unbind 24 | - 取消绑定 25 | - 解绑 26 | """ 27 | 28 | 29 | @on_command("bind", aliases=("绑定", "绑定教务处")) 30 | async def bind(session: CommandSession): 31 | web_url = Config.WEB_URL 32 | if not web_url: 33 | session.finish("绑定功能未启用") 34 | 35 | await session.send("开始请求绑定~ 请等待") 36 | 37 | sender_qq = session.event["user_id"] 38 | token = to_token(sender_qq) 39 | # web 登录界面地址 40 | query: str = urlencode({"qq": sender_qq, "token": token}) 41 | encoded_query = b64encode(query.encode("utf8")).decode("utf8") 42 | url_ = f"{Config.WEB_URL}/login/?{encoded_query}" 43 | session.finish(f"请点击链接绑定:{url_}") 44 | 45 | 46 | @on_command("unbind", aliases=("解绑", "取消绑定", "取消绑定教务处")) 47 | async def unbind(session: CommandSession): 48 | r = await User.unbind(session.event["user_id"]) 49 | if r: 50 | session.finish("取消绑定成功") 51 | session.finish("取消绑定失败") 52 | -------------------------------------------------------------------------------- /app/bot/code_runner.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from loguru import logger 3 | from nonebot import CommandSession, on_command 4 | from nonebot.command.argfilter import controllers, validators 5 | from nonebot.message import escape as message_escape 6 | 7 | from app.env import env 8 | 9 | __plugin_name__ = "运行代码" 10 | __plugin_short_description__ = "命令: run" 11 | __plugin_usage__ = r""" 12 | 帮助链接:https://bot.artin.li/guide/code_runner.html 13 | 14 | 运行代码: 15 | - code_runner 16 | - run 17 | - 执行代码 18 | - 运行 19 | - 运行代码 20 | 然后根据提示输入即可。 21 | 22 | 你也可以直接在后面加上相关内容,如: 23 | 24 | run python 25 | print(1234) 26 | """.strip() 27 | RUN_API_URL_FORMAT = "https://run.glot.io/languages/{}/latest" 28 | SUPPORTED_LANGUAGES = { 29 | "assembly": {"ext": "asm"}, 30 | "bash": {"ext": "sh"}, 31 | "c": {"ext": "c"}, 32 | "clojure": {"ext": "clj"}, 33 | "coffeescript": {"ext": "coffe"}, 34 | "cpp": {"ext": "cpp"}, 35 | "csharp": {"ext": "cs"}, 36 | "erlang": {"ext": "erl"}, 37 | "fsharp": {"ext": "fs"}, 38 | "go": {"ext": "go"}, 39 | "groovy": {"ext": "groovy"}, 40 | "haskell": {"ext": "hs"}, 41 | "java": {"ext": "java", "name": "Main"}, 42 | "javascript": {"ext": "js"}, 43 | "julia": {"ext": "jl"}, 44 | "kotlin": {"ext": "kt"}, 45 | "lua": {"ext": "lua"}, 46 | "perl": {"ext": "pl"}, 47 | "php": {"ext": "php"}, 48 | "python": {"ext": "py"}, 49 | "ruby": {"ext": "rb"}, 50 | "rust": {"ext": "rs"}, 51 | "scala": {"ext": "scala"}, 52 | "swift": {"ext": "swift"}, 53 | "typescript": {"ext": "ts"}, 54 | } 55 | 56 | 57 | @on_command("code_runner", aliases=["run", "运行代码", "运行", "执行代码"], only_to_me=False) 58 | async def run(session: CommandSession): 59 | api_token = env("GLOT_IO_TOKEN", None) 60 | if not api_token: 61 | logger.error("未设置 `GLOT_IO_TOKEN`") 62 | session.finish("运行代码功能未启用") 63 | supported_languages = ", ".join(sorted(SUPPORTED_LANGUAGES)) 64 | language = session.get( 65 | "language", 66 | prompt=f"你想运行的代码是什么语言?\n目前支持 {supported_languages}", 67 | arg_filters=[ 68 | controllers.handle_cancellation(session), 69 | str.lstrip, 70 | validators.not_empty("请输入有效内容哦~"), 71 | ], 72 | ) 73 | code = session.get( 74 | "code", 75 | prompt="你想运行的代码是?", 76 | arg_filters=[ 77 | controllers.handle_cancellation(session), 78 | str.lstrip, 79 | validators.not_empty("请输入有效内容哦~"), 80 | ], 81 | ) 82 | await session.send("正在运行,请稍等……") 83 | async with httpx.AsyncClient() as client: 84 | resp = await client.post( 85 | RUN_API_URL_FORMAT.format(language), 86 | json={ 87 | "files": [ 88 | { 89 | "name": (SUPPORTED_LANGUAGES[language].get("name", "main")) 90 | + f'.{SUPPORTED_LANGUAGES[language]["ext"]}', 91 | "content": code, 92 | } 93 | ], 94 | }, 95 | headers={"Authorization": f"Token {api_token}"}, 96 | ) 97 | if not resp: 98 | session.finish("运行失败,服务可能暂时不可用,请稍后再试。") 99 | 100 | payload = resp.json() 101 | 102 | sent = False 103 | for k in ["stdout", "stderr", "error"]: 104 | v = payload.get(k) 105 | lines = v.splitlines() 106 | lines, remained_lines = lines[:10], lines[10:] 107 | out = "\n".join(lines) 108 | out, remained_out = out[: 60 * 10], out[60 * 10 :] 109 | 110 | if remained_lines or remained_out: 111 | out += "\n(输出过多,已忽略剩余内容)" 112 | 113 | out = message_escape(out) 114 | if out: 115 | await session.send(f"{k}:\n\n{out}") 116 | sent = True 117 | 118 | if not sent: 119 | session.finish("运行成功,没有任何输出") 120 | 121 | 122 | @run.args_parser 123 | async def _(session: CommandSession): 124 | stripped_arg = session.current_arg_text.rstrip() 125 | if not session.is_first_run: 126 | if not stripped_arg: 127 | session.pause("请输入有效内容") 128 | session.state[session.current_key] = stripped_arg 129 | return 130 | 131 | if not stripped_arg: 132 | return 133 | 134 | # first argument is not empty 135 | language, *remains = stripped_arg.split("\n", maxsplit=1) 136 | language = language.strip() 137 | if language not in SUPPORTED_LANGUAGES: 138 | session.finish("暂时不支持运行你输入的编程语言") 139 | session.state["language"] = language 140 | 141 | if remains: 142 | code = remains[0].strip() # type: ignore 143 | if code: 144 | session.state["code"] = code 145 | -------------------------------------------------------------------------------- /app/bot/course_schedule/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List 3 | 4 | import arrow 5 | import regex as re 6 | from chinese_time_nlp import StringPreHandler, TimeNormalizer 7 | from loguru import logger 8 | from nonebot import ( 9 | CommandSession, 10 | IntentCommand, 11 | NLPSession, 12 | on_command, 13 | on_natural_language, 14 | ) 15 | 16 | from app.models.course import CourseStudent 17 | 18 | from .parse import get_week, parse_course_by_date, str_int_wday_dict, week_course 19 | 20 | __plugin_name__ = "查询/更新 课表" 21 | __plugin_short_description__ = "命令:cs/uc" 22 | __plugin_usage__ = r""" 23 | 帮助链接:https://bot.artin.li/guide/course_schedule.html 24 | 25 | 查询课表输入: 26 | - cs 27 | - 查询课表 28 | - 或者加上时间限定: 29 | - 今天课表 30 | - 明天有什么课 31 | - 九月十五号有什么课 32 | 33 | 更新课表可以输入: 34 | - uc 35 | - 更新课表 36 | """.strip() 37 | 38 | tn = TimeNormalizer() 39 | 40 | 41 | @on_command("cs", aliases=("查询课表", "课表", "课程表", "课程")) 42 | async def course_schedule(session: CommandSession): 43 | sender_qq = session.event["user_id"] 44 | 45 | # 从更新课表中传过来的值 46 | if session.state.get("course_schedule"): 47 | resp = session.state.get("course_schedule") 48 | else: 49 | resp = await CourseStudent.get_course(sender_qq) 50 | 51 | if not resp: 52 | await session.send("查询出错") 53 | return 54 | 55 | if resp == "WAIT": 56 | return 57 | elif resp == "NOT_BIND": 58 | return 59 | 60 | json_: dict = json.loads(resp.course_json) # type: ignore 61 | logger.debug(f"查询课表结果:{str(json_)}") 62 | body = json_["body"] 63 | week = session.state.get("week") 64 | wday = session.state.get("wday") 65 | is_today = session.state.get("today") 66 | if is_today: 67 | logger.info("发送当天课表") 68 | now = arrow.now("Asia/Shanghai") 69 | week = get_week(now.timestamp) 70 | wday = str(now.isoweekday()) 71 | course = parse_course_by_date(body, week, wday) 72 | await session.send(course) 73 | elif week and wday: 74 | logger.info(f"检测到时间意图:{str(session.state)}") 75 | course = parse_course_by_date(body, week, wday) 76 | await session.send(course) 77 | elif week: 78 | logger.info(f"检测到时间意图:{str(session.state)}") 79 | course_dict: List[str] = week_course(body, int(week)) 80 | for i in course_dict: 81 | await session.send(i) 82 | else: 83 | # 所有课表 84 | course_dict: List[str] = week_course(body) 85 | for i in course_dict: 86 | await session.send(i) 87 | 88 | if body["errMsg"]: 89 | session.finish(f"错误信息:{body['errMsg']}") 90 | 91 | if body["updateTime"]: 92 | session.finish(f"课表抓取时间:{body['updateTime']}") 93 | 94 | 95 | @on_natural_language("课") 96 | async def process_accu_date(session: NLPSession): 97 | msg = session.event["raw_message"] 98 | now = arrow.now("Asia/Shanghai") 99 | 100 | week_re = re.search(r"下周", msg) 101 | if week_re: 102 | logger.info("获取下周课表") 103 | week = get_week(now.timestamp) 104 | args = {"week": week + 1} 105 | await session.send(f"下周课表(第{week + 1}周):") 106 | return IntentCommand(100, "cs", args=args) 107 | 108 | res = tn.parse(target=msg, timeBase=now) 109 | logger.debug(f"课程时间意图分析结果: {str(msg)} -> {str(res)}") 110 | tn_type: str = res.get("type") 111 | if tn_type == "timestamp": 112 | date = arrow.get(res.get(tn_type), "YYYY-MM-DD HH:mm:ss") 113 | wday = str(date.isoweekday()) 114 | week = get_week(date.timestamp) 115 | await session.send( 116 | f"{res.get(tn_type)[:10]},第{week}周,星期{str_int_wday_dict.get(wday, wday)}" 117 | ) 118 | logger.info(f"第{str(week)}周,星期{str_int_wday_dict.get(wday,wday)}") 119 | args = {"wday": wday, "week": week} 120 | return IntentCommand(100, "cs", args=args) 121 | 122 | # 周数匹配 123 | text = StringPreHandler.numberTranslator(msg) 124 | week_re = re.search(r"第(\d+)周", text) 125 | if week_re and week_re.group(1): 126 | await session.send(f"{week_re.group(0)}课表:") 127 | logger.info(f"周数分析结果:{week_re.group(1)}") 128 | args = {"week": week_re.group(1)} 129 | return IntentCommand(100, "cs", args=args) 130 | 131 | return IntentCommand(100, "cs") 132 | 133 | 134 | @on_command("uc", aliases=("更新课表",)) 135 | async def uc(session: CommandSession): 136 | sender_qq = session.event["user_id"] 137 | 138 | await session.send("正在更新课表...") 139 | try: 140 | await CourseStudent.update_course(sender_qq) 141 | except Exception: 142 | session.finish("更新出错") 143 | -------------------------------------------------------------------------------- /app/bot/course_schedule/parse.py: -------------------------------------------------------------------------------- 1 | import math 2 | import time 3 | from collections import defaultdict 4 | from typing import Optional 5 | 6 | from app.constants.dean import INFO 7 | 8 | import arrow 9 | 10 | chinese_wday_dict = { 11 | "一": "1", 12 | "二": "2", 13 | "三": "3", 14 | "四": "4", 15 | "五": "5", 16 | "六": "6", 17 | "日": "7", 18 | } 19 | str_int_wday_dict = {v: k for k, v in chinese_wday_dict.items()} 20 | 21 | 22 | def get_week(target_time) -> int: 23 | """ 24 | target_time: 时间戳 25 | :return: 返回当前时间戳的周数 26 | """ 27 | # 将格式字符串转换为时间戳 28 | start_time = int(time.mktime(INFO.term_start_day)) 29 | # 加1,因为刚好七天的时候 used_weeks 的值会是 1.0, 会认为还是第一周 30 | used_weeks = (target_time - start_time + 10) / (24 * 60 * 60 * 7) 31 | return math.ceil(used_weeks) 32 | 33 | 34 | def tip(strs: str) -> str: 35 | after = strs.split("-") 36 | start = int(after[0]) 37 | last = int(after[1]) 38 | if start == 1 and last == 2: 39 | return "上午第一讲" 40 | if start == 2 and last == 2: 41 | return "上午第二讲" 42 | if start == 3 and last == 2: 43 | return "下午第一讲" 44 | if start == 4 and last == 2: 45 | return "下午第二讲" 46 | if start == 5 and last == 2: 47 | return "晚上第一讲" 48 | if start == 6 and last == 2: 49 | return "晚上第二讲" 50 | 51 | if start == 1 and last == 4: 52 | return "上午一到二讲" 53 | if start == 3 and last == 4: 54 | return "下午一到二讲" 55 | if start == 5 and last == 4: 56 | return "晚上一到二讲" 57 | 58 | return f"第{start}讲,持续{last}节" 59 | 60 | 61 | def week_course(course_table, weekday: Optional[int] = None): 62 | result = course_table["result"] 63 | now = arrow.now("Asia/Shanghai") 64 | 65 | weekday = weekday or get_week(now.timestamp) 66 | # 课程字典 key: 星期几 value: 那一天的课 67 | wday_course_dict = defaultdict(list) 68 | 69 | for x in result: 70 | # 当周 71 | if weekday >= int(x["qsz"]) and weekday <= int(x["zzz"]): 72 | for _time, _location in zip(x["class_time"], x["location"]): 73 | 74 | _course = { 75 | "class_name": x["class_name"], 76 | "class_time": _time[2:], 77 | "location": _location, 78 | "teacher_name": x["teacher_name"], 79 | } 80 | # class_time [1@2-2, 3@3-2] 81 | wday_course_dict[str(_time[0])].append(_course) 82 | 83 | sorted_wday_course_dict = sorted(wday_course_dict.items(), key=lambda e: int(e[0])) 84 | 85 | r_course_list = [] 86 | for wday, course_list in sorted_wday_course_dict: 87 | r_course_list.append(parse_course_by_wday(course_list, wday)) 88 | 89 | return r_course_list 90 | 91 | 92 | def parse_course_by_wday(course_list, day: str): 93 | day = str(day) 94 | if len(course_list) == 0: 95 | return f"星期{str_int_wday_dict.get(day, day)}没有课" 96 | msg = f"星期{str_int_wday_dict.get(day, day)}的课程如下:\n" 97 | 98 | course_list.sort(key=lambda e: e["class_time"][0]) 99 | for course in course_list: 100 | t = "{}\n- {}({})\n- {}\n\n".format( 101 | tip(course["class_time"]), 102 | course["class_name"], 103 | course["teacher_name"], 104 | course["location"], 105 | ) 106 | msg = msg + t 107 | return msg.strip() 108 | 109 | 110 | def parse_course_by_date(course_table, weekday: int, day: str): 111 | result = course_table["result"] 112 | # 课程字典 key: 星期几 value: 那一天的课 113 | wday_course_dict = defaultdict(list) 114 | 115 | for x in result: 116 | # 当周 117 | if weekday >= int(x["qsz"]) and weekday <= int(x["zzz"]): 118 | for _time, _location in zip(x["class_time"], x["location"]): 119 | 120 | _course = { 121 | "class_name": x["class_name"], 122 | "class_time": _time[2:], 123 | "location": _location, 124 | "teacher_name": x["teacher_name"], 125 | } 126 | # class_time [1@2-2, 3@3-2] 127 | wday_course_dict[str(_time[0])].append(_course) 128 | 129 | return parse_course_by_wday(wday_course_dict[day], day) 130 | -------------------------------------------------------------------------------- /app/bot/credit/__init__.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | from nonebot import CommandSession, on_command 3 | 4 | from .service import CreditService 5 | 6 | __plugin_name__ = "绩点" 7 | __plugin_short_description__ = "命令:credit" 8 | __plugin_usage__ = r""" 9 | 帮助链接:https://bot.artin.li/guide/credit.html 10 | 11 | 查看我的绩点: 12 | 命令: 13 | - credit 14 | - 绩点 15 | - 我的绩点 16 | """.strip() 17 | 18 | 19 | @on_command("credit", aliases=("绩点", "我的绩点")) 20 | async def _(session: CommandSession): 21 | sender_qq = session.event["user_id"] 22 | try: 23 | await CreditService.get_progress(sender_qq) 24 | except Exception as e: 25 | logger.exception(e) 26 | await session.send("查询出错") 27 | -------------------------------------------------------------------------------- /app/bot/credit/service.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from loguru import logger 3 | 4 | from nonebot import get_bot 5 | 6 | from app.libs.aio import run_sync_func 7 | from app.libs.cache import cache 8 | from app.libs.scheduler import add_job 9 | from app.models.user import User 10 | from app.utils.bot import qq2event 11 | from app.services.credit.parse import get_credit_progress, CreditProgressDict 12 | 13 | _bot = get_bot() 14 | 15 | 16 | class CreditService: 17 | @classmethod 18 | async def get_progress(cls, qq: int) -> Optional[str]: 19 | # 先查 user 出来,再查 Course 表 20 | user = await User.check(qq) 21 | if not user: 22 | return "NOT_BIND" 23 | await add_job(cls._get_progress, args=[user]) 24 | await _bot.send(qq2event(qq), "正在抓取绩点,抓取过后我会直接发给你!") 25 | return "WAIT" 26 | 27 | @classmethod 28 | async def _get_progress(cls, user: User): 29 | try: 30 | key = f"credit/{user.qq}" 31 | res = await cache.get(key) 32 | if not res: 33 | sess = await User.get_session(user) 34 | res: CreditProgressDict = await run_sync_func(get_credit_progress, sess) 35 | if res: 36 | await cache.set(key, res, ttl=600) 37 | else: 38 | raise ValueError("查询绩点出错") 39 | await _bot.send(qq2event(user.qq), _format(res)) 40 | except Exception as e: 41 | logger.exception(e) 42 | await _bot.send(qq2event(user.qq), "查询绩点出错,请稍后再试") 43 | 44 | 45 | def _format(credits: CreditProgressDict): 46 | msg = "" 47 | for name, credit in credits.items(): 48 | msg = msg + f"{name}: {credit}\n" 49 | return msg.strip() 50 | -------------------------------------------------------------------------------- /app/bot/deposit_ics.py: -------------------------------------------------------------------------------- 1 | from nonebot import CommandSession, on_command 2 | from loguru import logger 3 | 4 | __plugin_name__ = "托管日历" 5 | __plugin_short_description__ = "将课表生成日历,命令:dpics" 6 | __plugin_usage__ = r"""输入 托管日历 7 | 然后我会给你一个日历的在线地址,日历每天更新 8 | """.strip() 9 | 10 | 11 | @on_command("deposit_ics", aliases=("托管日历", "dpics")) 12 | async def uc(session: CommandSession): 13 | sender_qq = session.event["user_id"] 14 | logger.info(f"{sender_qq} 请求托管日历。") 15 | await session.send("托管日历成功!") 16 | await session.send("日历地址我稍后会发送给你。") 17 | 18 | session.finish("托管日历出错") 19 | -------------------------------------------------------------------------------- /app/bot/ecard/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import IntentCommand, NLPSession, on_natural_language 2 | from nonebot import CommandSession, on_command 3 | 4 | from .service import ECardService 5 | 6 | __plugin_name__ = "饭卡余额" 7 | __plugin_short_description__ = "命令:balance" 8 | __plugin_usage__ = r""" 9 | 帮助链接:https://bot.artin.li/guide/ecard.html 10 | 11 | 命令: 12 | - 余额 13 | - 一卡通余额 14 | """.strip() 15 | 16 | 17 | @on_command("balance", aliases=("余额", "一卡通余额")) 18 | async def _(session: CommandSession): 19 | await session.send("学校相关接口有误") 20 | sender_qq = session.event["user_id"] 21 | try: 22 | await ECardService.get_balance(sender_qq) 23 | except Exception: 24 | await session.send("查询出错") 25 | 26 | 27 | @on_command("饭卡消费", aliases=("消费", "消费记录")) 28 | async def _(session: CommandSession): 29 | await session.send("学校相关接口有误") 30 | 31 | 32 | @on_natural_language(["饭卡", "一卡通", "ecard"]) 33 | async def _(session: NLPSession): 34 | msg: str = session.event["raw_message"] 35 | 36 | if "消费" in msg: 37 | return IntentCommand(90.0, "饭卡消费") 38 | return IntentCommand(90.0, "饭卡余额") 39 | -------------------------------------------------------------------------------- /app/bot/ecard/service.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from nonebot import get_bot 4 | 5 | from app.libs.aio import run_sync_func 6 | from app.libs.scheduler import add_job 7 | from app.models.user import User 8 | from app.utils.bot import qq2event 9 | from app.services.ecard.parse import get_ecard_balance 10 | 11 | _bot = get_bot() 12 | 13 | 14 | class ECardService: 15 | @classmethod 16 | async def get_balance(cls, qq: int) -> Optional[str]: 17 | # 先查 user 出来,再查 Course 表 18 | user = await User.check(qq) 19 | if not user: 20 | return "NOT_BIND" 21 | await add_job(cls._get_balance, args=[user]) 22 | await _bot.send(qq2event(user.qq), "正在抓取余额,抓取过后我会直接发给你!") 23 | return "WAIT" 24 | 25 | @classmethod 26 | async def _get_balance(cls, user: User): 27 | sess = await User.get_session(user) 28 | res = await run_sync_func(get_ecard_balance, sess, user.student_id) 29 | if res: 30 | await _bot.send(qq2event(user.qq), str(res)) 31 | return 32 | await _bot.send(qq2event(user.qq), "查询余额出错,请稍后再试") 33 | -------------------------------------------------------------------------------- /app/bot/hitokoto.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | from typing import Optional 3 | 4 | import httpx 5 | from nonebot import CommandSession, on_command 6 | 7 | __plugin_name__ = "一言" 8 | __plugin_short_description__ = "命令:hi" 9 | __plugin_usage__ = r""" 10 | 帮助链接:https://bot.artin.li/guide/hitokoto.html 11 | 12 | 给你回复一句话 13 | """.strip() 14 | 15 | defaults = [ 16 | "这里有嬉笑怒骂,柴米油盐,人间戏梦,滚滚红尘。", 17 | "这温热的跳动,就是活着。", 18 | "那就祝你早安,午安,晚安吧。", 19 | ] 20 | 21 | 22 | @on_command("hi", aliases=("一言", "hitokoto"), only_to_me=False) 23 | async def _(session: CommandSession): 24 | _hitokoto = await hitokoto() 25 | if _hitokoto: 26 | await session.finish(_hitokoto["hitokoto"].strip()) 27 | await session.finish(choice(defaults)) 28 | 29 | 30 | async def hitokoto() -> Optional[dict]: 31 | # https://hitokoto.cn/api 32 | hitokoto_url = "https://v1.hitokoto.cn/" 33 | async with httpx.AsyncClient() as client: 34 | r: httpx.Response = await client.get(hitokoto_url) 35 | res = r.json() 36 | return res 37 | -------------------------------------------------------------------------------- /app/bot/man.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot import on_command, CommandSession 3 | 4 | from rapidfuzz import fuzz 5 | 6 | 7 | def get_description(p): 8 | try: 9 | desc = f" ({p.module.__plugin_short_description__})" 10 | except Exception: 11 | desc = "" 12 | return p.name + desc 13 | 14 | 15 | @on_command("man", aliases=["使用帮助", "帮助", "使用方法", "help"], only_to_me=False) 16 | async def _(session: CommandSession): 17 | # 获取设置了名称的插件列表 18 | plugins = list(filter(lambda p: p.name, nonebot.get_loaded_plugins())) 19 | 20 | arg = session.current_arg_text.strip().lower() 21 | if not arg: 22 | # 如果用户没有发送参数,则发送功能列表 23 | await session.send( 24 | "我现在支持的功能有:\n" + "\n".join(get_description(p) for p in plugins) 25 | ) 26 | await session.send("具体各功能帮助请查看:https://bot.artin.li/guide/") 27 | session.finish( 28 | '输入 "帮助+空格+功能名" 查看各功能使用指南以及命令。\n' + '如:"帮助 绑定教务处",不需要加上括号及括号内内容。' 29 | ) 30 | 31 | found = False 32 | 33 | # 如果发了参数则发送相应命令的使用帮助 34 | for p in plugins: 35 | if fuzz.partial_ratio(p.name.lower(), arg) > 0.6: 36 | found = True 37 | session.finish(p.usage) 38 | 39 | if not found: 40 | session.finish(f"暂时没有 {arg} 这个功能呢") 41 | -------------------------------------------------------------------------------- /app/bot/nlp/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Set 2 | 3 | from loguru import logger 4 | from nonebot import IntentCommand, NLPSession, on_natural_language 5 | from nonebot.command import Command, CommandManager 6 | from nonebot.permission import check_permission 7 | from nonebot.typing import CommandName_T 8 | from rapidfuzz import fuzz, process 9 | 10 | 11 | def gen_commands_keys(commands: Dict[CommandName_T, Command]): 12 | result_set: Set[str] = set() 13 | for item in commands.keys(): 14 | if len(item) == 1: 15 | result_set.add(item[0]) 16 | return result_set 17 | 18 | 19 | @on_natural_language() 20 | async def _(session: NLPSession): 21 | raw_message: List[str] = session.event["raw_message"].split() 22 | # 假设该消息为命令,取第一个字段 23 | query_cmd = raw_message[0] 24 | 25 | fuzz_cmd = None 26 | confidence = None 27 | # 检查 commands 28 | commands_dct = CommandManager._commands 29 | choices = gen_commands_keys(commands_dct) 30 | # 模糊匹配命令与 commands 31 | result = process.extractOne(query_cmd, choices, scorer=fuzz.WRatio) 32 | if result: 33 | cmd_name, confidence = result 34 | _cmd = (cmd_name,) 35 | if commands_dct.get(_cmd) is not None: 36 | if check_permission( 37 | session.bot, session.event, commands_dct[_cmd].permission 38 | ): 39 | fuzz_cmd = cmd_name 40 | 41 | # 检查 commands 没有匹配到命令 42 | if fuzz_cmd is None: 43 | # 检查 aliases 44 | aliases_dct = CommandManager._aliases # type: Dict[str, Command] 45 | choices = set(aliases_dct.keys()) 46 | # 模糊匹配命令与 aliases 47 | result = process.extractOne(query_cmd, choices, scorer=fuzz.WRatio) 48 | if result: 49 | alias, confidence = result 50 | if check_permission( 51 | session.bot, session.event, aliases_dct[alias].permission 52 | ): 53 | fuzz_cmd = alias 54 | 55 | if fuzz_cmd is not None and confidence is not None: 56 | logger.debug(f"query_cmd: {query_cmd}") 57 | logger.debug(f"fuzz cmd, confidence: {fuzz_cmd} {confidence}") 58 | if confidence - 66 > 0: 59 | raw_message[0] = fuzz_cmd 60 | return IntentCommand( 61 | confidence, "switch", current_arg=" ".join(raw_message), 62 | ) 63 | -------------------------------------------------------------------------------- /app/bot/notice_handler.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | from nonebot import on_notice, NoticeSession 4 | 5 | 6 | @on_notice 7 | async def _(session: NoticeSession): 8 | logger.info(f"有新的通知事件:{session.event}") 9 | 10 | 11 | @on_notice("group_increase") 12 | async def _(session: NoticeSession): 13 | await session.send("欢迎新朋友~") 14 | -------------------------------------------------------------------------------- /app/bot/preprocessor.py: -------------------------------------------------------------------------------- 1 | import regex as re 2 | from aiocqhttp import Event 3 | from loguru import logger 4 | from nonebot import Message, NoneBot, message_preprocessor 5 | from nonebot.helpers import send 6 | 7 | from app.libs.qqai_async.aaiasr import check_qqai_key, echo 8 | 9 | record_re = re.compile(r"^\[CQ:record,file=([A-Z0-9]{32}\.silk)\]$") 10 | 11 | 12 | @message_preprocessor 13 | async def audio_preprocessor(bot: NoneBot, event: Event, *args): 14 | raw_message: str = event["raw_message"] 15 | logger.info(event) 16 | if raw_message.startswith("[CQ:record,"): 17 | if not check_qqai_key(): 18 | return 19 | 20 | logger.info(f"raw_message: {raw_message}") 21 | await send(bot, event, "正在识别语音...") 22 | 23 | # [CQ:record,file=8970935D1A480B008970935D1A480B00.silk] 24 | match = record_re.search(raw_message) 25 | if not match: 26 | return 27 | 28 | result, rec_text = await echo(match.group(1)) 29 | logger.info(f"result: {result}, rec_text: {rec_text}") 30 | 31 | if result: 32 | event["message"] = Message(rec_text) 33 | event["raw_message"] = rec_text 34 | await send(bot, event, f"语音识别结果:{rec_text}") 35 | else: 36 | event["message"] = Message("") 37 | event["raw_message"] = "" 38 | await send(bot, event, f"语音识别失败,原因:{rec_text}") 39 | 40 | event["preprocessed"] = True 41 | -------------------------------------------------------------------------------- /app/bot/remind/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 修改自:https://github.com/nonebot/nonebot-alarm 3 | 原作者: yanyongyu 4 | 原作者GitHub: https://github.com/yanyongyu 5 | """ 6 | 7 | from nonebot import get_bot 8 | 9 | __plugin_name__ = "⏰ 提醒" 10 | __plugin_short_description__ = "[原 push]命令:remind" 11 | __plugin_usage__ = r""" 12 | 帮助链接:https://bot.artin.li/guide/remind.html 13 | 14 | 新建提醒输入: 15 | - remind 16 | - 提醒 17 | - 添加提醒 18 | - 新增提醒 19 | - 新建提醒 20 | 21 | 查看提醒可以输入: 22 | - remind.show 23 | - 查看提醒 24 | - 我的提醒 25 | - 提醒列表 26 | 27 | 删除提醒: 28 | - remind.rm 29 | - 取消提醒 30 | - 停止提醒 31 | - 关闭提醒 32 | - 删除提醒 33 | """.strip() 34 | 35 | bot = get_bot() 36 | nickname = getattr(bot.config, "NICKNAME", "我") 37 | 38 | EXPR_COULD_NOT = (f"哎鸭,{nickname}没有时光机,这个时间没办法提醒你。", f"你这是要穿越吗?这个时间{nickname}没办法提醒你。") 39 | 40 | EXPR_OK = ( 41 | "遵命!我会在{time}叫你{action}!\n", 42 | "好!我会在{time}提醒你{action}!\n", 43 | "没问题!我一定会在{time}通知你{action}。\n", 44 | "好鸭~ 我会准时在{time}提醒你{action}。\n", 45 | "嗯嗯!我会在{time}准时叫你{action}哒\n", 46 | "好哦!我会在{time}准时叫你{action}~\n", 47 | ) 48 | 49 | EXPR_REMIND = ( 50 | "提醒通知:\n提醒时间到啦!该{action}了!", 51 | "提醒通知:\n你设置的提醒时间已经到了~ 赶快{action}!", 52 | "提醒通知:\n你应该没有忘记{action}吧?", 53 | "提醒通知:\n你定下的提醒时间已经到啦!快{action}吧!", 54 | ) 55 | 56 | from . import commands, nlp # noqa: F401 57 | -------------------------------------------------------------------------------- /app/bot/remind/nlp.py: -------------------------------------------------------------------------------- 1 | import regex as re 2 | from datetime import datetime, timedelta 3 | 4 | from chinese_time_nlp import TimeNormalizer 5 | from nonebot import on_natural_language, NLPSession, IntentCommand 6 | 7 | 8 | @on_natural_language(keywords={"提醒", "通知", "叫", "告诉"}) 9 | async def _(session: NLPSession): 10 | stripped_arg = session.msg_text.strip() 11 | 12 | # 将消息分为两部分(时间|事件) 13 | time, target = re.split(r"(?:提醒)|(?:通知)|(?:叫)|(?:告诉)", stripped_arg, maxsplit=1) 14 | 15 | # 解析时间 16 | tn = TimeNormalizer() 17 | time_json = tn.parse(time) 18 | 19 | if time_json["type"] == "error": 20 | return 21 | # 时间差转换为时间点 22 | elif time_json["type"] == "timedelta": 23 | time_diff = time_json["timedelta"] 24 | time_diff = timedelta( 25 | days=time_diff["day"], 26 | hours=time_diff["hour"], 27 | minutes=time_diff["minute"], 28 | seconds=time_diff["second"], 29 | ) 30 | time_target = datetime.now() + time_diff 31 | elif time_json["type"] == "timestamp": 32 | time_target = datetime.strptime(time_json["timestamp"], "%Y-%m-%d %H:%M:%S") 33 | # 默认时间点为中午12点 34 | if ( 35 | not re.search(r"[\d+一二两三四五六七八九十]+点", time) 36 | and time_target.hour == 0 37 | and time_target.minute == 0 38 | and time_target.second == 0 39 | ): 40 | time_target.replace(hour=12) 41 | 42 | return IntentCommand( 43 | 90.0, 44 | "_alarm", 45 | args={ 46 | "time": time_target, # type: ignore 47 | "target": target.lstrip("我"), 48 | }, 49 | ) 50 | -------------------------------------------------------------------------------- /app/bot/request_handler.py: -------------------------------------------------------------------------------- 1 | from nonebot import on_request, RequestSession 2 | 3 | 4 | # 好友请求处理器 5 | @on_request("friend") 6 | async def _(session: RequestSession): 7 | await session.approve() 8 | await session.send("你可以向我发送 <帮助> 查看我的使用指南。") 9 | await session.send("在输入框内输入 帮助 二字,点击发送按钮,然后按我的回复进行下一步的操作。") 10 | 11 | 12 | @on_request("group.invite") 13 | async def group(session: RequestSession): 14 | await session.approve() 15 | await session.send("你可以向我发送 <帮助> 查看我的使用指南。") 16 | await session.send("在输入框内输入 帮助 二字,点击发送按钮,然后按我的回复进行下一步的操作。") 17 | -------------------------------------------------------------------------------- /app/bot/rss/__init__.py: -------------------------------------------------------------------------------- 1 | from apscheduler.triggers.interval import IntervalTrigger 2 | from httpx import ConnectTimeout 3 | from loguru import logger 4 | from nonebot import CommandGroup, CommandSession 5 | from nonebot.command.argfilter import controllers, validators 6 | 7 | from app.config import Config 8 | from app.libs.scheduler import scheduler 9 | from app.models.subscribe import SubContent, SubUser 10 | 11 | rss_cg = CommandGroup("rss", only_to_me=False) 12 | 13 | 14 | @rss_cg.command("add", only_to_me=False) 15 | async def add(session: CommandSession): 16 | url = session.get( 17 | "url", 18 | prompt="请输入你要订阅的地址:", 19 | arg_filters=[ 20 | controllers.handle_cancellation(session), 21 | str.lstrip, 22 | validators.not_empty("请输入有效内容哦~"), 23 | ], 24 | ) 25 | if not session.state.get("silent"): 26 | await session.send(f"正在处理订阅链接:{url}") 27 | 28 | try: 29 | sub = await SubUser.get_sub(session.event, url) 30 | if sub: 31 | await session.send(f"{sub.sub_content.name} 已订阅~") 32 | return 33 | title = await SubUser.add_sub(session.event, url) 34 | if title: 35 | await session.send(f"{title} 订阅成功~") 36 | return 37 | except ConnectTimeout as e: 38 | logger.exception(e) 39 | await session.send("获取订阅源超时,请稍后重试。") 40 | except Exception as e: 41 | logger.exception(e) 42 | await session.send("订阅失败,请稍后重试。") 43 | 44 | 45 | @add.args_parser 46 | async def _(session: CommandSession): 47 | stripped_arg = session.current_arg_text.rstrip() 48 | if session.is_first_run: 49 | if stripped_arg: 50 | session.state["url"] = stripped_arg 51 | return 52 | 53 | if not stripped_arg: 54 | session.pause("链接不能为空呢,请重新输入") 55 | 56 | session.state[session.current_key] = stripped_arg 57 | 58 | 59 | @scheduler.scheduled_job( 60 | IntervalTrigger(seconds=Config.SUBSCIBE_INTERVAL, jitter=60), id="rss_update", 61 | ) 62 | async def rss_update(): 63 | await SubContent.check_update() 64 | -------------------------------------------------------------------------------- /app/bot/save_chat_records.py: -------------------------------------------------------------------------------- 1 | from aiocqhttp import Event 2 | from nonebot import get_bot 3 | 4 | from app.models.chat_records import ChatRecords 5 | from app.utils.bot import switch_session 6 | 7 | PLUGIN_NAME = "save_chat_records" 8 | __plugin_name__ = "再次执行上一句消息" 9 | __plugin_short_description__ = "命令:re" 10 | __plugin_usage__ = r""" 11 | 发送 re 让我重新执行你上一次发的消息 12 | """.strip() 13 | 14 | bot = get_bot() 15 | 16 | 17 | @bot.on_message() 18 | async def _(event: Event): 19 | if event.raw_message == "re": 20 | chat_record = await ChatRecords.get_last_msg(event) 21 | await switch_session(event, chat_record.msg) 22 | else: 23 | await ChatRecords.add_msg(event) 24 | -------------------------------------------------------------------------------- /app/bot/score.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | from nonebot import CommandSession, on_command 3 | 4 | from app.services.score import ScoreService 5 | 6 | __plugin_name__ = "查询成绩" 7 | __plugin_short_description__ = "命令:score" 8 | __plugin_usage__ = r""" 9 | 帮助链接:https://bot.artin.li/guide/score.html 10 | 11 | 输入 查询成绩/成绩 12 | 使用方法: 13 | 成绩""" 14 | 15 | 16 | @on_command("score", aliases=("查询成绩", "成绩")) 17 | async def score(session: CommandSession): 18 | sender_qq = session.event["user_id"] 19 | try: 20 | await ScoreService.send_score(sender_qq) 21 | except Exception as e: 22 | logger.exception(e) 23 | await session.send("查询出错") 24 | 25 | 26 | @on_command("cet_score", aliases=("四六级成绩", "四六级成绩", "四级", "六级")) 27 | async def cet_score(session: CommandSession): 28 | sender_qq = session.event["user_id"] 29 | try: 30 | await ScoreService.send_cet_score(sender_qq) 31 | except Exception as e: 32 | logger.exception(e) 33 | await session.send("查询出错") 34 | 35 | 36 | @score.args_parser 37 | async def _(session: CommandSession): 38 | pass 39 | -------------------------------------------------------------------------------- /app/bot/subscribe/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | from nonebot import CommandGroup, CommandSession 5 | from nonebot import permission as perm 6 | from nonebot.command import call_command 7 | from nonebot.command.argfilter import controllers, extractors, validators 8 | 9 | from app.services.subscribe.wrapper import SubWrapper, judge_sub 10 | from app.utils.bot import get_user_center 11 | 12 | __plugin_name__ = "订阅" 13 | __plugin_short_description__ = "订阅 通知/成绩/考试 等,命令: subscribe" 14 | __plugin_usage__ = r""" 15 | 帮助链接:https://bot.artin.li/guide/subscribe.html 16 | 17 | 添加订阅: 18 | - 订阅 19 | - 添加订阅 20 | - 新建订阅 21 | - subscribe 22 | 然后会提示输入序号,你也可以直接在后面加上序号,如: 23 | - 订阅 1 24 | 查看订阅: 25 | - 查看订阅 26 | - 订阅列表 27 | - subscribe show 28 | 29 | 移除订阅: 30 | - 移除订阅 31 | - 取消订阅 32 | - 停止订阅 33 | - 删除订阅 34 | - subscribe rm 35 | 然后会提示输入序号,你也可以直接在后面加上序号,如: 36 | - 移除订阅 1 37 | - 移除订阅 all 38 | """.strip() 39 | 40 | cg = CommandGroup( 41 | "subscribe", permission=perm.PRIVATE | perm.GROUP_ADMIN | perm.DISCUSS 42 | ) 43 | 44 | 45 | def get_subscribe_str() -> str: 46 | msg = "" 47 | dct = SubWrapper.get_subs() 48 | for k, v in dct.items(): 49 | msg = msg + f"{k}. {v}\n" 50 | 51 | return msg 52 | 53 | 54 | @cg.command( 55 | "subscribe", aliases=["subscribe", "订阅", "添加订阅", "新增订阅", "新建订阅"], only_to_me=False 56 | ) 57 | async def subscribe(session: CommandSession): 58 | message = session.get( 59 | "message", 60 | prompt=f"你想订阅什么内容呢?(请输入序号,也可输入 `取消、不` 等语句取消):\n{get_subscribe_str()}", 61 | arg_filters=[ 62 | controllers.handle_cancellation(session), 63 | str.strip, 64 | validators.not_empty("请输入有效内容哦~"), 65 | ], 66 | ) 67 | SubC = judge_sub(message) 68 | if not SubC: 69 | session.finish("输入序号有误!") 70 | 71 | _, msg = await SubC.add_sub(session.event, message) 72 | session.finish(msg) 73 | 74 | 75 | @subscribe.args_parser 76 | async def _(session: CommandSession): 77 | if session.is_first_run: 78 | if session.current_arg: 79 | session.state["message"] = session.current_arg 80 | return 81 | 82 | 83 | @cg.command("show", aliases=["查看订阅", "我的订阅", "订阅列表"], only_to_me=False) 84 | async def _(session: CommandSession): 85 | subs = session.state.get("subs") or await SubWrapper.get_user_sub(session.event) 86 | 87 | if subs: 88 | for k, v in subs.items(): 89 | await session.send(format_subscription(k, v)) 90 | await asyncio.sleep(0.05) 91 | await session.send(f"以上是所有的 {len(subs)} 个订阅") 92 | else: 93 | await session.send("你还没有订阅任何内容哦") 94 | 95 | session.finish(f"订阅管理:{get_user_center(session.event)}") 96 | 97 | 98 | @cg.command("rm", aliases=["取消订阅", "停止订阅", "关闭订阅", "删除订阅", "移除订阅"], only_to_me=False) 99 | async def unsubscribe(session: CommandSession): 100 | subs = await SubWrapper.get_user_sub(session.event) 101 | key: Optional[str] = session.state.get("key") 102 | if key is None: 103 | session.state["subs"] = subs 104 | await call_command( 105 | session.bot, 106 | session.ctx, 107 | ("subscribe", "show"), 108 | args={"subs": subs}, 109 | disable_interaction=True, 110 | ) 111 | 112 | if not subs: 113 | session.finish() 114 | 115 | key = session.get( 116 | "key", 117 | prompt="你想取消哪一个订阅呢?(请发送序号,或者 `取消`)", 118 | arg_filters=[ 119 | extractors.extract_text, 120 | controllers.handle_cancellation(session), 121 | ], 122 | ) 123 | 124 | if key: 125 | SubC = judge_sub(key) 126 | if not SubC: 127 | session.finish("输入序号有误!") 128 | 129 | _, msg = await SubC.del_sub(session.event, key) 130 | session.finish(msg) 131 | 132 | 133 | @unsubscribe.args_parser 134 | async def _(session: CommandSession): 135 | if session.is_first_run: 136 | if session.current_arg: 137 | session.state["key"] = session.current_arg 138 | 139 | 140 | def format_subscription(k, v) -> str: 141 | return f"序号:{k}\n" f"订阅名称:" f"{v}\n" 142 | -------------------------------------------------------------------------------- /app/bot/switch.py: -------------------------------------------------------------------------------- 1 | """给其他想执行命令的插件使用 2 | 如 执行: switch xxxx 3 | """ 4 | import asyncio 5 | 6 | from nonebot import on_command, CommandSession 7 | from nonebot.argparse import ArgumentParser 8 | from nonebot.command import kill_current_session 9 | from nonebot.message import Message, handle_message 10 | 11 | 12 | @on_command("switch", privileged=True, shell_like=True) 13 | async def switch(session: CommandSession): 14 | parser = ArgumentParser(session=session, usage=USAGE) 15 | parser.add_argument("-r", "--repeat-message", action="store_true", default=False) 16 | parser.add_argument("message") 17 | args = parser.parse_args(session.argv) 18 | 19 | kill_current_session(session.event) 20 | msg = Message(args.message) 21 | if args.repeat_message: 22 | await session.send(msg) 23 | event = session.event 24 | event["message"] = msg 25 | event["to_me"] = True # ensure to_me 26 | asyncio.ensure_future(handle_message(session.bot, event)) 27 | 28 | 29 | USAGE = r""" 30 | 切换到新的消息上下文(让机器人假装收到一段消息,然后进行正常的消息处理逻辑) 31 | 使用方法: 32 | switch [OPTIONS] MESSAGE 33 | OPTIONS: 34 | -h, --help 显示本使用帮助 35 | -r, --repeat-message 重复发送 MESSAGE 参数内容 36 | MESSAGE: 37 | 新的消息内容 38 | """.strip() 39 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from sqlalchemy.engine.url import URL 3 | 4 | from .env import env 5 | 6 | __all__ = ["get_database_url", "Config"] 7 | 8 | 9 | def get_database_url() -> URL: 10 | port = 5432 11 | if env("RUN_MODE", "") != "bot": 12 | port = env("DATABASE_PORT", 5432) 13 | return URL( 14 | host=env("DATABASE_HOST", "database"), 15 | port=port, 16 | drivername="postgresql", 17 | username=env("POSTGRES_USER"), 18 | password=env("POSTGRES_PASSWORD"), 19 | database=env("POSTGRES_DATABASE", "qqrobot"), 20 | ) 21 | 22 | 23 | class Config: 24 | SUPERUSERS = env.list("SUPERUSERS", "", subcast=int) 25 | HOST = env("HOST", "0.0.0.0") 26 | PORT = env("PORT", 8080) 27 | DEBUG = env.bool("DEBUG", False) 28 | NICKNAME = "小科" 29 | COMMAND_START = {""} 30 | COMMAND_SEP = {"."} 31 | DATABASE_URL = get_database_url() 32 | SESSION_RUN_TIMEOUT = timedelta(minutes=2) 33 | SECRET = env("SECRET") 34 | AIOCACHE_DEFAULT_CONFIG = { 35 | "cache": "aiocache.SimpleMemoryCache", 36 | "serializer": {"class": "aiocache.serializers.PickleSerializer"}, 37 | } 38 | SUBSCIBE_INTERVAL = env.int("SUBSCIBE_INTERVAL", 600) # 单位 s 39 | CACHE_SESSION_TIMEOUT = env.int("CACHE_SESSION_TIMEOUT", 60 * 30) # 单位 s 40 | DB_ECHO = env.bool("DB_ECHO", False) 41 | WEB_URL = env("WEB_URL", "").rstrip("/") 42 | REDIS_PASSWORD = env("REDIS_PASSWORD", "") 43 | -------------------------------------------------------------------------------- /app/constants/dean.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | class API: 5 | studentInfo = "http://myo.swust.edu.cn/mht_shall/a/service/studentInfo" 6 | studentMark = "http://myo.swust.edu.cn/mht_shall/a/service/studentMark" 7 | card_data = "http://myo.swust.edu.cn/mht_shall/a/service/cardData?stuempno={}" 8 | jwc_course_table = "https://matrix.dean.swust.edu.cn/acadmicManager/index.cfm?event=studentPortal:courseTable" 9 | jwc_course_mark = "https://matrix.dean.swust.edu.cn/acadmicManager/index.cfm?event=studentProfile:courseMark" 10 | jwc_index = "https://matrix.dean.swust.edu.cn/acadmicManager/index.cfm?event=studentPortal:DEFAULT_EVENT" 11 | # 实验课的一些 信息 和 数据 12 | syk_base_url = "http://202.115.175.177" 13 | syk_verify_url = "http://202.115.175.177/swust/" 14 | syk_course_table = "http://202.115.175.177/StuExpbook/book/bookResult.jsp" 15 | auth_token_server = "http://cas.swust.edu.cn/authserver/login?service=" 16 | 17 | 18 | class INFO: 19 | term_start_day = time.strptime("2020-02-17", "%Y-%m-%d") 20 | term_name = "2019-2020-2" 21 | -------------------------------------------------------------------------------- /app/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | from environs import Env 3 | from dotenv import load_dotenv, find_dotenv 4 | 5 | __all__ = ["env", "load_env"] 6 | 7 | env = Env() 8 | 9 | 10 | def load_env(mode="bot"): 11 | load_dotenv(encoding="utf8") 12 | 13 | os.environ["RUN_MODE"] = mode 14 | if mode != "bot": 15 | if f := find_dotenv(".env.local"): 16 | load_dotenv(f, encoding="utf8") 17 | -------------------------------------------------------------------------------- /app/exceptions.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/app/exceptions.py -------------------------------------------------------------------------------- /app/libs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/app/libs/__init__.py -------------------------------------------------------------------------------- /app/libs/aio.py: -------------------------------------------------------------------------------- 1 | # from https://github.com/cczu-osa/aki/blob/master/aki/aio/__init__.py 2 | 3 | import asyncio 4 | from functools import partial 5 | from typing import Any 6 | 7 | 8 | async def run_sync_func(func, *args, **kwargs) -> Any: 9 | return await asyncio.get_event_loop().run_in_executor( 10 | None, partial(func, *args, **kwargs) 11 | ) 12 | -------------------------------------------------------------------------------- /app/libs/cache.py: -------------------------------------------------------------------------------- 1 | from aiocache import Cache 2 | from aiocache.serializers import PickleSerializer 3 | from app.config import Config 4 | 5 | __all__ = ["cache"] 6 | redis_kwargs = dict( 7 | endpoint="redis", 8 | password=Config.REDIS_PASSWORD, 9 | namespace="nb", 10 | serializer=PickleSerializer(), 11 | ) 12 | 13 | cache = Cache(Cache.REDIS, **redis_kwargs) 14 | -------------------------------------------------------------------------------- /app/libs/gino.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from gino.api import Gino as _Gino 4 | from gino.api import GinoExecutor as _Executor 5 | from gino.engine import GinoConnection as _Connection 6 | from gino.engine import GinoEngine as _Engine 7 | from gino.strategies import GinoStrategy 8 | 9 | from loguru import logger 10 | 11 | convention = { 12 | "ix": "ix_%(column_0_label)s", 13 | "uq": "uq_%(table_name)s_%(column_0_name)s", 14 | "ck": "ck_%(table_name)s_%(constraint_name)s", 15 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 16 | "pk": "pk_%(table_name)s", 17 | } 18 | 19 | 20 | class QuartModelMixin: 21 | pass 22 | 23 | 24 | class GinoExecutor(_Executor): 25 | pass 26 | 27 | 28 | class GinoConnection(_Connection): 29 | pass 30 | 31 | 32 | class GinoEngine(_Engine): 33 | connection_cls = GinoConnection 34 | 35 | 36 | class QuartStrategy(GinoStrategy): 37 | name = "quart" 38 | engine_cls = GinoEngine 39 | 40 | 41 | QuartStrategy() 42 | 43 | 44 | # noinspection PyClassHasNoInit 45 | class Gino(_Gino): 46 | """Support Quart web server. 47 | By :meth:`init_app` GINO registers a few hooks on Quart, so that GINO could 48 | use database configuration in Quart ``config`` to initialize the bound 49 | engine. 50 | """ 51 | 52 | model_base_classes = _Gino.model_base_classes + (QuartModelMixin,) 53 | query_executor = GinoExecutor 54 | 55 | async def set_bind(self, bind, loop=None, **kwargs): 56 | kwargs.setdefault("strategy", "quart") 57 | return await super().set_bind(bind, loop=loop, **kwargs) 58 | 59 | 60 | db = Gino(naming_convention=convention) 61 | 62 | 63 | async def init_db(): 64 | logger.debug("Initializing database") 65 | from app.config import Config 66 | 67 | if getattr(Config, "DATABASE_URL", None): 68 | try: 69 | await db.set_bind( 70 | Config.DATABASE_URL, echo=Config.DB_ECHO, loop=asyncio.get_event_loop(), 71 | ) 72 | logger.info("Database connected") 73 | except Exception: 74 | raise ConnectionError("Database connection error!") 75 | else: 76 | logger.warning("DATABASE_URL is missing, database may not work") 77 | -------------------------------------------------------------------------------- /app/libs/qqai_async/README.md: -------------------------------------------------------------------------------- 1 | # qqai_async 2 | 3 | > 修改自 4 | > 支持 async 5 | -------------------------------------------------------------------------------- /app/libs/qqai_async/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | from typing import Optional, TypedDict 4 | from urllib import parse 5 | 6 | import httpx 7 | from httpx import Response 8 | from loguru import logger 9 | 10 | from app.env import env 11 | 12 | 13 | class QQAI_KEY(TypedDict): 14 | appid: str 15 | appkey: str 16 | 17 | 18 | def check_qqai_key() -> Optional[QQAI_KEY]: 19 | appid = env("QQAI_APPID", None) 20 | appkey = env("QQAI_APPKEY", None) 21 | if not appid or not appkey: 22 | logger.error("未设置 QQAI_APPID 和 QQAI_APPKEY!") 23 | return None 24 | 25 | return {"appid": appid, "appkey": appkey} 26 | 27 | 28 | class QQAIClass: 29 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 30 | mediaHeaders = { 31 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.75 Safari/537.36" 32 | } 33 | api = "" 34 | 35 | def __init__(self, app_id, app_key): 36 | self.app_id = app_id 37 | self.app_key = app_key 38 | 39 | async def get_base64(self, media_param): 40 | """获取媒体的Base64字符串 41 | 42 | :param media_param 媒体URL或者媒体BufferedReader对象 43 | """ 44 | if type(media_param) == str: 45 | async with httpx.AsyncClient() as client: 46 | media_data = await client.get( 47 | media_param, headers=self.mediaHeaders 48 | ).content 49 | elif hasattr(media_param, "read"): 50 | media_data = await media_param.read() 51 | else: 52 | raise TypeError("media must be URL or BufferedReader") 53 | 54 | media = base64.b64encode(media_data).decode("utf-8") 55 | return media 56 | 57 | def get_sign(self, params): 58 | """获取签名 59 | """ 60 | uri_str = "" 61 | for key in sorted(params.keys()): 62 | uri_str += "{}={}&".format(key, parse.quote_plus(str(params[key]), safe="")) 63 | sign_str = uri_str + "app_key=" + self.app_key 64 | 65 | hash_str = hashlib.md5(sign_str.encode("utf-8")) 66 | return hash_str.hexdigest().upper() 67 | 68 | async def call_api(self, params, api=None) -> Response: 69 | if api is None: 70 | api = self.api 71 | 72 | async with httpx.AsyncClient() as client: 73 | return await client.post( 74 | api, data=parse.urlencode(params).encode("utf-8"), headers=self.headers 75 | ) 76 | -------------------------------------------------------------------------------- /app/libs/qqai_async/aaiasr.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from pathlib import Path 4 | 5 | import aiofiles 6 | from loguru import logger 7 | 8 | 9 | from . import QQAIClass, check_qqai_key 10 | 11 | 12 | class AudioRecognitionEcho(QQAIClass): 13 | """语音识别-echo版""" 14 | 15 | api = "https://api.ai.qq.com/fcgi-bin/aai/aai_asr" 16 | 17 | async def make_params(self, audio_format: int, speech, rate=None): 18 | """获取调用接口的参数""" 19 | params = { 20 | "app_id": self.app_id, 21 | "time_stamp": int(time.time()), 22 | "nonce_str": int(time.time()), 23 | "format": audio_format, 24 | "speech": await self.get_base64(speech), 25 | "rate": rate or 16000, 26 | } 27 | 28 | params["sign"] = self.get_sign(params) 29 | return params 30 | 31 | async def run(self, audio_format, speech, rate=None): 32 | params = await self.make_params(audio_format, speech, rate) 33 | response = await self.call_api(params) 34 | result = json.loads(response.text) 35 | return result 36 | 37 | 38 | async def echo(silk_fimename: str, coolq_record_dir=None): 39 | """[echo 语音识别] 40 | { 41 | "ret": 0, 42 | "msg": "ok", 43 | "data": { 44 | "format": 2, 45 | "rate": 16000, 46 | "text": "今天天气怎么样" 47 | } 48 | } 49 | """ 50 | key = check_qqai_key() 51 | if not key: 52 | return False, "未设置 QQAI 相关密钥" 53 | 54 | audio_rec = AudioRecognitionEcho(key["appid"], key["appkey"]) 55 | if not silk_fimename.endswith(".silk"): 56 | return False, "没有检测到语音文件" 57 | 58 | if coolq_record_dir is None: 59 | coolq_record_dir = Path("/coolq") / Path("data/record") 60 | 61 | SLIK = 4 62 | path: Path = coolq_record_dir / silk_fimename 63 | async with aiofiles.open(path, mode="rb") as f: 64 | result: dict = await audio_rec.run(audio_format=SLIK, speech=f) 65 | logger.info(result) 66 | if int(result.get("ret", -1)) == 0: 67 | return True, result["data"].get("text") 68 | else: 69 | return False, result.get("msg") 70 | -------------------------------------------------------------------------------- /app/libs/roconfig.py: -------------------------------------------------------------------------------- 1 | from collections import abc 2 | from typing import Any, Dict, Mapping, Optional 3 | 4 | __all__ = ["Configuration", "ConfigurationError", "ConfigurationOverrideError"] 5 | 6 | 7 | class ConfigurationError(Exception): 8 | """An exception risen for invalid configuration.""" 9 | 10 | 11 | class ConfigurationOverrideError(ConfigurationError): 12 | """An exception risen for invalid configuration override.""" 13 | 14 | 15 | def apply_key_value(obj: Any, key: str, value): 16 | key = key.strip("_:") # remove special characters from both ends 17 | for token in (":", "__"): 18 | if token in key: 19 | parts = key.split(token) 20 | 21 | sub_property = obj 22 | last_part = parts[-1] 23 | for part in parts[:-1]: 24 | if isinstance(sub_property, abc.MutableSequence): 25 | try: 26 | index = int(part) 27 | except ValueError: 28 | raise ConfigurationOverrideError( 29 | f"{part} was supposed to be a numeric index in {key}" 30 | ) 31 | 32 | sub_property = sub_property[index] 33 | continue 34 | 35 | try: 36 | sub_property = sub_property[part] 37 | except KeyError: 38 | sub_property[part] = {} 39 | sub_property = sub_property[part] 40 | else: 41 | if not isinstance(sub_property, abc.Mapping) and not isinstance( 42 | sub_property, abc.MutableSequence 43 | ): 44 | raise ConfigurationOverrideError( 45 | f"The key `{key}` cannot be used " 46 | f"because it overrides another " 47 | f"variable with shorter key! ({part}, {sub_property})" 48 | ) 49 | 50 | if isinstance(sub_property, abc.MutableSequence): 51 | try: 52 | index = int(last_part) 53 | except ValueError: 54 | raise ConfigurationOverrideError( 55 | f"{last_part} was supposed to be a numeric index in {key}, " 56 | f"because the affected property is a mutable sequence." 57 | ) 58 | 59 | try: 60 | sub_property[index] = value 61 | except IndexError: 62 | raise ConfigurationOverrideError( 63 | f"Invalid override for mutable sequence {key}; " 64 | f"assignment index out of range" 65 | ) 66 | else: 67 | try: 68 | sub_property[last_part] = value 69 | except TypeError as te: 70 | raise ConfigurationOverrideError( 71 | f"Invalid assignment {key} -> {value}; {str(te)}" 72 | ) 73 | 74 | return obj 75 | 76 | obj[key] = value 77 | return obj 78 | 79 | 80 | class Configuration: 81 | """ 82 | Provides methods to handle configuration objects. 83 | A read-only façade for navigating configuration objects using attribute notation. 84 | Thanks to Fluent Python, book by Luciano Ramalho; this class is inspired by his 85 | example of JSON structure explorer. 86 | """ 87 | 88 | __slots__ = ("__data",) 89 | 90 | def __new__(cls, arg=None): 91 | if not arg: 92 | return super().__new__(cls) 93 | if isinstance(arg, abc.Mapping): 94 | return super().__new__(cls) 95 | if isinstance(arg, abc.MutableSequence): 96 | return [cls(item) for item in arg] 97 | return arg 98 | 99 | def __init__(self, mapping: Optional[Mapping[str, Any]] = None): 100 | self.__data: Dict[str, Any] = {} 101 | if mapping: 102 | self.add_map(mapping) 103 | 104 | def __contains__(self, item: str) -> bool: 105 | return item in self.__data 106 | 107 | def __getitem__(self, name): 108 | value = self.__getattr__(name) 109 | if value is None: 110 | raise KeyError(name) 111 | return value 112 | 113 | def __getattr__(self, name, default=None) -> Any: 114 | if name in self.__data: 115 | value = self.__data.get(name) 116 | if isinstance(value, abc.Mapping) or isinstance(value, abc.MutableSequence): 117 | return Configuration(value) # type: ignore 118 | return value 119 | return default 120 | 121 | def __repr__(self) -> str: 122 | return repr(self.values) 123 | 124 | @property 125 | def values(self) -> Dict[str, Any]: 126 | """ 127 | Returns a copy of the dictionary of current settings. 128 | """ 129 | return self.__data.copy() 130 | 131 | def to_dict(self): 132 | return self.values 133 | 134 | def add_value(self, name: str, value: Any): 135 | """ 136 | Adds a configuration value by name. The name can contain 137 | paths to nested objects and list indices. 138 | :param name: name of property to set 139 | :param value: the value to set 140 | """ 141 | apply_key_value(self.__data, name, value) 142 | 143 | def add_object(self, obj): 144 | config = { 145 | k: v 146 | for k, v in obj.__dict__.items() 147 | if k.isupper() and not k.startswith("_") 148 | } 149 | self.__data.update(config) 150 | 151 | def add_map(self, value: Mapping[str, Any]): 152 | """ 153 | Merges a mapping object such as a dictionary, 154 | inside this configuration, 155 | :param value: instance of mapping object 156 | """ 157 | for key, value in value.items(): 158 | self.__data[key] = value 159 | 160 | def to_config(self): 161 | class Config: 162 | pass 163 | 164 | for k, v in self.values.items(): 165 | if k.isupper() and not k.startswith("_"): 166 | setattr(Config, k, v) 167 | return Config 168 | -------------------------------------------------------------------------------- /app/libs/scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List 3 | 4 | import nonebot as nb 5 | from nonebot import context_id, scheduler as nbscheduler 6 | 7 | from aiocqhttp import Event 8 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 9 | from apscheduler.jobstores.redis import RedisJobStore 10 | from apscheduler.jobstores.base import JobLookupError 11 | from apscheduler.job import Job 12 | 13 | from app.config import Config 14 | 15 | from ..aio import run_sync_func 16 | 17 | scheduler = AsyncIOScheduler() 18 | 19 | 20 | async def init_scheduler(): 21 | _bot = nb.get_bot() 22 | jobstores = { 23 | "default": RedisJobStore( 24 | host="redis", port=6379, password=Config.REDIS_PASSWORD 25 | ) 26 | } # 存储器 27 | if nbscheduler and nbscheduler.running: 28 | nbscheduler.shutdown(wait=False) 29 | 30 | if scheduler and not scheduler.running: 31 | scheduler.configure(_bot.config.APSCHEDULER_CONFIG, jobstores=jobstores) 32 | scheduler.start() 33 | 34 | 35 | def make_job_id(plugin_name: str, event: Event = None, job_name: str = "") -> str: 36 | """ 37 | Make a scheduler job id. 38 | :param plugin_name: the plugin that the user is calling 39 | :param context_id: context id 40 | :param job_name: name of the job, if not given, job id prefix is returned 41 | :return: job id, or job id prefix if job_name is not given 42 | """ 43 | job_id = f"/{plugin_name}" 44 | if event: 45 | job_id += context_id(event) 46 | 47 | if job_name: 48 | if not re.fullmatch(r"[_a-zA-Z][_a-zA-Z0-9]*", job_name): 49 | raise ValueError(r'job name should match "[_a-zA-Z][_a-zA-Z0-9]*"') 50 | job_id += f"/{job_name}" 51 | return job_id 52 | 53 | 54 | async def get_job(job_id: str) -> Job: 55 | """Get a scheduler job by id.""" 56 | return await run_sync_func(scheduler.get_job, job_id) 57 | 58 | 59 | async def get_jobs(job_id_prefix: str) -> List[Job]: 60 | """Get all scheduler jobs with given id prefix.""" 61 | all_jobs = await run_sync_func(scheduler.get_jobs) 62 | return list( 63 | filter( 64 | lambda j: j.id.rsplit("/", maxsplit=1)[0] == job_id_prefix.rstrip("/"), 65 | all_jobs, 66 | ) 67 | ) 68 | 69 | 70 | async def remove_job(job_id: str) -> bool: 71 | """Remove a scheduler job by id.""" 72 | try: 73 | await run_sync_func(scheduler.remove_job, job_id) 74 | return True 75 | except JobLookupError: 76 | return False 77 | 78 | 79 | async def add_job(func, **kwargs) -> Job: 80 | return await run_sync_func(scheduler.add_job, func, **kwargs) 81 | -------------------------------------------------------------------------------- /app/libs/scheduler/command.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, Union, List, Tuple 2 | 3 | import nonebot as nb 4 | from nonebot.command import call_command 5 | 6 | from aiocqhttp import Event 7 | from apscheduler.job import Job 8 | from apscheduler.jobstores.base import ConflictingIdError 9 | 10 | from . import scheduler 11 | from .exception import JobIdConflictError 12 | from ..aio import run_sync_func 13 | 14 | 15 | class ScheduledCommand: 16 | """ 17 | Represent a command that will be run when a scheduler job 18 | being executing. 19 | """ 20 | 21 | __slots__ = ("name", "current_arg") 22 | 23 | def __init__(self, name: Union[str, Tuple[str]], current_arg: str = ""): 24 | self.name = name 25 | self.current_arg = current_arg 26 | 27 | def __repr__(self): 28 | return ( 29 | f"" 32 | ) 33 | 34 | def __str__(self): 35 | return f"{self.name}" f'{" " + self.current_arg if self.current_arg else ""}' 36 | 37 | 38 | async def add_scheduled_commands( 39 | commands: Union[ScheduledCommand, List[ScheduledCommand]], 40 | *, 41 | job_id: str, 42 | event: Event, 43 | trigger: str, 44 | replace_existing: bool = False, 45 | misfire_grace_time: int = 360, 46 | apscheduler_kwargs: Dict[str, Any] = None, 47 | **trigger_args, 48 | ) -> Job: 49 | """ 50 | Add commands to scheduler for scheduled execution. 51 | :param commands: commands to add, can be a single ScheduledCommand 52 | :param job_id: job id, using of make_job_id() is recommended 53 | :param event: context dict 54 | :param trigger: same as APScheduler 55 | :param replace_existing: same as APScheduler 56 | :param misfire_grace_time: same as APScheduler 57 | :param apscheduler_kwargs: other APScheduler keyword args 58 | :param trigger_args: same as APScheduler 59 | """ 60 | commands = [commands] if isinstance(commands, ScheduledCommand) else commands 61 | 62 | try: 63 | return await run_sync_func( 64 | scheduler.add_job, 65 | _scheduled_commands_callback, 66 | id=job_id, 67 | trigger=trigger, 68 | **trigger_args, 69 | kwargs={"event": event, "commands": commands}, 70 | replace_existing=replace_existing, 71 | misfire_grace_time=misfire_grace_time, 72 | **(apscheduler_kwargs or {}), 73 | ) 74 | except ConflictingIdError: 75 | raise JobIdConflictError(job_id) 76 | 77 | 78 | async def _scheduled_commands_callback( 79 | event: Event, commands: List[ScheduledCommand] 80 | ) -> None: 81 | # get the current bot, we may not in the original running environment now 82 | bot = nb.get_bot() 83 | for cmd in commands: 84 | await call_command( 85 | bot, 86 | event, 87 | cmd.name, 88 | current_arg=cmd.current_arg, 89 | check_perm=True, 90 | disable_interaction=True, 91 | ) 92 | 93 | 94 | def get_scheduled_commands_from_job(job: Job) -> List[ScheduledCommand]: 95 | """Get list of ScheduledCommand from a job.""" 96 | return job.kwargs["commands"] 97 | -------------------------------------------------------------------------------- /app/libs/scheduler/exception.py: -------------------------------------------------------------------------------- 1 | from apscheduler.jobstores.base import ConflictingIdError 2 | 3 | 4 | class SchedulerError(Exception): 5 | pass 6 | 7 | 8 | class JobIdConflictError(ConflictingIdError, SchedulerError): 9 | pass 10 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/app/models/__init__.py -------------------------------------------------------------------------------- /app/models/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from sqlalchemy import Column 3 | 4 | from app.libs.gino import db 5 | 6 | 7 | class Base(db.Model): 8 | __abstract__ = True 9 | create_at = Column(db.DateTime, default=datetime.now) 10 | update_at = Column(db.DateTime, default=datetime.now, onupdate=datetime.now) 11 | -------------------------------------------------------------------------------- /app/models/chat_records.py: -------------------------------------------------------------------------------- 1 | from aiocqhttp import Event 2 | from nonebot import context_id 3 | from sqlalchemy import Column 4 | 5 | from app.libs.gino import db 6 | 7 | from .base import Base 8 | 9 | 10 | class ChatRecords(Base, db.Model): 11 | """保存聊天记录 Model 12 | """ 13 | 14 | __tablename__ = "chat_records" 15 | 16 | id_ = Column("id", db.Integer, db.Sequence("chat_records_id_seq"), primary_key=True) 17 | self_id = Column(db.String(32)) 18 | ctx_id = Column(db.String(64)) 19 | msg = Column(db.String) 20 | out = Column(db.Boolean, default=False) 21 | 22 | @classmethod 23 | async def add_msg(cls, event: Event, out: bool = False): 24 | await ChatRecords.create( 25 | self_id=str(event.self_id), 26 | ctx_id=context_id(event), 27 | msg=str(event.message), 28 | out=out, 29 | ), 30 | 31 | @classmethod 32 | async def get_last_msg(cls, event: Event): 33 | return ( 34 | await ChatRecords.query.where(cls.ctx_id == context_id(event)) 35 | .where(cls.self_id == str(event.self_id),) 36 | .order_by(cls.id_.desc()) 37 | .gino.first() 38 | ) 39 | -------------------------------------------------------------------------------- /app/models/course.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Union 3 | 4 | from nonebot import get_bot 5 | from nonebot.command import call_command 6 | from sqlalchemy import Column 7 | from sqlalchemy.dialects.postgresql import JSONB 8 | 9 | from app.libs.aio import run_sync_func 10 | from app.libs.gino import db 11 | from app.libs.scheduler import add_job 12 | from app.utils.bot import qq2event 13 | from app.services.course.parse import get_course_api 14 | 15 | from .base import Base 16 | from .user import User 17 | 18 | 19 | class CourseStudent(Base, db.Model): 20 | """学生选课表 Model 21 | """ 22 | 23 | __tablename__ = "course_student" 24 | 25 | student_id = Column( 26 | db.String(32), 27 | db.ForeignKey("user.student_id", onupdate="CASCADE", ondelete="SET NULL"), 28 | primary_key=True, 29 | ) 30 | course_json = Column(JSONB, nullable=False, server_default="{}") 31 | 32 | def __str__(self): 33 | return f"" 34 | 35 | @classmethod 36 | async def add_or_update(cls, student_id, course_json) -> "CourseStudent": 37 | c_stu = await cls.get(student_id) 38 | if c_stu: 39 | await c_stu.update(course_json=course_json).apply() 40 | else: 41 | c_stu = await cls.create(student_id=student_id, course_json=course_json) 42 | return c_stu 43 | 44 | @classmethod 45 | async def get_course(cls, qq: int) -> Union["CourseStudent", str]: 46 | if not await User.check(qq): 47 | return "NOT_BIND" 48 | _bot = get_bot() 49 | query = cls.join(User).select() 50 | course_student = await query.where(User.qq == str(qq)).gino.first() 51 | if course_student is None: 52 | await add_job(cls.update_course, args=[qq]) 53 | await _bot.send(qq2event(qq), "正在抓取课表,抓取过后我会直接发给你!") 54 | return "WAIT" 55 | return course_student 56 | 57 | @classmethod 58 | async def update_course(cls, qq: int): 59 | user: User = await User.get(str(qq)) 60 | if not user: 61 | return 62 | _bot = get_bot() 63 | sess = await User.get_session(user) 64 | res = await run_sync_func(get_course_api, sess) 65 | if res: 66 | c_stu = await cls.add_or_update( 67 | student_id=user.student_id, course_json=json.dumps(res) 68 | ) 69 | await call_command(_bot, qq2event(qq), "cs") 70 | return c_stu 71 | -------------------------------------------------------------------------------- /app/models/score.py: -------------------------------------------------------------------------------- 1 | """ 2 | 因为 `成绩` 部分爬取的教务处信息无法和课程表对应上,所以这里是独立的一个表,跟 course 表没有关系。 3 | """ 4 | from collections import defaultdict 5 | from typing import List 6 | 7 | import pandas as pd 8 | from aiocqhttp import Event 9 | from sqlalchemy import Column 10 | 11 | from app.libs.gino import db 12 | from app.models.user import User 13 | from .base import Base 14 | 15 | 16 | class ScoreCata: 17 | COMMON = "common" # 通选课 18 | PHYSICAL = "physical" # 体育 19 | REQUIRED = "required" # 必修课 20 | 21 | 22 | class PlanScore(Base, db.Model): 23 | """计划课程成绩 Model 24 | """ 25 | 26 | _cn_list = ["课程", "课程号", "学分", "课程性质", "正考", "补考", "绩点"] 27 | _en_list = [ 28 | "course_name", 29 | "course_id", 30 | "credit", 31 | "property_", 32 | "score", 33 | "make_up_score", 34 | "gpa", 35 | ] 36 | __tablename__ = "score_plan" 37 | id_ = Column("id", db.Integer, db.Sequence("plan_score_id_seq"), primary_key=True) 38 | student_id = Column( 39 | db.String(32), 40 | db.ForeignKey("user.student_id", onupdate="CASCADE", ondelete="SET NULL"), 41 | primary_key=True, 42 | ) 43 | course_id = Column(db.String(16), primary_key=True) 44 | term = Column(db.String(64), primary_key=True) # 学期 45 | course_name = Column(db.String(64)) 46 | property_ = Column("property", db.String(64)) # 必修 选修 限选 47 | credit = Column(db.String(16)) 48 | score = Column(db.String(16)) # 可能考试成绩是 `通过` 49 | make_up_score = Column(db.String(16)) # 补考成绩 50 | gpa = Column(db.String(16)) 51 | season = Column(db.String(16)) # 春季 秋季 term中表现为春季2 秋季1 52 | 53 | @classmethod 54 | async def add_or_update_one(cls, student_id, term, season, series): 55 | kwargs = dict(student_id=student_id, term=term, season=season) 56 | for cn_key, en_key in zip(cls._cn_list, cls._en_list): 57 | kwargs[en_key] = series[cn_key] 58 | sco = ( 59 | await cls.query.where(cls.student_id == student_id) 60 | .where(cls.course_id == series["课程号"]) 61 | .where(cls.term == term) 62 | .gino.first() 63 | ) 64 | if sco: 65 | await sco.update(**kwargs).apply() 66 | else: 67 | await cls.create(**kwargs) 68 | 69 | @classmethod 70 | async def add_or_update(cls, student_id, plan): 71 | terms = pd.unique(plan["term"]) 72 | for term in terms: 73 | _term = plan[plan["term"] == term] 74 | seasons = pd.unique(_term["season"]) 75 | for season in seasons: 76 | data = _term[_term["season"] == season] 77 | for _, series in data.iterrows(): 78 | await cls.add_or_update_one(student_id, term, season, series) 79 | 80 | @classmethod 81 | def to_df(cls, plan_scores: List["PlanScore"]): 82 | dct = defaultdict(list) 83 | for item in plan_scores: 84 | for en, cn in zip(PlanScore._en_list, PlanScore._cn_list): 85 | dct[cn].append(getattr(item, en, None)) 86 | dct["term"].append(getattr(item, "term", None)) 87 | dct["season"].append(getattr(item, "season", None)) 88 | 89 | df = pd.DataFrame(data=dct) 90 | return df 91 | 92 | @classmethod 93 | async def load_score(cls, event: Event) -> pd.DataFrame: 94 | user = await User.check(event.user_id) 95 | if not user: 96 | return 97 | scores = await cls.query.where(cls.student_id == user.student_id).gino.all() 98 | if not scores: 99 | return 100 | df = cls.to_df(scores) 101 | return df 102 | 103 | 104 | class PhysicalOrCommonScore(Base, db.Model): 105 | """课程成绩 Model 106 | """ 107 | 108 | _cn_list = ["学期", "课程", "课程号", "学分", "正考", "补考", "绩点"] 109 | _en_list = [ 110 | "term", 111 | "course_name", 112 | "course_id", 113 | "credit", 114 | "score", 115 | "make_up_score", 116 | "gpa", 117 | ] 118 | __tablename__ = "score_physic_or_common" 119 | id_ = Column( 120 | "id", db.Integer, db.Sequence("physic_or_common_score_id_seq"), primary_key=True 121 | ) 122 | student_id = Column( 123 | db.String(32), 124 | db.ForeignKey("user.student_id", onupdate="CASCADE", ondelete="SET NULL"), 125 | primary_key=True, 126 | ) 127 | course_id = Column(db.String(16), primary_key=True) 128 | term = Column(db.String(64), primary_key=True) # 学期 129 | course_name = Column(db.String(64)) 130 | credit = Column(db.String(16)) 131 | score = Column(db.String(16)) # 可能考试成绩是 `通过` 132 | make_up_score = Column(db.String(16)) # 补考成绩 133 | gpa = Column(db.String(16)) 134 | cata = Column(db.String(16)) 135 | 136 | @classmethod 137 | async def add_or_update_one(cls, student_id, cata, series): 138 | kwargs = dict(student_id=student_id, cata=cata) 139 | for cn_key, en_key in zip(cls._cn_list, cls._en_list): 140 | kwargs[en_key] = series[cn_key] 141 | sco = ( 142 | await cls.query.where(cls.student_id == student_id) 143 | .where(cls.course_id == series["课程号"]) 144 | .where(cls.term == series["学期"]) 145 | .gino.first() 146 | ) 147 | if sco: 148 | await sco.update(**kwargs).apply() 149 | else: 150 | await cls.create(**kwargs) 151 | 152 | @classmethod 153 | async def add_or_update(cls, student_id, score_dict, cata): 154 | table = score_dict[cata] 155 | for _, series in table.iterrows(): 156 | await cls.add_or_update_one(student_id, cata, series) 157 | 158 | 159 | class CETScore(Base, db.Model): 160 | """计划课程成绩 Model 161 | """ 162 | 163 | _cn_list = ["准考证号", "考试场次", "语言级别", "总分", "听力", "阅读", "写作", "综合"] 164 | _en_list = [ 165 | "exam_id", 166 | "exam_name", 167 | "level", 168 | "total", 169 | "listen", 170 | "read", 171 | "write", 172 | "common", 173 | ] 174 | __tablename__ = "score_cet" 175 | id_ = Column("id", db.Integer, db.Sequence("cet_score_id_seq"), primary_key=True) 176 | student_id = Column( 177 | db.String(32), 178 | db.ForeignKey("user.student_id", onupdate="CASCADE", ondelete="SET NULL"), 179 | primary_key=True, 180 | ) 181 | 182 | exam_id = Column(db.String(32), primary_key=True) 183 | exam_name = Column(db.String(64)) 184 | level = Column(db.String(16)) 185 | total = Column(db.String(16)) 186 | listen = Column(db.String(16)) 187 | read = Column(db.String(16)) 188 | write = Column(db.String(16)) 189 | common = Column(db.String(16)) 190 | 191 | @classmethod 192 | async def add_or_update_one(cls, student_id, series): 193 | kwargs = dict(student_id=student_id) 194 | for cn_key, en_key in zip(cls._cn_list, cls._en_list): 195 | kwargs[en_key] = series[cn_key] 196 | sco = ( 197 | await cls.query.where(cls.student_id == student_id) 198 | .where(cls.exam_id == series["准考证号"]) 199 | .gino.first() 200 | ) 201 | if sco: 202 | await sco.update(**kwargs).apply() 203 | else: 204 | await cls.create(**kwargs) 205 | 206 | @classmethod 207 | async def add_or_update(cls, student_id, table): 208 | for _, series in table.iterrows(): 209 | await cls.add_or_update_one(student_id, series) 210 | -------------------------------------------------------------------------------- /app/models/subscribe.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pickle 3 | from typing import List 4 | 5 | from aiocqhttp import Event 6 | from loguru import logger 7 | from nonebot import context_id, get_bot 8 | from nonebot.helpers import send 9 | 10 | from app.libs.gino import db 11 | from app.utils.bot import ctx_id2event, send_msgs 12 | from app.utils.rss import diff, get_rss_info, mk_msg_content 13 | 14 | from .base import Base 15 | 16 | 17 | class SubContent(Base, db.Model): 18 | """所有订阅内容 Model 19 | """ 20 | 21 | __tablename__ = "sub_content" 22 | 23 | link = db.Column(db.String(128), primary_key=True) 24 | name = db.Column(db.String(128)) 25 | content = db.Column(db.LargeBinary) 26 | 27 | @classmethod 28 | async def add_or_update(cls, link, name, content) -> "SubContent": 29 | sub = await cls.get(link) 30 | if sub: 31 | await sub.update(link=link, name=name, content=content).apply() 32 | else: 33 | sub = await cls.create(link=link, name=name, content=content) 34 | return sub 35 | 36 | @classmethod 37 | async def check_update(cls): 38 | logger.info("开始检查RSS更新") 39 | all_subs = await SubContent.query.gino.all() 40 | if all_subs: 41 | await asyncio.wait([cls._check_one(sub) for sub in all_subs]) 42 | logger.info("结束检查RSS更新") 43 | 44 | @classmethod 45 | async def _check_one(cls, sub): 46 | users = await SubUser.get_users(sub.link) 47 | if not users: 48 | return 49 | logger.info("检查" + sub.name + "更新") 50 | logger.info(sub.name + "的用户们:" + str(users)) 51 | event_list = [ctx_id2event(user.ctx_id) for user in users] 52 | 53 | content = await get_rss_info(sub.link) 54 | old_content = pickle.loads(sub.content) 55 | diffs = diff(content, old_content) 56 | logger.info(sub.name + "的更新" + str(diffs)) 57 | msgs = mk_msg_content(content, diffs) 58 | 59 | await asyncio.gather(*[send_msgs(event, msgs) for event in event_list]) 60 | await SubContent.add_or_update(sub.link, sub.name, pickle.dumps(content)) 61 | 62 | 63 | class SubUser(Base, db.Model): 64 | """用户订阅 Model 65 | """ 66 | 67 | __tablename__ = "sub_user" 68 | 69 | ctx_id = db.Column(db.String(64), primary_key=True) 70 | link = db.Column( 71 | db.String(128), 72 | db.ForeignKey("sub_content.link", onupdate="CASCADE", ondelete="SET NULL"), 73 | primary_key=True, 74 | ) 75 | only_title = db.Column(db.Boolean, default=True) 76 | 77 | def __repr__(self): 78 | return f"" 79 | 80 | @classmethod 81 | async def add_sub(cls, event: Event, url: str, only_title=False): 82 | try: 83 | d = await get_rss_info(url) 84 | except Exception as e: 85 | logger.exception(e) 86 | await send(get_bot(), event, f"获取 {url} 订阅信息失败,请稍后重试。") 87 | return None 88 | 89 | if not d: 90 | return None 91 | info = d["channel"] 92 | title = info.get("title", url) 93 | sub = await SubContent.add_or_update( 94 | link=url, name=title, content=pickle.dumps(d), 95 | ) 96 | await SubUser.create( 97 | ctx_id=context_id(event, mode="group"), link=sub.link, only_title=only_title 98 | ) 99 | return title 100 | 101 | @classmethod 102 | async def get_sub(cls, event: Event, url: str): 103 | ctx_id = context_id(event, mode="group") 104 | loader = SubUser.load(sub_content=SubContent) 105 | sub = ( 106 | await cls.outerjoin(SubContent) 107 | .select() 108 | .where(cls.link == url) 109 | .where(cls.ctx_id == ctx_id) 110 | .gino.load(loader) 111 | .first() 112 | ) 113 | return sub 114 | 115 | @classmethod 116 | async def get_user_subs(cls, event: Event) -> List["SubUser"]: 117 | ctx_id = context_id(event, mode="group") 118 | loader = SubUser.load(sub_content=SubContent) 119 | sub = ( 120 | await cls.outerjoin(SubContent) 121 | .select() 122 | .where(cls.ctx_id == ctx_id) 123 | .gino.load(loader) 124 | .all() 125 | ) 126 | return sub 127 | 128 | @classmethod 129 | async def remove_sub(cls, event: Event, url: str): 130 | ctx_id = context_id(event, mode="group") 131 | sub = ( 132 | await cls.query.where(cls.link == url) 133 | .where(cls.ctx_id == ctx_id) 134 | .gino.first() 135 | ) 136 | await sub.delete() 137 | return True 138 | 139 | @classmethod 140 | async def get_users(cls, url: str): 141 | sub = await cls.query.where(cls.link == url).gino.all() 142 | return sub 143 | -------------------------------------------------------------------------------- /app/models/user.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | from loguru import logger 4 | from nonebot import get_bot 5 | from sqlalchemy import Column 6 | 7 | from app.constants.dean import API 8 | from app.libs.aio import run_sync_func 9 | from app.libs.cache import cache 10 | from app.libs.gino import db 11 | from app.utils.bot import qq2event 12 | from app.config import Config 13 | 14 | from .base import Base 15 | 16 | 17 | class User(Base, db.Model): 18 | """用户表 Model 19 | """ 20 | 21 | __tablename__ = "user" 22 | 23 | qq = Column(db.String(16), primary_key=True) 24 | student_id = Column(db.String(32), unique=True) 25 | password = Column(db.String(64), nullable=False) 26 | cookies = Column(db.LargeBinary) 27 | 28 | @classmethod 29 | async def add(cls, *, qq: int, student_id: int, password: str, cookies): 30 | user = User( 31 | student_id=str(student_id), qq=str(qq), password=password, cookies=cookies, 32 | ) 33 | await user.create() 34 | return user 35 | 36 | @classmethod 37 | async def unbind(cls, qq: int): 38 | user = await User.query.where(User.qq == str(qq)).gino.first() 39 | await user.delete() 40 | return True 41 | 42 | @classmethod 43 | async def check(cls, qq: int): 44 | user = await cls.get(str(qq)) 45 | if user: 46 | return user 47 | _bot = get_bot() 48 | await _bot.send(qq2event(qq), "未绑定,试试对我发送 `绑定`") 49 | return False 50 | 51 | @classmethod 52 | async def get_cookies(cls, user: "User"): 53 | from auth_swust import Login 54 | from auth_swust import request as login_request 55 | 56 | cookies = pickle.loads(user.cookies) 57 | sess = login_request.Session(cookies) 58 | res = await run_sync_func( 59 | sess.get, API.jwc_index, allow_redirects=False, verify=False 60 | ) 61 | 62 | # 302重定向了,session 失效,重新获取session 63 | if res.status_code == 302 or res.status_code == 301: 64 | u_ = Login(user.student_id, user.password) 65 | is_log, _ = await run_sync_func(u_.try_login) 66 | if is_log: 67 | cookies = pickle.dumps(u_.get_cookies()) 68 | await user.update(cookies=cookies).apply() 69 | logger.info(f"更新qq: {user.qq} 的 session") 70 | return u_.get_cookies() 71 | else: 72 | return cookies 73 | 74 | @classmethod 75 | async def get_session(cls, user: "User"): 76 | from auth_swust import request as login_request 77 | 78 | key = f"cookies/{user.qq}" 79 | cookies = await cache.get(key) 80 | if not cookies: 81 | cookies = await cls.get_cookies(user) 82 | await cache.set(key, cookies, ttl=Config.CACHE_SESSION_TIMEOUT) 83 | sess = login_request.Session(cookies) 84 | return sess 85 | -------------------------------------------------------------------------------- /app/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/app/services/__init__.py -------------------------------------------------------------------------------- /app/services/credit/parse.py: -------------------------------------------------------------------------------- 1 | """ 2 | 解析 学分修读进度 3 | """ 4 | 5 | from typing import List, TypedDict 6 | from bs4 import BeautifulSoup 7 | 8 | from app.constants.dean import API 9 | 10 | 11 | class CreditProgressDict(TypedDict): 12 | 总学分: float 13 | 必修课: float 14 | 选修课: float 15 | 体育类: float 16 | 全校通选: float 17 | 学位课: float 18 | 平均绩点: float 19 | 必修课绩点: float 20 | 学位课绩点: float 21 | 22 | 23 | def get_credit_progress(sess) -> CreditProgressDict: 24 | """传入 requests 的 session 25 | """ 26 | 27 | res = sess.get(API.jwc_course_mark, verify=False) 28 | json = _parse_credit_progress(res.text) 29 | 30 | return json 31 | 32 | 33 | def _parse_credit_progress(html) -> CreditProgressDict: 34 | """ 35 | :param html: 网页 36 | :return: dict 37 | """ 38 | result: CreditProgressDict = {} # type: ignore 39 | soup: BeautifulSoup = BeautifulSoup(html, "lxml") 40 | blocks: List[BeautifulSoup] = soup.select( 41 | "div#Summary > div.UICircle > ul.boxNavigation > li" 42 | ) 43 | # 循环遍历每个 block 一共有上下两块 44 | for block in blocks: 45 | result[str(block.span.string)] = str(block.em.text) 46 | 47 | return result 48 | -------------------------------------------------------------------------------- /app/services/ecard/parse.py: -------------------------------------------------------------------------------- 1 | from app.constants.dean import API 2 | 3 | 4 | def get_ecard_balance(sess, student_id): 5 | """传入 requests 的 session 6 | """ 7 | 8 | res = sess.get(API.card_data.format(student_id), verify=False) 9 | json = _parse_credit_progress(res.text) 10 | 11 | return json 12 | 13 | 14 | def _parse_credit_progress(json): 15 | return json 16 | -------------------------------------------------------------------------------- /app/services/score/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from aiocqhttp import Event 4 | from loguru import logger 5 | from nonebot import get_bot 6 | import pandas as pd 7 | 8 | from app.config import Config 9 | from app.libs.aio import run_sync_func 10 | from app.libs.cache import cache 11 | from app.libs.scheduler import add_job 12 | from app.models.score import PlanScore 13 | from app.models.user import User 14 | from app.utils.bot import qq2event 15 | from .parse import parse_score, ScoreDict 16 | from .utils import ( 17 | diff_score, 18 | send_score, 19 | tabulate, 20 | save_score, 21 | save_cet_score, 22 | send_cet_score, 23 | ) 24 | 25 | 26 | class ScoreService: 27 | @classmethod 28 | async def check_update(cls, event: Event, plan: pd.DataFrame): 29 | old_score = await PlanScore.load_score(event) 30 | if old_score is None: 31 | return 32 | diffs = diff_score(plan, old_score) 33 | if not diffs.empty: 34 | bot = get_bot() 35 | await bot.send(event, f"有新的成绩:\n{tabulate(diffs)}") 36 | 37 | @classmethod 38 | async def send_score(cls, qq: int) -> Optional[str]: 39 | # 先查 user 出来,再查 Course 表 40 | user = await User.check(qq) 41 | if not user: 42 | return "NOT_BIND" 43 | await add_job(cls._send_score, args=[user]) 44 | _bot = get_bot() 45 | await _bot.send(qq2event(qq), "正在抓取成绩,抓取过后我会直接发给你!") 46 | return "WAIT" 47 | 48 | @classmethod 49 | async def send_cet_score(cls, qq: int) -> Optional[str]: 50 | # 先查 user 出来,再查 Course 表 51 | user = await User.check(qq) 52 | if not user: 53 | return "NOT_BIND" 54 | await add_job(cls._send_cet_score, args=[user]) 55 | _bot = get_bot() 56 | await _bot.send(qq2event(qq), "正在抓取成绩,抓取过后我会直接发给你!") 57 | return "WAIT" 58 | 59 | @classmethod 60 | async def _send_cet_score(cls, user: User): 61 | _bot = get_bot() 62 | try: 63 | res: ScoreDict = await cls._get_score(user) 64 | await save_cet_score(user, res) 65 | await send_cet_score(user, res) 66 | except Exception as e: 67 | logger.exception(e) 68 | await _bot.send(qq2event(user.qq), "查询成绩出错,请稍后再试") 69 | 70 | @classmethod 71 | async def _get_score(cls, user: User) -> ScoreDict: 72 | key = f"score/{user.qq}" 73 | res = await cache.get(key) 74 | if not res: 75 | sess = await User.get_session(user) 76 | res: ScoreDict = await run_sync_func(parse_score, sess) 77 | if res: 78 | await cache.set(key, res, ttl=Config.CACHE_SESSION_TIMEOUT) 79 | else: 80 | raise ValueError("查询成绩出错") 81 | return res 82 | 83 | @classmethod 84 | async def _send_score(cls, user: User): 85 | _bot = get_bot() 86 | try: 87 | res: ScoreDict = await cls._get_score(user) 88 | await save_score(user, res) 89 | await send_score(user, res) 90 | except Exception as e: 91 | logger.exception(e) 92 | await _bot.send(qq2event(user.qq), "查询成绩出错,请稍后再试") 93 | -------------------------------------------------------------------------------- /app/services/score/parse.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict, List 2 | 3 | import pandas as pd 4 | import numpy as np 5 | import regex as re 6 | from bs4 import BeautifulSoup 7 | 8 | from app.constants.dean import API 9 | 10 | 11 | def clear_lino1(table: pd.DataFrame): 12 | """这一步是把 columns 替换为第一行的内容 13 | 并且 drop 掉第一行,重设 index 14 | """ 15 | table.columns = table.iloc[0] 16 | table.drop(0, inplace=True) 17 | table.reset_index(inplace=True, drop=True) 18 | 19 | 20 | def rm_nan(table: pd.DataFrame): 21 | """替换 NaN 为 无""" 22 | table.fillna("无", inplace=True) 23 | table.replace(np.nan, "无", regex=True, inplace=True) 24 | 25 | 26 | def extract_term(df: pd.DataFrame): 27 | """解析出当前学期 28 | df 的第一行就是当前学期,使用正则表达式解析出相应的数据 29 | drop 掉第一行,并且重设 index 30 | """ 31 | text = df.iloc[0][0] 32 | text = text.replace(" ", "") 33 | match = re.compile(r"(\d*-\d*)学年").match(text) 34 | if match: 35 | df.drop(0, inplace=True) 36 | df.reset_index(drop=True, inplace=True) 37 | return match.group(1) 38 | return None 39 | 40 | 41 | def parse_score(sess): 42 | """传入 requests 的 session,这个函数负责请求页面 43 | 然后爬取成绩页面 解析出来相应内容 44 | """ 45 | 46 | res = sess.get(API.jwc_course_mark, verify=False) 47 | return _parse_score(res.text) 48 | 49 | 50 | class ScoreDict(TypedDict): 51 | cet: pd.DataFrame 52 | plan: pd.DataFrame 53 | physical: pd.DataFrame 54 | common: pd.DataFrame 55 | 56 | 57 | def _parse_score(html) -> ScoreDict: 58 | """解析相应信息""" 59 | result: ScoreDict = {} # type: ignore 60 | soup = BeautifulSoup(html, "lxml") 61 | b = soup.select_one("#contentArea > div.UIElement > ul > li > #welcome") 62 | result["cet"] = _parse_cet(b) 63 | result["plan"] = _parse_plan(b) # 计划课程 64 | result["physical"] = _parse_physic_or_common(b, "physical") 65 | result["common"] = _parse_physic_or_common(b, "common") # 通选课 66 | return result 67 | 68 | 69 | def _parse_cet(block): 70 | # ["准考证号", "考试场次", "语言级别", "总分", "听力", "阅读", "写作", "综合"] 71 | table = pd.read_html(str(block.select("#CET table")))[0] 72 | clear_lino1(table) 73 | rm_nan(table) 74 | return table 75 | 76 | 77 | def _parse_physic_or_common(block, cata: str) -> pd.DataFrame: 78 | # ["学期", "课程", "课程号", "学分", "正考", "补考", "绩点"] 79 | table = pd.read_html(str(block.select(f"#{cata.capitalize()} table")))[0] 80 | clear_lino1(table) 81 | rm_nan(table) 82 | # 最后一行是学分绩点计算 83 | table.drop(len(table) - 1, inplace=True) 84 | return table 85 | 86 | 87 | def _parse_plan(block) -> pd.DataFrame: 88 | _tables = pd.read_html(str(block.select("#Plan table"))) 89 | df_lst = [] # type: List[pd.DataFrame] 90 | for table in _tables: 91 | table.dropna(thresh=len(table.columns) - 2, inplace=True) # 去除 NaN 行 92 | table.reset_index(drop=True, inplace=True) 93 | # line 0: 哪个学期 94 | term = extract_term(table) 95 | if not term: 96 | continue 97 | start_index = 0 98 | for idx, series in table.iterrows(): 99 | text = series[0] 100 | # 找到 `平均学分绩点` 这一行进行切分,分为上下两部分 101 | if text.startswith("平均学分绩点"): 102 | new_df = table.iloc[start_index:idx].copy() 103 | new_df.reset_index(inplace=True, drop=True) 104 | clear_lino1(new_df) 105 | rm_nan(new_df) 106 | # 学期名 春/秋 107 | # 替换 春/秋 为 season 108 | new_df.rename(columns={new_df.iat[0, 0]: "season"}, inplace=True) 109 | new_df["term"] = [term] * len(new_df.index) 110 | start_index = idx + 1 111 | df_lst.append(new_df) 112 | result = pd.concat(df_lst, ignore_index=True) 113 | return result 114 | -------------------------------------------------------------------------------- /app/services/score/utils.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from nonebot import get_bot 3 | 4 | from app.models.score import CETScore, PhysicalOrCommonScore, PlanScore, ScoreCata 5 | from app.utils.bot import qq2event, send_msgs 6 | 7 | from .parse import ScoreDict 8 | 9 | 10 | def calc_gpa(table, jidian_col="绩点", xuefen_col="学分", required=False): 11 | table[jidian_col] = pd.to_numeric(table[jidian_col]) 12 | table[xuefen_col] = pd.to_numeric(table[xuefen_col]) 13 | result = {"mean": round(table[jidian_col].sum() / table[xuefen_col].sum(), 3)} 14 | if required: 15 | _required = table[table["课程性质"] == "必修"] 16 | result["required"] = round( 17 | _required[jidian_col].sum() / _required[xuefen_col].sum(), 3, 18 | ) 19 | return result 20 | 21 | 22 | async def send_score(user, score: ScoreDict): 23 | msgs = [] 24 | msgs.append(_format_cet(score["cet"])) 25 | msgs.append(_format_physical_or_physical(score, "common")) 26 | msgs.append(_format_physical_or_physical(score, "physical")) 27 | msgs.extend(_format_plan(score["plan"])) 28 | await send_msgs(qq2event(user.qq), msgs) 29 | 30 | 31 | async def send_cet_score(user, score: ScoreDict): 32 | bot = get_bot() 33 | await bot.send(qq2event(user.qq), _format_cet(score["cet"])) 34 | 35 | 36 | def diff_score(new, old) -> pd.DataFrame: 37 | result = [] 38 | for idx, n_series in new.iterrows(): 39 | flag = 0 40 | for _, o_series in old.iterrows(): 41 | if ( 42 | n_series["课程号"] == o_series["课程号"] 43 | and n_series["term"] == o_series["term"] 44 | and n_series["season"] == o_series["season"] 45 | ): 46 | flag = 1 47 | break 48 | if flag == 0: 49 | result.append(idx) 50 | df = new.iloc[result] 51 | return df 52 | 53 | 54 | def _format_cet(table): 55 | msg = "四六级成绩:\n---------\n" 56 | for _, series in table.iterrows(): 57 | msg += str(series["考试场次"]) + "\n" 58 | msg += "- 准考证号:" + str(series["准考证号"]) + "\n" 59 | msg += "- 语言级别:" + str(series["语言级别"]) 60 | msg += " 总分:" + str(series["总分"]) + "\n" 61 | msg += "- 听力:" + str(series["听力"]) 62 | msg += " 阅读:" + str(series["阅读"]) + "\n" 63 | msg += "- 写作:" + str(series["写作"]) 64 | msg += " 综合:" + str(series["综合"]) + "\n" 65 | return msg 66 | 67 | 68 | def _format_physical_or_physical(score, cata): 69 | cata_cn = "体育成绩" if cata == "physical" else "全校通选课" 70 | df = score[cata] 71 | return ( 72 | f"{cata_cn}:\n" 73 | + tabulate(df, is_common_physic=True) 74 | + f"平均绩点:{calc_gpa(df)['mean']}\n" 75 | ) 76 | 77 | 78 | def _format_plan(plan): 79 | term_score = [] 80 | terms = pd.unique(plan["term"]) 81 | for term in terms: 82 | _term = plan[plan["term"] == term] 83 | seasons = pd.unique(_term["season"]) 84 | for season in seasons: 85 | data = _term[_term["season"] == season] 86 | jidian = calc_gpa(data, required=True) 87 | term_score.append( 88 | f"{term} {season}\n" 89 | + tabulate(data) 90 | + f"\n平均绩点:{jidian['mean']}\n" 91 | + f"必修绩点:{jidian['required']}\n" 92 | ) 93 | term_score.reverse() 94 | return term_score 95 | 96 | 97 | def tabulate(table, is_common_physic=False): 98 | msg = "---------\n" 99 | for _, series in table.iterrows(): 100 | if is_common_physic: 101 | msg += str(series["学期"]) + "\n" 102 | if not is_common_physic: 103 | msg += "[" + str(series["课程性质"]) + "] " 104 | msg += str(series["课程"]) + "\n" 105 | msg += "- 学分:" + str(series["学分"]) 106 | msg += " 绩点:" + str(series["绩点"]) + "\n" 107 | msg += "- 正考:" + str(series["正考"]) 108 | msg += " 补考:" + str(series["补考"]) + "\n" 109 | 110 | return msg 111 | 112 | 113 | async def save_score(user, score: ScoreDict): 114 | await save_cet_score(user, score) 115 | await PhysicalOrCommonScore.add_or_update(user.student_id, score, ScoreCata.COMMON) 116 | await PhysicalOrCommonScore.add_or_update( 117 | user.student_id, score, ScoreCata.PHYSICAL 118 | ) 119 | await PlanScore.add_or_update(user.student_id, score["plan"]) 120 | 121 | 122 | async def save_cet_score(user, score: ScoreDict): 123 | await CETScore.add_or_update(user.student_id, score["cet"]) 124 | -------------------------------------------------------------------------------- /app/services/subscribe/__init__.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Dict 3 | from aiocqhttp import Event 4 | 5 | 6 | class BaseSub(ABC): 7 | PREFIX = "" 8 | sub_info = {} 9 | 10 | @classmethod 11 | def get_subs(cls) -> Dict[str, str]: 12 | ... 13 | 14 | @classmethod 15 | async def get_user_sub(cls, event: Event) -> dict: 16 | ... 17 | 18 | @classmethod 19 | async def del_sub(cls, event: Event, key: str): 20 | ... 21 | 22 | @classmethod 23 | async def add_sub(cls, event: Event, key: str): 24 | ... 25 | 26 | @classmethod 27 | def dct(cls) -> Dict[str, str]: 28 | """ 29 | { "s0":"有新成绩出来时提醒我", "s1":"有新考试出来提醒我" } 30 | """ 31 | dct = {f"{cls.PREFIX}{idx}": k for idx, k in enumerate(cls.sub_info.keys())} 32 | return dct 33 | 34 | @classmethod 35 | def inv_dct(cls) -> Dict[str, str]: 36 | """ 37 | { "有新成绩出来时提醒我":"s0", "有新考试出来提醒我":"s1" } 38 | """ 39 | return {v: k for k, v in cls.dct().items()} 40 | -------------------------------------------------------------------------------- /app/services/subscribe/dean.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Tuple 2 | 3 | from aiocqhttp import Event 4 | from apscheduler.job import Job 5 | from apscheduler.triggers.interval import IntervalTrigger 6 | from loguru import logger 7 | 8 | 9 | from app.config import Config 10 | from app.libs.scheduler import add_job, get_jobs, make_job_id, remove_job 11 | from app.services.score import ScoreService 12 | from app.services.score.utils import save_score 13 | 14 | from app.models.user import User 15 | 16 | from . import BaseSub 17 | 18 | PLUGIN_NAME = "sub_score_update" 19 | 20 | 21 | class CheckUpdate: 22 | @staticmethod 23 | async def score(event: Event, **kwargs): 24 | logger.info(f"检查 {event.user_id} 是否有新成绩") 25 | user = await User.check(event.user_id) 26 | if not user: 27 | return 28 | score = await ScoreService._get_score(user) 29 | await ScoreService.check_update(event, score["plan"]) 30 | await save_score(user, score) 31 | 32 | @staticmethod 33 | async def exam(event: Event, **kwargs): 34 | logger.info(f"检查 {event.user_id} 是否有新考试") 35 | user = await User.check(event.user_id) 36 | if not user: 37 | return 38 | 39 | 40 | class DeanSub(BaseSub): 41 | PREFIX = "s" 42 | sub_info = {"有新成绩出来时提醒我": "score", "有新考试出来提醒我": "exam"} 43 | 44 | @classmethod 45 | def get_subs(cls) -> Dict[str, str]: 46 | return cls.dct() 47 | 48 | @classmethod 49 | async def get_user_sub(cls, event: Event) -> dict: 50 | result = {} 51 | jobs = await get_jobs(make_job_id(PLUGIN_NAME, event)) 52 | for job in jobs: 53 | job: Job 54 | key = job.kwargs["key"] 55 | result[key] = cls.dct()[key] 56 | 57 | return result 58 | 59 | @classmethod 60 | async def del_sub(cls, event: Event, key: str) -> Tuple[bool, str]: 61 | name = cls.dct().get(key, "") 62 | if name: 63 | res = await remove_job(make_job_id(PLUGIN_NAME, event, key)) 64 | if res: 65 | return True, f"删除 `{name}` 成功" 66 | else: 67 | return False, f"删除 `{name}` 失败,请稍后再试" 68 | else: 69 | return False, "输入有误" 70 | 71 | @classmethod 72 | async def add_sub(cls, event: Event, key: str) -> Tuple[bool, str]: 73 | name = cls.dct().get(key, "") 74 | cmd = cls.sub_info.get(name, "") 75 | func = getattr(CheckUpdate, cmd, None) 76 | if func: 77 | await add_job( 78 | func=func, 79 | trigger=IntervalTrigger( 80 | seconds=Config.CACHE_SESSION_TIMEOUT + 120, jitter=10 81 | ), 82 | args=(event,), 83 | id=make_job_id(PLUGIN_NAME, event, key), 84 | misfire_grace_time=60, 85 | job_defaults={"max_instances": 10}, 86 | replace_existing=True, 87 | kwargs={"key": key}, 88 | ) 89 | return True, f"添加 `{name}` 成功!" 90 | else: 91 | return False, "输入序号有误" 92 | -------------------------------------------------------------------------------- /app/services/subscribe/school_notice.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, Tuple 2 | 3 | from aiocqhttp import Event 4 | from nonebot import get_bot 5 | from nonebot.command import call_command 6 | 7 | from app.env import env 8 | from app.models.subscribe import SubUser 9 | 10 | from . import BaseSub 11 | 12 | rsshub_url: str = env("RSSHUB_URL", "").rstrip("/") 13 | 14 | 15 | class SchoolNoticeSub(BaseSub): 16 | PREFIX = "r" 17 | sub_info = { 18 | "教务处新闻": "/swust/jwc/news", 19 | "教务处通知 创新创业教育": "/swust/jwc/notice/1", 20 | "教务处通知 学生学业": "/swust/jwc/notice/2", 21 | "教务处通知 建设与改革": "/swust/jwc/notice/3", 22 | "教务处通知 教学质量保障": "/swust/jwc/notice/4", 23 | "教务处通知 教学运行": "/swust/jwc/notice/5", 24 | "教务处通知 教师教学": "/swust/jwc/notice/6", 25 | "计科学院通知 新闻动态": "/swust/cs/1", 26 | "计科学院通知 学术动态": "/swust/cs/2", 27 | "计科学院通知 通知公告": "/swust/cs/3", 28 | "计科学院通知 教研动态": "/swust/cs/4", 29 | } 30 | inv_sub_info = {v: k for k, v in sub_info.items()} 31 | 32 | @classmethod 33 | def get_subs(cls) -> Dict[str, str]: 34 | return cls.dct() 35 | 36 | @classmethod 37 | async def get_user_sub(cls, event: Event) -> dict: 38 | result = {} 39 | subs = await SubUser.get_user_subs(event) 40 | if subs: 41 | for sub in subs: 42 | link = sub.link.replace(rsshub_url, "") 43 | name = cls.inv_sub_info.get(link, "") 44 | key = cls.inv_dct().get(name) 45 | if not key: 46 | continue 47 | result[key] = name 48 | return result 49 | 50 | @classmethod 51 | async def del_sub(cls, event: Event, key: str) -> Tuple[bool, str]: 52 | try: 53 | name = cls.dct().get(key, "") 54 | link = cls.sub_info.get(name) 55 | subs = await SubUser.get_user_subs(event) 56 | for sub in subs: 57 | sub_link = sub.link.replace(rsshub_url, "") 58 | if sub_link == link: 59 | await SubUser.remove_sub(event, sub.link) 60 | return True, f"{sub.sub_content.name} 删除成功" 61 | return False, "你没有这个订阅哦" 62 | except Exception: 63 | return False, "出了点问题,请稍后再试吧" 64 | 65 | @classmethod 66 | def _make_url(cls, key: str) -> Optional[str]: 67 | name = cls.dct().get(key, "") 68 | _url = cls.sub_info.get(name) 69 | if _url: 70 | return rsshub_url + _url 71 | return None 72 | 73 | @classmethod 74 | async def add_sub(cls, event: Event, key: str) -> Tuple[bool, str]: 75 | url = cls._make_url(key) 76 | name = cls.dct().get(key, "") 77 | if url: 78 | await call_command( 79 | get_bot(), 80 | event, 81 | ("rss", "add"), 82 | args={"url": url, "silent": True}, 83 | disable_interaction=True, 84 | ) 85 | return True, f"订阅 {name} 成功" 86 | else: 87 | return False, "序号不存在" 88 | -------------------------------------------------------------------------------- /app/services/subscribe/wrapper.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from aiocqhttp import Event 4 | from nonebot import get_bot 5 | 6 | from .dean import DeanSub 7 | from .school_notice import SchoolNoticeSub 8 | 9 | 10 | def judge_sub(key: str): 11 | if key.startswith(DeanSub.PREFIX): 12 | return DeanSub 13 | if key.startswith(SchoolNoticeSub.PREFIX): 14 | return SchoolNoticeSub 15 | 16 | 17 | class SubWrapper: 18 | bot = get_bot() 19 | 20 | @classmethod 21 | def get_subs(cls) -> Dict[str, str]: 22 | result = {} 23 | result.update(SchoolNoticeSub.get_subs()) 24 | result.update(DeanSub.get_subs()) 25 | return result 26 | 27 | @classmethod 28 | async def get_user_sub(cls, event: Event) -> dict: 29 | result = {} 30 | result.update(await SchoolNoticeSub.get_user_sub(event)) 31 | result.update(await DeanSub.get_user_sub(event)) 32 | return result 33 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/app/utils/__init__.py -------------------------------------------------------------------------------- /app/utils/api.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import List, Optional, Tuple 3 | 4 | from ..env import env 5 | 6 | 7 | def true_ret(data=None, msg="success"): 8 | return {"code": 200, "data": data, "msg": msg} 9 | 10 | 11 | def false_ret(data=None, msg="fail", code=-1): 12 | return {"code": code, "data": data, "msg": msg} 13 | 14 | 15 | def check_args(**kwargs) -> Tuple[bool, Optional[List[str]]]: 16 | msg_list = [] 17 | for k, v in kwargs.items(): 18 | if v is None: 19 | msg = f"Missing arg: {k}" 20 | msg_list.append(msg) 21 | if msg_list: 22 | return False, msg_list 23 | return True, None 24 | 25 | 26 | def to_token(_qq) -> str: 27 | qq: str = str(_qq) 28 | key = env("SECRET").encode(encoding="utf8") 29 | inner = hashlib.md5() 30 | inner.update(qq.encode()) 31 | outer = hashlib.md5() 32 | outer.update(inner.hexdigest().encode() + key) 33 | return outer.hexdigest() 34 | -------------------------------------------------------------------------------- /app/utils/bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiocqhttp import Event 4 | from nonebot import get_bot 5 | from nonebot.command import kill_current_session 6 | from nonebot.message import Message, handle_message 7 | from app.utils.api import to_token 8 | from urllib.parse import urlencode 9 | from base64 import b64encode 10 | from app.config import Config 11 | 12 | 13 | def ctx_id2event(ctx_id: str): 14 | if ctx_id.startswith("/group/"): 15 | return Event(group_id=ctx_id.replace("/group/", "").split("/")[0]) 16 | if ctx_id.startswith("/discuss/"): 17 | return Event(discuss_id=ctx_id.replace("/discuss/", "").split("/")[0]) 18 | if ctx_id.startswith("/user/"): 19 | return Event(user_id=ctx_id.replace("/user/", "").split("/")[0]) 20 | return Event() 21 | 22 | 23 | async def send_msgs(event: Event, msgs): 24 | bot = get_bot() 25 | if not msgs: 26 | return 27 | for msg in msgs: 28 | await bot.send(event, msg) 29 | 30 | 31 | def qq2event(qq: int): 32 | return Event( 33 | user_id=qq, 34 | message_type="private", 35 | post_type="message", 36 | sub_type="friend", 37 | to_me=True, 38 | ) 39 | 40 | 41 | def get_user_center(event: Event): 42 | sender_qq = event.user_id 43 | 44 | if sender_qq: 45 | token = to_token(sender_qq) 46 | # web 登录界面地址 47 | query: str = urlencode({"qq": sender_qq, "token": token}) 48 | encoded_query = b64encode(query.encode("utf8")).decode("utf8") 49 | return f"{Config.WEB_URL}/user/?{encoded_query}" 50 | 51 | 52 | def replace_event_msg(event: Event, msg: str): 53 | new_event = Event.from_payload(event) 54 | new_event["message"] = Message(msg) 55 | new_event["raw_message"] = msg 56 | return new_event 57 | 58 | 59 | async def switch_session(event, msg): 60 | bot = get_bot() 61 | kill_current_session(event) 62 | new_event = replace_event_msg(event, msg) 63 | await bot.send(new_event, "re: " + msg) 64 | event["to_me"] = True 65 | asyncio.ensure_future(handle_message(bot, new_event)) 66 | -------------------------------------------------------------------------------- /app/utils/rss.py: -------------------------------------------------------------------------------- 1 | import arrow 2 | import feedparser 3 | import httpx 4 | 5 | from app.config import Config 6 | 7 | 8 | async def get_rss_info(url: str): 9 | async with httpx.AsyncClient(timeout=Config.SUBSCIBE_INTERVAL) as client: 10 | response = await client.get(url) 11 | response.raise_for_status() 12 | return feedparser.parse(response.text or "") 13 | 14 | 15 | def diff(new, old) -> list: 16 | new_items = new.get("entries", []) 17 | old_items = old.get("entries", []) 18 | result = [] 19 | 20 | for new in new_items: 21 | flag = 0 22 | for old in old_items: 23 | if new["link"] == old["link"]: 24 | flag = 1 25 | break 26 | if flag == 0: 27 | result.append(new) 28 | return result 29 | 30 | 31 | def mk_msg_content(content, diffs: list): 32 | msg_list = [] 33 | for item in diffs: 34 | msg = "【" + content.feed.title + "】有更新:\n----------------------\n" 35 | 36 | msg = msg + "标题:" + item["title"] + "\n" 37 | msg = msg + "链接:" + item["link"] + "\n" 38 | 39 | try: 40 | msg = ( 41 | msg 42 | + "日期:" 43 | + arrow.get(item["published_parsed"]) 44 | .shift(hours=8) 45 | .format("YYYY-MM-DD HH:mm:ss") 46 | ) 47 | except BaseException: 48 | msg = msg + "日期:" + arrow.now("Asia/Shanghai").format("YYYY-MM-DD HH:mm:ss") 49 | msg_list.append(msg) 50 | return msg_list 51 | -------------------------------------------------------------------------------- /app/utils/str_.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | 5 | def random_string( 6 | length: int = 16, chars: str = string.ascii_letters + string.digits 7 | ) -> str: 8 | return "".join(random.choices(chars, k=length)) 9 | -------------------------------------------------------------------------------- /app/utils/tools.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | import httpx 5 | from loguru import logger 6 | from werkzeug.utils import find_modules, import_string 7 | 8 | 9 | isUrl = re.compile(r"^https?:\/\/") 10 | 11 | 12 | async def dwz(url: str) -> Optional[str]: 13 | if not isUrl.match(url): 14 | logger.error("请输入正常的 url") 15 | return 16 | 17 | dwz_url = "http://sa.sogou.com/gettiny?={}" 18 | 19 | data = {"url": url} 20 | async with httpx.AsyncClient() as client: 21 | r: httpx.Response = await client.get(dwz_url, params=data) 22 | return r.text 23 | 24 | 25 | def load_modules(path): 26 | """引入路径下所有包 27 | """ 28 | for model in find_modules(path): 29 | try: 30 | import_string(model) 31 | logger.info(f'Load model: "{model}"') 32 | except Exception as e: 33 | logger.error(f'Failed to Load "{model}", error: {e}') 34 | logger.exception(e) 35 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | cqhttp: 4 | image: richardchien/cqhttp:latest 5 | container_name: "iswust_cqhttp" 6 | volumes: 7 | - ./data/coolq:/home/user/coolq # 用于保存COOLQ文件的目录 8 | ports: 9 | - ${CQHTTP_PORT}:9000 10 | environment: 11 | - TZ=Asia/Shanghai 12 | - COOLQ_ACCOUNT=${COOLQ_ACCOUNT} # 指定要登陆的QQ号,用于自动登录 13 | - VNC_PASSWD=${VNC_PASSWD} # 指定要登陆的QQ号,用于自动登录 14 | - FORCE_ENV=true 15 | - CQHTTP_USE_HTTP=false 16 | - CQHTTP_USE_WS=false 17 | - CQHTTP_USE_WS_REVERSE=true 18 | - CQHTTP_WS_REVERSE_API_URL=ws://nonebot:${PORT}/ws/api/ 19 | - CQHTTP_WS_REVERSE_EVENT_URL=ws://nonebot:${PORT}/ws/event/ 20 | depends_on: 21 | - nonebot 22 | 23 | nonebot: 24 | build: . 25 | image: iswust_nonebot 26 | container_name: "iswust_nonebot" 27 | expose: 28 | - ${PORT} 29 | ports: 30 | - ${PORT}:${PORT} 31 | environment: 32 | - TZ=Asia/Shanghai 33 | volumes: 34 | - .:/qbot 35 | - ./data/coolq:/coolq 36 | command: quart run --host ${HOST} --port ${PORT} 37 | depends_on: 38 | - database 39 | - redis 40 | 41 | redis: 42 | container_name: "iswust_redis" 43 | image: "redis:alpine" 44 | expose: 45 | - 6379 46 | ports: 47 | - ${REDIS_PORT}:6379 48 | volumes: 49 | - redis_data:/data 50 | entrypoint: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} 51 | environment: 52 | - TZ=Asia/Shanghai 53 | sysctls: 54 | net.core.somaxconn: "888" 55 | 56 | database: 57 | image: postgres 58 | container_name: "iswust_postgres" 59 | expose: 60 | - 5432 61 | ports: 62 | - ${DATABASE_PORT}:5432 63 | environment: 64 | - TZ=Asia/Shanghai 65 | - POSTGRES_USER=${POSTGRES_USER} 66 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 67 | - POSTGRES_DB=${POSTGRES_DB} 68 | volumes: 69 | - database_data:/var/lib/postgresql/data/ 70 | 71 | volumes: 72 | database_data: {} 73 | redis_data: {} 74 | -------------------------------------------------------------------------------- /docs/.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD026": { 4 | "punctuation": ".,;:!。,;:!" 5 | }, 6 | "MD007": { 7 | "indent": 2 8 | }, 9 | "no-hard-tabs": false, 10 | "line-length": false, 11 | "no-inline-html": false 12 | } 13 | -------------------------------------------------------------------------------- /docs/.vuepress/README.md: -------------------------------------------------------------------------------- 1 | # 声明 2 | 3 | 文档中的 ChatMessage 参考自 [koishijs 的文档](https://github.com/koishijs/koishijs.github.io/tree/docs/.vuepress/components)。 4 | -------------------------------------------------------------------------------- /docs/.vuepress/components/ChatMessage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 67 | 68 | 148 | -------------------------------------------------------------------------------- /docs/.vuepress/components/PanelView.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | 24 | 86 | -------------------------------------------------------------------------------- /docs/.vuepress/components/Terminal.vue: -------------------------------------------------------------------------------- 1 | 184 | 185 | 266 | -------------------------------------------------------------------------------- /docs/.vuepress/components/WindowJitte.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 58 | 59 | 81 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | host: "localhost", 3 | title: "小科 - QQ 教务机器人", 4 | description: "QQ 教务机器人", 5 | markdown: { 6 | lineNumbers: true 7 | }, 8 | plugins: [ 9 | [ 10 | "@vuepress/register-components", 11 | { 12 | componentDir: "components" 13 | } 14 | ] 15 | ], 16 | head: [ 17 | ["link", { rel: "icon", href: `/logo.png` }], 18 | ["meta", { name: "theme-color", content: "#ffffff" }] 19 | ], 20 | themeConfig: { 21 | repo: "BudyLab/iswust_bot", 22 | docsDir: "docs", 23 | editLinks: true, 24 | editLinkText: "在 GitHub 上编辑此页", 25 | lastUpdated: "上次更新", 26 | activeHeaderLinks: false, 27 | nav: [ 28 | { text: "使用", link: "/guide/" }, 29 | { 30 | text: "部署", 31 | link: "/deploy/" 32 | } 33 | ], 34 | sidebar: { 35 | "/guide/": [ 36 | { 37 | title: "开始使用", 38 | collapsable: true, 39 | children: [""] 40 | }, 41 | { 42 | title: "教务相关", 43 | collapsable: false, 44 | children: [ 45 | "bind", 46 | "course_schedule", 47 | "credit", 48 | "ecard", 49 | "score", 50 | "subscribe" 51 | ] 52 | }, 53 | { 54 | title: "效率相关", 55 | collapsable: false, 56 | children: ["remind", "code_runner", "rss"] 57 | }, 58 | { 59 | title: "其他", 60 | collapsable: false, 61 | children: ["hitokoto"] 62 | } 63 | ], 64 | "/deploy/": [ 65 | { 66 | title: "总览", 67 | collapsable: false, 68 | children: ["", "update"] 69 | }, 70 | { 71 | title: "配置项", 72 | collapsable: false, 73 | children: ["set-env", "set-qq"] 74 | } 75 | ] 76 | } 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /docs/.vuepress/public/CNAME: -------------------------------------------------------------------------------- 1 | bot.artin.li 2 | -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/docs/.vuepress/public/favicon.ico -------------------------------------------------------------------------------- /docs/.vuepress/public/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/docs/.vuepress/public/logo.jpg -------------------------------------------------------------------------------- /docs/.vuepress/styles/index.styl: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "Open Sans", "Helvetica Neue", "Noto Sans CJK SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", "Wenquanyi Micro Hei", "WenQuanYi Zen Hei", sans-serif; 3 | } 4 | 5 | main.home > header.hero > img { 6 | max-width: 100%; 7 | max-height: 220px; 8 | display: block; 9 | margin: 3rem auto 1.5rem; 10 | } 11 | -------------------------------------------------------------------------------- /docs/.vuepress/styles/palette.styl: -------------------------------------------------------------------------------- 1 | $accentColor = #d32f2f 2 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | heroImage: /logo.jpg 4 | heroText: 小科 5 | actionText: 开始使用 → 6 | actionLink: /guide/ 7 | features: 8 | - title: 省心至上 9 | details: 你关心的,我都可以推送给你。 10 | - title: 自然语言处理驱动 11 | details: 享受自然语言的便利。 12 | - title: 高性能 13 | details: 采用异步 I/O 以获得极高的性能。 14 | footer: MIT Licensed | Copyright © 2020 BudyLab 15 | --- 16 | -------------------------------------------------------------------------------- /docs/deploy/README.md: -------------------------------------------------------------------------------- 1 | # 部署 2 | 3 | ## 配置环境变量 4 | 5 | 复制一份 `.env.example` 重命名为 `.env`,并修改里面的内容。 6 | 7 | 复制一份 `.quartenv.example` 重命名为 `.quartenv`,并修改里面的内容。 8 | 9 | 各字段的意义文件内都有,也可在侧边栏配置项中查看详细信息。 10 | 11 | ## 启动 12 | 13 | 需要先[配置环境变量](#配置环境变量)。 14 | 需要先[配置环境变量](#配置环境变量)。 15 | 需要先[配置环境变量](#配置环境变量)。 16 | 17 | 1. 首先 build 镜像: 18 | 19 | ```sh 20 | docker-compose build 21 | ``` 22 | 23 | 需要等执行完,build 阶段需要使用 pip 安装各种包比较慢。 24 | 25 | 2. 创建\更新 数据库结构: 26 | 27 | ```sh 28 | docker-compose run --rm nonebot alembic upgrade head 29 | ``` 30 | 31 | 3. 然后运行: 32 | 33 | ```sh 34 | docker-compose up -d 35 | ``` 36 | 37 | 然后浏览器打开地址 `localhost:${CQHTTP_PORT}` 进入 `noVNC`,这个 `CQHTTP_PORT` 是你配置在 `.env` 里的。 38 | 输入你配置在 `.env` 内的密码,然后登录你的 QQ 小号即可。 39 | 40 | 添加 QQ 小号为好友即可使用各种[功能](/guide/)。 41 | -------------------------------------------------------------------------------- /docs/deploy/set-env.md: -------------------------------------------------------------------------------- 1 | # 环境变量配置项 2 | -------------------------------------------------------------------------------- /docs/deploy/set-qq.md: -------------------------------------------------------------------------------- 1 | # 机器人配置项 2 | -------------------------------------------------------------------------------- /docs/deploy/update.md: -------------------------------------------------------------------------------- 1 | # 更新 2 | 3 | 想更新的时候执行: 4 | 5 | ```sh 6 | docker-compose pull # 拉取依赖的镜像 7 | docker-compose build # 重新打包 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/guide/README.md: -------------------------------------------------------------------------------- 1 | # 开始使用 2 | 3 | 你可以对我发送各种消息: 4 | 5 | 6 | echo 你好 7 | 你好 8 | 绑定教务处 9 | 开始请求绑定~ 请等待 10 | 请点击链接绑定:https://example.com 11 | 12 | 13 | ::: tip 提示 14 | 需要先添加小科为好友~ 15 | ::: 16 | -------------------------------------------------------------------------------- /docs/guide/bind.md: -------------------------------------------------------------------------------- 1 | # 绑定教务处 2 | 3 | 4 | 绑定教务处 5 | 开始请求绑定~ 请等待 6 | 请点击链接绑定:https://example.com 7 | 8 | 9 | - __plugin_name:__ 绑定教务处 10 | - __plugin_short_description:__ 命令:bind/unbind 11 | - __plugin_usage:__ 12 | - 对我发以下关键词绑定教务处: 13 | - bind 14 | - 绑定 15 | - 绑定教务处 16 | 17 | - 取消绑定教务处向我发送以下指令。 18 | - unbind 19 | - 取消绑定 20 | - 解绑 21 | -------------------------------------------------------------------------------- /docs/guide/code_runner.md: -------------------------------------------------------------------------------- 1 | # 运行代码 2 | 3 | 7 | 8 | 9 | run python
print("Hello 小科") 10 |
11 | 正在运行,请稍等…… 12 | stdout:
Hello 小科
13 |
14 | 15 | - __plugin_name:__ 运行代码 16 | - __plugin_short_description:__ 命令: run 17 | - __plugin_usage:__ 18 | - 运行代码可以输入以下命令,然后根据提示输入即可: 19 | - code_runner 20 | - run 21 | - 执行代码 22 | - 运行 23 | - 运行代码 24 | 25 | 你也可以直接在后面加上相关内容,如: 26 | 27 | ```py 28 | run python 29 | print(1234) 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/guide/course_schedule.md: -------------------------------------------------------------------------------- 1 | # 查询/更新 课表 2 | 3 | 4 | 明天课表 5 | 2020-05-28,第15周,星期四 6 | 7 | 8 | 星期四的课程如下: 9 | 上午第一讲 10 |   -  图形图像新技术专题讲座(XXX) 11 |   -  东1304 12 | 13 | 14 | 课表抓取时间:2020-05-22 13:48:25 15 | 16 | 17 | - __plugin_name:__ 查询/更新 课表 18 | 19 | - __plugin_short_description:__ 命令:cs/uc 20 | 21 | - __plugin_usage:__ 22 | - 查询课表输入: 23 | - cs 24 | - 查询课表 25 | - 或者加上时间限定: 26 | - 今天课表 27 | - 明天有什么课 28 | - 九月十五号有什么课 29 | 30 | - 更新课表可以输入: 31 | - uc 32 | - 更新课表 33 | -------------------------------------------------------------------------------- /docs/guide/credit.md: -------------------------------------------------------------------------------- 1 | # 查询绩点 2 | 3 | 4 | 绩点 5 | 正在抓取绩点,抓取过后我会直接发给你! 6 | 7 | 8 | 总学分: 139 9 | 必修课: 105.5 10 | 选修课: 20.5 11 | 学位课: 44.5 12 | 全校通选: 13 13 | 体育类: 4 14 | 平均绩点: 3.637 15 | 必修课绩点: 3.612 16 | 学位课绩点: 4.064 17 | 18 | 19 | 20 | 21 | __plugin_name__ = "绩点" 22 | __plugin_short_description__ = "命令:credit" 23 | __plugin_usage__ = r"""查看我的绩点: 24 | 命令: 25 | - credit 26 | - 绩点 27 | - 我的绩点 28 | """.strip() 29 | -------------------------------------------------------------------------------- /docs/guide/ecard.md: -------------------------------------------------------------------------------- 1 | # 饭卡余额 2 | 3 | ::: warning Warning 4 | 暂时无法使用 5 | ::: 6 | -------------------------------------------------------------------------------- /docs/guide/hitokoto.md: -------------------------------------------------------------------------------- 1 | # 一言 2 | 3 | 4 | 一言 5 | 这里有嬉笑怒骂,柴米油盐,人间戏梦,滚滚红尘。 6 | 7 | 8 | - __plugin_name:__ 一言 9 | - __plugin_short_description:__ 命令:hi 10 | - __plugin_usage:__ 11 | 给你回复一句话 12 | -------------------------------------------------------------------------------- /docs/guide/remind.md: -------------------------------------------------------------------------------- 1 | # ⏰ 提醒 2 | 3 | 4 | 今晚八点半提醒我跑步 5 | 6 | 7 | 好哦!我会在2020-05-27 20:30:00准时叫你跑步~ 8 | 9 | 提醒创建成功: 10 | \> 提醒时间:2020-05-27 20:30:00 11 | \> 内容:跑步 12 | 13 | 14 | 15 |
20:30:00
16 | 17 | 18 | 19 | 20 | 提醒通知: 21 | 你定下的提醒时间已经到啦!快跑步吧! 22 | 23 | 24 |
25 | 26 | 27 | 新建提醒 28 | 你想让我提醒什么内容呢?语句命令都可,输入 `取消、不` 等来取消 29 | 今天课表 30 | 你希望我在每天的什么时候给你提醒呢? 31 | 早上七点半 32 | 添加提醒成功啦,下次提醒时间 2020-05-28 07:30 33 | 34 | 35 | - __plugin_name:__ ⏰ 提醒 36 | - __plugin_short_description:__ [原 push]命令:remind 37 | - __plugin_usage:__ 38 | - 新建提醒输入: 39 | - remind 40 | - 提醒 41 | - 添加提醒 42 | - 新增提醒 43 | - 新建提醒 44 | 45 | - 查看提醒可以输入: 46 | - remind.show 47 | - 查看提醒 48 | - 我的提醒 49 | - 提醒列表 50 | 51 | - 删除提醒: 52 | - remind.rm 53 | - 取消提醒 54 | - 停止提醒 55 | - 关闭提醒 56 | - 删除提醒 57 | -------------------------------------------------------------------------------- /docs/guide/rss.md: -------------------------------------------------------------------------------- 1 | # 订阅 RSS 2 | 3 | 4 | rss.add https://rsshub.app/gov/miit/wjgs 5 | 中国工业化和信息部 订阅成功~ 6 |
某时某刻
7 | 8 | 9 | 【中国工业化和信息部】有更新: 10 | \-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\- 11 | 标题:标题 12 | 链接:http://example.com 13 | 日期:2020-XX-XX XX:XX:XX 14 | 15 | 16 | 17 |
18 | -------------------------------------------------------------------------------- /docs/guide/score.md: -------------------------------------------------------------------------------- 1 | # 查询成绩 2 | 3 | 4 | 成绩 5 | 正在抓取成绩,抓取过后我会直接发给你! 6 | 7 | 8 | 四六级成绩: 9 | \--------- 10 | <-- 此处省略内容 --> 11 | 12 | 13 | 14 | 15 | 16 | 全校通选课: 17 | \--------- 18 | <-- 此处省略内容 --> 19 | 20 | 21 | 22 | 23 | 24 | 体育成绩: 25 | \--------- 26 | <-- 此处省略内容 --> 27 | 28 | 29 | 30 | 31 | 32 | 2017-2018 秋: 33 | \--------- 34 | <-- 此处省略内容 --> 35 | 36 | 37 | 38 | 39 | 40 | 2017-2018 春: 41 | \--------- 42 | <-- 此处省略内容 --> 43 | 44 | 45 | 46 | 47 | 48 | - __plugin_name:__ 查询成绩 49 | - __plugin_short_description:__ 命令:score 50 | - __plugin_usage:__ 51 | - 输入 查询成绩/成绩 52 | -------------------------------------------------------------------------------- /docs/guide/subscribe.md: -------------------------------------------------------------------------------- 1 | # 订阅 2 | 3 | ::: tip 提示 4 | 这部分可以在 **个人中心网页版** 操作哦~ 5 | ::: 6 | 7 | 8 | 订阅 9 | 10 | 11 | 你想订阅什么内容呢?(请输入序号,也可输入 \`取消、不\` 等语句取消): 12 | r0. 教务处新闻 13 | r1. 教务处通知 创新创业教育 14 | r2. 教务处通知 学生学业 15 | <-- 此处省略 --> 16 | r10. 计科学院通知 教研动态 17 | s0. 有新成绩出来时提醒我 18 | s1. 有新考试出来提醒我 19 | 20 | 21 | r3 22 | 西南科技大学教务处 建设与改革 订阅成功~ 23 |
某时某刻
24 | 25 | 26 | 【西南科技大学教务处 建设与改革】有更新: 27 | \-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\- 28 | 标题:标题 29 | 链接:http://example.com 30 | 日期:2020-XX-XX XX:XX:XX 31 | 32 | 33 |
34 | 35 | 36 | - __plugin_name:__ 订阅 37 | - __plugin_short_description:__ 订阅 通知/成绩/考试 等,命令: subscribe 38 | - __plugin_usage:__ 39 | - 添加订阅: 40 | - 订阅 41 | - 添加订阅 42 | - 新建订阅 43 | - subscribe 44 | 然后会提示输入序号,你也可以直接在后面加上序号,如: 45 | - 订阅 1 46 | - 查看订阅: 47 | - 查看订阅 48 | - 订阅列表 49 | - subscribe show 50 | 51 | - 移除订阅: 52 | - 移除订阅 53 | - 取消订阅 54 | - 停止订阅 55 | - 删除订阅 56 | - subscribe rm 57 | 然后会提示输入序号,你也可以直接在后面加上序号,如: 58 | - 移除订阅 1 59 | - 移除订阅 all 60 | -------------------------------------------------------------------------------- /log.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from nonebot.log import logger as nblogger 4 | from logging import handlers, Formatter 5 | 6 | _dir_name = "logs" 7 | if not os.path.exists(_dir_name): 8 | os.mkdir(_dir_name) 9 | log_dir = Path(_dir_name) 10 | 11 | # 往文件里写入 指定间隔时间自动生成文件的处理器 12 | # 实例化TimedRotatingFileHandler 13 | # interval是时间间隔,backupCount是备份文件的个数,如果超过这个个数,就会自动删除 14 | # when是间隔的时间单位,单位有以下几种: 15 | # S 秒 M 分 H 小时、 D 天、 W 每星期(interval==0时代表星期一) 16 | fh = handlers.TimedRotatingFileHandler( 17 | filename=str(log_dir / "nonebot.log"), when="D", backupCount=30, encoding="utf-8", 18 | ) 19 | fh.setFormatter( 20 | Formatter( 21 | "[%(asctime)s %(name)s] %(levelname)s: [%(filename)s %(funcName)s] > %(message)s", 22 | "%Y-%m-%d %H:%M:%S", 23 | ) 24 | ) 25 | nblogger.addHandler(fh) 26 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import sys 3 | 4 | sys.path.append(str(pathlib.Path(__file__).parent.parent.resolve())) 5 | from logging.config import fileConfig 6 | 7 | from alembic import context 8 | from sqlalchemy import engine_from_config, pool 9 | 10 | from app.env import load_env 11 | 12 | load_env() 13 | from app.config import get_database_url 14 | from app.libs.gino import db 15 | from app.utils.tools import load_modules 16 | 17 | # this is the Alembic Config object, which provides 18 | # access to the values within the .ini file in use. 19 | config = context.config 20 | 21 | # Interpret the config file for Python logging. 22 | # This line sets up loggers basically. 23 | fileConfig(config.config_file_name) 24 | 25 | # add your model's MetaData object here 26 | # for 'autogenerate' support 27 | 28 | 29 | config.set_main_option("sqlalchemy.url", str(get_database_url())) 30 | 31 | load_modules("app.models") 32 | target_metadata = db 33 | 34 | 35 | def run_migrations_offline(): 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, 50 | target_metadata=target_metadata, 51 | literal_binds=True, 52 | dialect_opts={"paramstyle": "named"}, 53 | ) 54 | 55 | with context.begin_transaction(): 56 | context.run_migrations() 57 | 58 | 59 | def run_migrations_online(): 60 | """Run migrations in 'online' mode. 61 | 62 | In this scenario we need to create an Engine 63 | and associate a connection with the context. 64 | 65 | """ 66 | connectable = engine_from_config( 67 | config.get_section(config.config_ini_section), 68 | prefix="sqlalchemy.", 69 | poolclass=pool.NullPool, 70 | ) 71 | 72 | with connectable.connect() as connection: 73 | context.configure(connection=connection, target_metadata=target_metadata) 74 | 75 | with context.begin_transaction(): 76 | context.run_migrations() 77 | 78 | 79 | if context.is_offline_mode(): 80 | run_migrations_offline() 81 | else: 82 | run_migrations_online() 83 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/4101e812bbb4_fix_score.py: -------------------------------------------------------------------------------- 1 | """fix score 2 | 3 | Revision ID: 4101e812bbb4 4 | Revises: ead93f48a985 5 | Create Date: 2020-05-18 00:09:17.573527 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "4101e812bbb4" 14 | down_revision = "ead93f48a985" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column( 22 | "score_cet", sa.Column("student_id", sa.String(length=32), nullable=False) 23 | ) 24 | op.create_foreign_key( 25 | op.f("fk_score_cet_student_id_user"), 26 | "score_cet", 27 | "user", 28 | ["student_id"], 29 | ["student_id"], 30 | onupdate="CASCADE", 31 | ondelete="SET NULL", 32 | ) 33 | op.alter_column("score_cet", "level", existing_type=sa.Float, type_=sa.String(16)) 34 | op.alter_column("score_cet", "total", existing_type=sa.Integer, type_=sa.String(16)) 35 | op.alter_column( 36 | "score_cet", "listen", existing_type=sa.Integer, type_=sa.String(16) 37 | ) 38 | op.alter_column("score_cet", "read", existing_type=sa.Integer, type_=sa.String(16)) 39 | op.alter_column("score_cet", "write", existing_type=sa.Integer, type_=sa.String(16)) 40 | op.alter_column( 41 | "score_cet", "common", existing_type=sa.Integer, type_=sa.String(16) 42 | ) 43 | 44 | op.add_column( 45 | "score_physic_or_common", sa.Column("cata", sa.String(length=16), nullable=True) 46 | ) 47 | op.add_column( 48 | "score_physic_or_common", 49 | sa.Column("student_id", sa.String(length=32), nullable=False), 50 | ) 51 | op.alter_column( 52 | "score_physic_or_common", "credit", existing_type=sa.Float, type_=sa.String(16) 53 | ) 54 | op.alter_column( 55 | "score_physic_or_common", "gpa", existing_type=sa.Float, type_=sa.String(16) 56 | ) 57 | op.create_foreign_key( 58 | op.f("fk_score_physic_or_common_student_id_user"), 59 | "score_physic_or_common", 60 | "user", 61 | ["student_id"], 62 | ["student_id"], 63 | onupdate="CASCADE", 64 | ondelete="SET NULL", 65 | ) 66 | op.add_column( 67 | "score_plan", sa.Column("student_id", sa.String(length=32), nullable=False) 68 | ) 69 | op.create_foreign_key( 70 | op.f("fk_score_plan_student_id_user"), 71 | "score_plan", 72 | "user", 73 | ["student_id"], 74 | ["student_id"], 75 | onupdate="CASCADE", 76 | ondelete="SET NULL", 77 | ) 78 | op.alter_column("score_plan", "credit", existing_type=sa.Float, type_=sa.String(16)) 79 | op.alter_column("score_plan", "gpa", existing_type=sa.Float, type_=sa.String(16)) 80 | # ### end Alembic commands ### 81 | 82 | 83 | def downgrade(): 84 | # ### commands auto generated by Alembic - please adjust! ### 85 | op.drop_constraint( 86 | op.f("fk_score_plan_student_id_user"), "score_plan", type_="foreignkey" 87 | ) 88 | op.drop_column("score_plan", "student_id") 89 | op.drop_constraint( 90 | op.f("fk_score_physic_or_common_student_id_user"), 91 | "score_physic_or_common", 92 | type_="foreignkey", 93 | ) 94 | op.drop_column("score_physic_or_common", "student_id") 95 | op.drop_column("score_physic_or_common", "cata") 96 | op.drop_constraint( 97 | op.f("fk_score_cet_student_id_user"), "score_cet", type_="foreignkey" 98 | ) 99 | op.drop_column("score_cet", "student_id") 100 | op.alter_column("score_cet", "level", existing_type=sa.String(16), type_=sa.Float) 101 | op.alter_column("score_cet", "total", existing_type=sa.String(16), type_=sa.Integer) 102 | op.alter_column( 103 | "score_cet", "listen", existing_type=sa.String(16), type_=sa.Integer 104 | ) 105 | op.alter_column("score_cet", "read", existing_type=sa.String(16), type_=sa.Integer) 106 | op.alter_column("score_cet", "write", existing_type=sa.String(16), type_=sa.Integer) 107 | op.alter_column( 108 | "score_cet", "common", existing_type=sa.String(16), type_=sa.Integer 109 | ) 110 | op.alter_column( 111 | "score_physic_or_common", "credit", existing_type=sa.String(16), type_=sa.Float 112 | ) 113 | op.alter_column( 114 | "score_physic_or_common", "gpa", existing_type=sa.String(16), type_=sa.Float 115 | ) 116 | op.alter_column("score_plan", "credit", existing_type=sa.String(16), type_=sa.Float) 117 | op.alter_column("score_plan", "gpa", existing_type=sa.String(16), type_=sa.Float) 118 | # ### end Alembic commands ### 119 | -------------------------------------------------------------------------------- /migrations/versions/ead93f48a985_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: ead93f48a985 4 | Revises: 5 | Create Date: 2020-05-16 15:46:25.500952 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ead93f48a985' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('chat_records', 22 | sa.Column('create_at', sa.DateTime(), nullable=True), 23 | sa.Column('update_at', sa.DateTime(), nullable=True), 24 | sa.Column('id', sa.Integer(), nullable=False), 25 | sa.Column('self_id', sa.String(length=32), nullable=True), 26 | sa.Column('ctx_id', sa.String(length=64), nullable=True), 27 | sa.Column('msg', sa.String(), nullable=True), 28 | sa.Column('out', sa.Boolean(), nullable=True), 29 | sa.PrimaryKeyConstraint('id') 30 | ) 31 | op.create_table('score_cet', 32 | sa.Column('create_at', sa.DateTime(), nullable=True), 33 | sa.Column('update_at', sa.DateTime(), nullable=True), 34 | sa.Column('exam_id', sa.String(length=32), nullable=False), 35 | sa.Column('exam_name', sa.String(length=64), nullable=True), 36 | sa.Column('level', sa.Float(), nullable=True), 37 | sa.Column('total', sa.Integer(), nullable=True), 38 | sa.Column('listen', sa.Integer(), nullable=True), 39 | sa.Column('read', sa.Integer(), nullable=True), 40 | sa.Column('write', sa.Integer(), nullable=True), 41 | sa.Column('common', sa.Integer(), nullable=True), 42 | sa.PrimaryKeyConstraint('exam_id') 43 | ) 44 | op.create_table('score_physic_or_common', 45 | sa.Column('create_at', sa.DateTime(), nullable=True), 46 | sa.Column('update_at', sa.DateTime(), nullable=True), 47 | sa.Column('course_id', sa.String(length=16), nullable=False), 48 | sa.Column('term', sa.String(length=64), nullable=False), 49 | sa.Column('course_name', sa.String(length=64), nullable=True), 50 | sa.Column('credit', sa.Float(), nullable=True), 51 | sa.Column('score', sa.String(length=16), nullable=True), 52 | sa.Column('make_up_score', sa.String(length=16), nullable=True), 53 | sa.Column('gpa', sa.Float(), nullable=True), 54 | sa.PrimaryKeyConstraint('course_id', 'term') 55 | ) 56 | op.create_table('score_plan', 57 | sa.Column('create_at', sa.DateTime(), nullable=True), 58 | sa.Column('update_at', sa.DateTime(), nullable=True), 59 | sa.Column('course_id', sa.String(length=16), nullable=False), 60 | sa.Column('term', sa.String(length=64), nullable=False), 61 | sa.Column('course_name', sa.String(length=64), nullable=True), 62 | sa.Column('property', sa.String(length=64), nullable=True), 63 | sa.Column('credit', sa.Float(), nullable=True), 64 | sa.Column('score', sa.String(length=16), nullable=True), 65 | sa.Column('make_up_score', sa.String(length=16), nullable=True), 66 | sa.Column('gpa', sa.Float(), nullable=True), 67 | sa.Column('season', sa.String(length=16), nullable=True), 68 | sa.PrimaryKeyConstraint('course_id', 'term') 69 | ) 70 | op.create_table('sub_content', 71 | sa.Column('create_at', sa.DateTime(), nullable=True), 72 | sa.Column('update_at', sa.DateTime(), nullable=True), 73 | sa.Column('link', sa.String(length=128), nullable=False), 74 | sa.Column('name', sa.String(length=128), nullable=True), 75 | sa.Column('content', sa.LargeBinary(), nullable=True), 76 | sa.PrimaryKeyConstraint('link') 77 | ) 78 | op.create_table('user', 79 | sa.Column('create_at', sa.DateTime(), nullable=True), 80 | sa.Column('update_at', sa.DateTime(), nullable=True), 81 | sa.Column('qq', sa.String(length=16), nullable=False), 82 | sa.Column('student_id', sa.String(length=32), nullable=True), 83 | sa.Column('password', sa.String(length=64), nullable=False), 84 | sa.Column('cookies', sa.LargeBinary(), nullable=True), 85 | sa.PrimaryKeyConstraint('qq'), 86 | sa.UniqueConstraint('student_id') 87 | ) 88 | op.create_table('course_student', 89 | sa.Column('create_at', sa.DateTime(), nullable=True), 90 | sa.Column('update_at', sa.DateTime(), nullable=True), 91 | sa.Column('student_id', sa.String(length=32), nullable=False), 92 | sa.Column('course_json', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False), 93 | sa.ForeignKeyConstraint(['student_id'], ['user.student_id'], onupdate='CASCADE', ondelete='SET NULL'), 94 | sa.PrimaryKeyConstraint('student_id') 95 | ) 96 | op.create_table('sub_user', 97 | sa.Column('create_at', sa.DateTime(), nullable=True), 98 | sa.Column('update_at', sa.DateTime(), nullable=True), 99 | sa.Column('ctx_id', sa.String(length=64), nullable=False), 100 | sa.Column('link', sa.String(length=128), nullable=False), 101 | sa.Column('only_title', sa.Boolean(), nullable=True), 102 | sa.ForeignKeyConstraint(['link'], ['sub_content.link'], onupdate='CASCADE', ondelete='SET NULL'), 103 | sa.PrimaryKeyConstraint('ctx_id', 'link') 104 | ) 105 | # ### end Alembic commands ### 106 | 107 | 108 | def downgrade(): 109 | # ### commands auto generated by Alembic - please adjust! ### 110 | op.drop_table('sub_user') 111 | op.drop_table('course_student') 112 | op.drop_table('user') 113 | op.drop_table('sub_content') 114 | op.drop_table('score_plan') 115 | op.drop_table('score_physic_or_common') 116 | op.drop_table('score_cet') 117 | op.drop_table('chat_records') 118 | # ### end Alembic commands ### 119 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "scripts": { 4 | "docs:dev": "vuepress dev docs", 5 | "docs:build": "vuepress build docs" 6 | }, 7 | "devDependencies": { 8 | "vuepress": "^1.5.0", 9 | "watchpack": "^1.6.1" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "iswust_nonebot" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Artin "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | nonebot = "^1.4.2" 10 | msgpack = "^1.0.0" 11 | gino = "^0.8.6" 12 | numpy = "^1.18.2" 13 | alembic = "^1.4.1" 14 | beautifulsoup4 = "^4.8.2" 15 | lxml = "^4.5.0" 16 | python-dotenv = "^0.12.0" 17 | auth-swust = "^1.5.0" 18 | loguru = "^0.4.1" 19 | apscheduler = "^3.6.3" 20 | arrow = "^0.15.5" 21 | ChineseTimeNLP = "^1.1.5" 22 | httpx = "^0.11.1" 23 | psycopg2-binary = "^2.8.5" 24 | redis = "^3.4.1" 25 | feedparser = "^6.0.0b3" 26 | environs = "^7.4.0" 27 | tensorflow = "2.2.0rc4" 28 | aiocache = {extras = ["redis"], version = "^0.11.1"} 29 | pandas = "^1.0.3" 30 | rapidfuzz = "^0.8.2" 31 | 32 | [tool.poetry.dev-dependencies] 33 | flake8 = "^3.7.9" 34 | black = "^19.10b0" 35 | 36 | [[tool.poetry.source]] 37 | name = "aliyun" 38 | url = "https://mirrors.aliyun.com/pypi/simple/" 39 | default = true 40 | 41 | [tool.black] 42 | line-length = 88 43 | target-version = ['py38'] 44 | include = '\.pyi?$' 45 | exclude = ''' 46 | ( 47 | /( 48 | | \.git 49 | | \.mypy_cache 50 | | \.tox 51 | | \.venv 52 | | build 53 | | dist 54 | | migrations 55 | | web 56 | )/ 57 | ) 58 | ''' 59 | 60 | [build-system] 61 | requires = ["poetry>=0.12"] 62 | build-backend = "poetry.masonry.api" 63 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from quart import Quart 2 | 3 | 4 | def create_app(mode="bot") -> Quart: 5 | import log # noqa: F401 6 | import app 7 | 8 | return app.init(mode) 9 | -------------------------------------------------------------------------------- /shelltools.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from app.libs.gino import init_db 3 | 4 | loop = asyncio.get_event_loop() 5 | loop.run_until_complete(init_db()) 6 | -------------------------------------------------------------------------------- /tests/audio_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pytest 3 | from pathlib import Path 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | sys.path.append(str(Path(".").resolve())) 9 | 10 | filename = "777D935D643B0300777D935D643B0300.silk" 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_echo(): 15 | from app.libs.qqai_async.aaiasr import echo 16 | 17 | record_dir = Path("tests") / Path("data/record") 18 | # 接口十分不稳定 无法测试 19 | result, text = await echo(filename, record_dir) 20 | assert text in ["明天课表", "system busy, please try again later"] 21 | # assert False, "dumb assert to make PyTest print my stuff" 22 | -------------------------------------------------------------------------------- /tests/data/record/777D935D643B0300777D935D643B0300.silk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/tests/data/record/777D935D643B0300777D935D643B0300.silk -------------------------------------------------------------------------------- /tests/dwz_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.asyncio 5 | async def test_dwz(): 6 | from app.utils.tools import dwz 7 | 8 | shorten_url_ = await dwz("https://www.baidu.com") 9 | assert shorten_url_ == "https://url.cn/5NzSyLv" 10 | -------------------------------------------------------------------------------- /web/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /web/.eslintrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/web/.eslintrc -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | 10 | # production 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | 16 | # umi 17 | /src/.umi 18 | /src/.umi-production 19 | /src/.umi-test 20 | /.env.local 21 | 22 | *.less.d.ts 23 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/*.svg 3 | **/*.ejs 4 | **/*.html 5 | package.json 6 | .umi 7 | .umi-production 8 | .umi-test 9 | -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 80, 5 | "overrides": [ 6 | { 7 | "files": ".prettierrc", 8 | "options": { "parser": "json" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /web/.umirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'umi'; 2 | 3 | export default defineConfig({ 4 | nodeModulesTransform: { 5 | type: 'none', 6 | }, 7 | cssModulesTypescriptLoader: {}, 8 | esbuild: {}, 9 | dynamicImport: { 10 | loading: '@/components/Loading', 11 | }, 12 | ignoreMomentLocale: true, 13 | title: '小科', 14 | routes: [ 15 | { 16 | path: '/', 17 | component: '@/pages/index', 18 | routes: [ 19 | { path: '/login', component: '@/pages/Login', title: 'Login' }, 20 | { path: '/user', component: '@/pages/UserCenter', title: 'UserCenter' }, 21 | ], 22 | }, 23 | ], 24 | define: { 25 | API_URL: process.env['API_URL'], 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # umi project 2 | 3 | ## Getting Started 4 | 5 | Install dependencies, 6 | 7 | ```bash 8 | $ yarn 9 | ``` 10 | 11 | Start the dev server, 12 | 13 | ```bash 14 | $ yarn start 15 | ``` 16 | -------------------------------------------------------------------------------- /web/mock/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kelab/iswust_bot/16d0eb0e591079dc6c87a2597234e6345467a487/web/mock/.gitkeep -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "start": "umi dev", 5 | "build": "umi build", 6 | "postinstall": "umi generate tmp", 7 | "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", 8 | "test": "umi-test", 9 | "test:coverage": "umi-test --coverage" 10 | }, 11 | "gitHooks": { 12 | "pre-commit": "lint-staged" 13 | }, 14 | "lint-staged": { 15 | "*.{js,jsx,less,md,json}": [ 16 | "prettier --write" 17 | ], 18 | "*.ts?(x)": [ 19 | "prettier --parser=typescript --write" 20 | ] 21 | }, 22 | "dependencies": { 23 | "@ant-design/icons": "^4.2.1", 24 | "@ant-design/pro-layout": "^5.0.14", 25 | "@umijs/plugin-esbuild": "^1.0.0-beta.2", 26 | "@umijs/preset-react": "1.x", 27 | "@umijs/test": "^3.2.3", 28 | "antd": "^4.3.1", 29 | "axios": "^0.19.2", 30 | "prettier": "^2.0.5", 31 | "react": "^16.13.1", 32 | "react-dom": "^16.13.1", 33 | "umi": "^3.2.3" 34 | }, 35 | "devDependencies": { 36 | "@umijs/preset-ui": "^2.1.14", 37 | "lint-staged": "^10.2.9", 38 | "yorkie": "^2.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /web/src/components/Agreement.jsx: -------------------------------------------------------------------------------- 1 | export default () => { 2 | return ( 3 |
13 | 14 | 为使用QQ教务处软件(以下简称“本软件”)及服务,用户应当阅读并遵守《用户协议》(以下简称“本协议”)。请用户务必审慎阅读、充分理解各条款内容,特别是免除或者限制责任的条款,并选择接受或不接受。限制、免责条款可能以加粗形式提示用户注意。 15 | 除非用户已阅读并接受本协议所有条款,否则用户无权使用本软件及相关服务。用户的使用、登录等行为即视为用户已阅读并同意上述协议的约束。 16 | 17 |
    18 |
      19 | 我们承诺只在合法范围内使用您的教务处帐号,比如向您发送课表,发送新闻。 20 |
    21 |
      22 | 我们将只保存您的以下信息:学号以及密码(用来自动登录教务系统)、QQ号(用作与您交流)。 23 |
      24 | 我们还将保存您的成绩、考试、课表等信息,用来向您推送。 25 |
    26 |
      我们不会在未经您的允许的情况下使用您的个人信息。
    27 |
      我们承诺会保护您的个人信息。
    28 |
29 |

用户注意事项

30 | 用户理解并同意:为了向用户提供有效的服务,本软件会利用用户终端设备的处理器和带宽等资源。本软件使用过程中可能产生数据流量的费用,用户需自行向运营商了解相关资费信息,并自行承担相关费用。 31 | 你理解并同意:本软件会让最终用户查询到本企业或组织内其他最终用户的信息,但管理员可以通过管理权限限制最终用户的信息查阅权限。在使用本服务管理你的最终用户信息时,你应当: 32 | 充分告知最终用户使用本软件及本服务对用户信息、内容相关影响的政策规定; 33 | 确保在使用本服务过程中对用户信息、内容的使用和处理遵从可适用的法律法规的要求; 34 | 应对并处理你与最终用户就用户信息、内容相关的,或因你未完全履行该条款所产生的所有争议及纠纷,并独立承担由此而产生的一切法律责任。 35 | 用户在使用本软件某一特定服务或功能时,可能会另有单独的协议、相关业务规则等。用户使用相关服务,即视为用户接受前述协议。 36 | 用户理解并同意软件开发者将会尽其商业上的合理努力保障用户在本软件及服务中的数据存储安全,但是,开发者并不能就此提供完全保证,包括但不限于以下情形: 37 |
    38 |
      开发者不对用户在本软件及服务中相关数据的删除或储存失败负责;
    39 |
      40 | 开发者有权根据实际情况自行决定用户在本软件及服务中数据的最长储存期限,并在服务器上为其分配数据最大存储空间等。 41 |
    42 |
      43 | 如果用户停止使用本软件及服务或服务被终止、取消,开发者可以从服务器上永久地删除你的数据。服务停止、终止或取消后,开发者没有义务向用户返还任何数据。 44 |
    45 |
46 | 用户在使用本软件及服务时,须自行承担如下来自开发者不可掌控的风险内容,包括但不限于: 47 |
    48 |
      由于不可抗拒因素可能引起的用户信息丢失、泄漏等风险;
    49 |
      50 | 用户在使用本软件访问第三方网站时,因第三方网站及相关内容所可能导致的风险,由用户自行承担; 51 |
    52 |
      用户发内容被他人转发、分享,因此等传播可能带来的风险和责任;
    53 |
      54 | 由于网络信号不稳定、网络带宽小等网络原因,所引起的登录失败、资料不完整、消息推送失败等风险。 55 |
    56 |
57 | 当你同意本协议时,意味着我们已经有权使用您的帐号。 58 | 最终解释权归本软件的开发者所有。 59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /web/src/components/Loading.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PageLoading } from '@ant-design/pro-layout'; 3 | function Loading(props) { 4 | return ; 5 | } 6 | export default Loading; 7 | -------------------------------------------------------------------------------- /web/src/components/icons/RSS.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Icon from '@ant-design/icons'; 3 | import { ReactComponent as RssSvg } from './rss.svg'; 4 | 5 | export default (props) => ; 6 | -------------------------------------------------------------------------------- /web/src/components/icons/rss.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/pages/Login.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Card, Checkbox, Form, Input, message, Modal } from 'antd'; 3 | import axios from 'axios'; 4 | import Agreement from '../components/Agreement'; 5 | import configs from './config'; 6 | import './Login.less'; 7 | import { getUserFromQuery } from './tools'; 8 | 9 | class App extends React.Component { 10 | constructor() { 11 | super(); 12 | this.state = { 13 | qq: '', 14 | stuId: '', 15 | passwd: '', 16 | token: '', 17 | agreeDeal: true, 18 | loginStatus: null, 19 | AgreeContentModalVisible: false, 20 | }; 21 | } 22 | componentDidMount() { 23 | const user = getUserFromQuery(); 24 | this.setState({ 25 | qq: user.qq, 26 | token: user.token, 27 | }); 28 | } 29 | 30 | handleIdInput = (e) => { 31 | this.setState({ 32 | stuId: e.target.value, 33 | }); 34 | }; 35 | handlePwdInput = (e) => { 36 | this.setState({ 37 | passwd: e.target.value, 38 | }); 39 | }; 40 | handleLogin = (e) => { 41 | if (this.state.stuId && this.state.passwd && this.state.token) { 42 | this.setState({ 43 | loginStatus: 'loading', 44 | }); 45 | let login_data = { 46 | username: this.state.stuId, 47 | password: this.state.passwd, 48 | qq: this.state.qq, 49 | token: this.state.token, 50 | }; 51 | axios 52 | .post(configs.apiUrl + '/api/user/bind', login_data) 53 | .then((res) => { 54 | if (res.status == '200' && res.data.code == '200') { 55 | message.success(`登录成功!请返回聊天页面。`); 56 | this.setState({ 57 | loginStatus: 'success', 58 | }); 59 | } else if (res.status == '200' && res.data.code == '-1') { 60 | if (res.data.data == 'qq绑定失败!失败原因是AuthFail') { 61 | message.error(`绑定失败!用户名密码错误!`); 62 | } else if (res.data.data == '该qq已经绑定了!') { 63 | message.error(`该 QQ 已绑定!`); 64 | } else { 65 | message.error(`绑定失败,教务处暂时无法访问!`); 66 | } 67 | this.setState({ 68 | loginStatus: 'fail', 69 | }); 70 | } 71 | }) 72 | .catch((err) => { 73 | if (err.response && err.response.status == 403) { 74 | message.error('参数请求错误!'); 75 | } else { 76 | message.error(`登录失败:${err.response.status},请检查网络连接!`); 77 | } 78 | this.setState({ 79 | loginStatus: 'fail', 80 | }); 81 | }); 82 | } else { 83 | message.error('请输入账号密码!'); 84 | } 85 | }; 86 | 87 | onClose = () => { 88 | this.setState({ 89 | agreeDeal: false, 90 | AgreeContentModalVisible: false, 91 | }); 92 | }; 93 | 94 | handleAgreeCheckboxClick = () => { 95 | this.setState({ 96 | agreeDeal: !this.state.agreeDeal, 97 | }); 98 | }; 99 | 100 | handleAgreeButtonClick = () => { 101 | this.setState({ 102 | agreeDeal: true, 103 | AgreeContentModalVisible: false, 104 | }); 105 | }; 106 | click = () => { 107 | this.setState({ 108 | AgreeContentModalVisible: true, 109 | }); 110 | }; 111 | render() { 112 | let a = true; 113 | let loginButtonIcon = null; 114 | if (this.state.loginStatus === 'success') { 115 | loginButtonIcon = 'check-circle-o'; 116 | } else if (this.state.loginStatus === 'fail') { 117 | loginButtonIcon = 'cross-circle'; 118 | } else if (this.state.loginStatus === 'loading') { 119 | loginButtonIcon = 'loading'; 120 | } 121 | let isLoading = this.state.loginStatus === 'loading'; 122 | if (this.state.qq && this.state.token) { 123 | return ( 124 |
125 | 126 |

您({this.state.qq})正在向小科绑定教务处~

127 |

请输入教务处账号密码:

128 |
129 | 143 | 144 | 145 | 146 | 157 | 158 | 159 | 164 | value 165 | ? Promise.resolve() 166 | : Promise.reject('Should accept agreement'), 167 | }, 168 | ]} 169 | > 170 | 174 | 我已经阅读了 175 | 178 | 并同意。 179 | 180 | 181 | 182 | 195 | 196 |
197 | 207 | 208 | 209 |
210 |
211 | ); 212 | } else { 213 | return <>参数错误; 214 | } 215 | } 216 | } 217 | 218 | export default App; 219 | -------------------------------------------------------------------------------- /web/src/pages/Login.less: -------------------------------------------------------------------------------- 1 | @import '../styles/parameter.less'; 2 | 3 | .login-container { 4 | position: relative; 5 | width: 100vw; 6 | height: 100vh; 7 | background-color: rgb(240, 240, 240); 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | .login-card { 12 | padding: 50px; 13 | @media @mobile { 14 | font-size: 1.5rem !important; 15 | .ant-form { 16 | & label { 17 | font-size: unset; 18 | } 19 | &-item { 20 | font-size: 1.5rem !important; 21 | } 22 | & .ant-input { 23 | font-size: unset !important; 24 | } 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /web/src/pages/UserCenter.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | AlertOutlined, 4 | BellOutlined, 5 | BulbOutlined, 6 | DollarCircleOutlined, 7 | DotChartOutlined, 8 | GithubOutlined, 9 | NodeExpandOutlined, 10 | ProfileOutlined, 11 | ScheduleOutlined, 12 | SendOutlined, 13 | } from '@ant-design/icons'; 14 | import { Button, Card, Checkbox, List, message, Switch } from 'antd'; 15 | import axios from 'axios'; 16 | import RSS from '../components/icons/RSS'; 17 | import configs from './config'; 18 | import { getUserFromQuery } from './tools'; 19 | import './UserCenter.less'; 20 | 21 | const CheckboxGroup = Checkbox.Group; 22 | export default class UserCenter extends React.Component { 23 | constructor(props) { 24 | super(props); 25 | this.state = { 26 | qq: '', 27 | token: '', 28 | loading: false, 29 | subdata: {}, 30 | }; 31 | } 32 | 33 | componentDidMount() { 34 | const user = getUserFromQuery(); 35 | this.setState( 36 | { 37 | qq: user.qq, 38 | token: user.token, 39 | }, 40 | () => { 41 | axios 42 | .get(configs.apiUrl + '/api/subs', { 43 | params: { qq: this.state.qq, token: this.state.token }, 44 | }) 45 | .then((res) => { 46 | this.setState({ 47 | subdata: res.data || [], 48 | }); 49 | }); 50 | }, 51 | ); 52 | } 53 | renderFuncItem = ({ name, Icon, iconColor, iconBackgroundColor }) => ( 54 | 55 | 68 |
74 | 75 |
76 |

{name}

77 |
78 |
79 | ); 80 | 81 | renderSubItem = ({ name, enable }) => ( 82 | 83 | this.handleSwitchClick(event, name)} 87 | loading={this.state.loading} 88 | /> 89 |

{name}

90 |
91 | ); 92 | 93 | handleSwitchClick = (event, name) => { 94 | this.setState({ 95 | loading: true, 96 | }); 97 | let postdata = {}; 98 | for (let item in this.state.subdata.data) { 99 | if (this.state.subdata.data[item].name === name) { 100 | postdata[item] = event; 101 | } 102 | } 103 | axios({ 104 | method: 'post', 105 | url: configs.apiUrl + '/api/subs', 106 | params: { qq: this.state.qq, token: this.state.token }, 107 | data: postdata, 108 | }) 109 | .then((res) => { 110 | this.setState({ 111 | loading: false, 112 | }); 113 | let key = Object.keys(res.data.data); 114 | let msg = res.data.data[key].msg; 115 | message.success(msg); 116 | }) 117 | .catch((err) => { 118 | message.error('当前出错了,请稍后再试'); 119 | }); 120 | }; 121 | 122 | render() { 123 | let options = []; 124 | for (let item in this.state.subdata.data) { 125 | options.push(this.state.subdata.data[item]); 126 | } 127 | return ( 128 |
129 |
130 | 131 | 144 | 145 |
146 |
147 | 155 | 帮助 156 | 157 | } 158 | > 159 | 205 | 245 | 246 |
247 |
248 | ); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /web/src/pages/UserCenter.less: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100vw; 3 | height: 100vh; 4 | background-color: #f2f6f8; 5 | padding: 30px; 6 | 7 | .function { 8 | margin-top: 30px; 9 | .func-item { 10 | height: 100% !important; 11 | padding: 0.6rem 0.5rem 0; 12 | position: relative; 13 | border: 1px solid #e1eaea; 14 | border-radius: 0.25rem; 15 | background-color: #f3f6fc; 16 | transition: all 0.3s; 17 | box-shadow: 0 0.25rem 0.25rem rgba(0, 0, 0, 0.075) !important; 18 | &-name { 19 | margin-top: 0.5rem; 20 | } 21 | 22 | &-icon { 23 | display: flex; 24 | align-items: center; 25 | justify-content: center; 26 | width: 60px; 27 | height: 60px; 28 | font-size: 28px; 29 | border-radius: 4px; 30 | white-space: nowrap; 31 | overflow: hidden; 32 | } 33 | } 34 | } 35 | 36 | .subscriptions { 37 | .subitem { 38 | height: 30px; 39 | } 40 | .switch { 41 | float: left; 42 | margin-top: 7px; 43 | } 44 | .content { 45 | margin-left: 50px; 46 | font-weight: 400; 47 | font-size: 20px; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /web/src/pages/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | apiUrl: API_URL, 3 | }; 4 | -------------------------------------------------------------------------------- /web/src/pages/index.less: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /web/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { DefaultHeader } from '@ant-design/pro-layout'; 3 | import './index.less'; 4 | import { getUserFromQuery } from './tools'; 5 | 6 | export default (props) => { 7 | const [qq, setQQ] = useState(''); 8 | const [token, setToken] = useState(''); 9 | 10 | useEffect(() => { 11 | const user = getUserFromQuery(); 12 | setQQ(user.qq as string); 13 | setToken(user.token as string); 14 | }); 15 | 16 | return ( 17 |
18 | null} 20 | navTheme="light" 21 | rightContentRender={() => <>{qq}} 22 | /> 23 | {props.children} 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /web/src/pages/tools.js: -------------------------------------------------------------------------------- 1 | import queryString from 'query-string'; 2 | 3 | export function CloseWebPage() { 4 | if (navigator.userAgent.indexOf('MSIE') > 0) { 5 | if (navigator.userAgent.indexOf('MSIE 6.0') > 0) { 6 | window.opener = null; 7 | window.close(); 8 | } else { 9 | window.open('', '_top'); 10 | window.top.close(); 11 | } 12 | } else if ( 13 | navigator.userAgent.indexOf('Firefox') > 0 || 14 | navigator.userAgent.indexOf('Chrome') > 0 15 | ) { 16 | //window.location.href = 'about:blank '; 17 | window.location.href = 'about:blank'; 18 | window.close(); 19 | } else { 20 | window.opener = null; 21 | window.open('', '_self'); 22 | window.close(); 23 | } 24 | } 25 | 26 | export const getUserFromQuery = () => { 27 | let b64 = window.location.search.substring(1); 28 | try { 29 | let query = window.atob(b64); 30 | let parsedQuery = queryString.parse(query); 31 | return { 32 | qq: parsedQuery.qq || '', 33 | token: parsedQuery.token || '', 34 | }; 35 | } catch (error) { 36 | return {}; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /web/src/styles/parameter.less: -------------------------------------------------------------------------------- 1 | @screen-xl: 1208px; 2 | @screen-lg: 1024px; 3 | @screen-md: 768px; 4 | // 移动 5 | @screen-sm: 767.9px; 6 | // 超小屏 7 | @screen-xs: 375px; 8 | 9 | // PC 宽度 >= 1208 10 | @pc: ~'only screen and (min-width: @{screen-xl})'; 11 | // 桌面 >= 1024 =< 1208 12 | @desktop: ~'screen and (min-width:@{screen-lg}) and (max-width:@{screen-xl})'; 13 | // 平板 >= 768 <= 1024 14 | @tablet: ~'screen and (min-width: @{screen-md}) and (max-width:@{screen-lg})'; 15 | // 移动端 <= 768 16 | @mobile: ~'only screen and (max-width: @{screen-md})'; 17 | // 超小移动端 <= 375 18 | @smallMobile: ~'only screen and (max-width: @{screen-xs})'; 19 | // ipad 横向 20 | @ipad-landscape: ~'only screen and (min-device-width: 768px)and (max-device-width : 1024px) and (orientation: landscape)'; 21 | // ipad 纵向 22 | @ipad-port: ~'only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: portrait)'; 23 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "importHelpers": true, 7 | "jsx": "react", 8 | "esModuleInterop": true, 9 | "sourceMap": true, 10 | "baseUrl": "./", 11 | "strict": false, 12 | "paths": { 13 | "@/*": ["src/*"], 14 | "@@/*": ["src/.umi/*"] 15 | }, 16 | "allowSyntheticDefaultImports": true 17 | }, 18 | "include": ["mock/**/*", "src/**/*", "config/**/*", ".umirc.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /web/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | declare module "*.png"; 4 | --------------------------------------------------------------------------------