├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── document.md │ ├── enhancement.md │ └── error.md └── workflows │ ├── docker-image.yml │ └── pr-checker.yml ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── README_en.md ├── deploy ├── .env.example ├── Dockerfile ├── Dockerfile.proxy ├── Dockerfile.proxy.saas ├── docker-compose.yml ├── entrypoint.sh ├── gitmaya.com.conf ├── proxy.conf └── wait-for-it.sh ├── pdm.lock ├── pyproject.toml ├── requirements.txt └── server ├── app.py ├── celery_app.py ├── command └── lark.py ├── env.py ├── model ├── lark.py ├── repo.py ├── schema.py ├── team.py └── user.py ├── routes ├── __init__.py ├── github.py ├── lark.py ├── team.py └── user.py ├── server.py ├── tasks ├── __init__.py ├── github │ ├── __init__.py │ ├── github.py │ ├── issue.py │ ├── organization.py │ ├── pull_request.py │ ├── push.py │ └── repo.py └── lark │ ├── __init__.py │ ├── base.py │ ├── chat.py │ ├── issue.py │ ├── lark.py │ ├── manage.py │ ├── pull_request.py │ └── repo.py └── utils ├── auth.py ├── constant.py ├── github ├── __init__.py ├── account.py ├── application.py ├── bot.py ├── model.py ├── organization.py └── repo.py ├── lark ├── __init__.py ├── base.py ├── chat_action_choose.py ├── chat_action_result.py ├── chat_manual.py ├── chat_tip_failed.py ├── issue_card.py ├── issue_manual_help.py ├── issue_open_in_browser.py ├── issue_tip_failed.py ├── issue_tip_success.py ├── manage_fail.py ├── manage_manual.py ├── manage_repo_detect.py ├── manage_success.py ├── parser.py ├── post_message.py ├── pr_card.py ├── pr_manual.py ├── pr_tip_commit_history.py ├── pr_tip_failed.py ├── pr_tip_success.py ├── repo_info.py ├── repo_manual.py ├── repo_tip_failed.py └── repo_tip_success.py ├── redis.py ├── user.py └── utils.py /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | *.md 5 | dist 6 | data 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/document.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Document Enhancement 3 | about: Welcome to sharing your suggestions for document improvement! 📝 4 | title: "📝 Document" 5 | labels: ["documentation"] 6 | --- 7 | ## 📝 Suggestions for Document Improvement 8 | 9 | Feel free to share your thoughts and suggestions for enhancing our documentation. We look forward to hearing your ideas. 10 | 11 | ## 🤔 What are your suggestions? 12 | 13 | Please briefly describe your suggestions for document improvement, including your goals and ideas. 14 | 15 | If your suggestion is aimed at solving a specific issue, kindly provide as much context and detail as possible. 16 | 17 | ## 🌟 Advantages of Your Suggestions 18 | 19 | Briefly describe the strengths and features of your suggestions, and explain why we should consider adopting them. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Enhancement 3 | about: Welcome to share your improvement suggestions! 4 | title: "🚀 Feature" 5 | labels: ["enhancement"] 6 | --- 7 | ## What's Your Suggestion? 🤔 8 | 9 | Please briefly describe your feature enhancement suggestion, including your goals and ideas. 10 | 11 | If your suggestion aims to address a specific issue, kindly provide as much context and detail as possible. 12 | 13 | Thank you for sharing and your support! 🙏 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/error.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Welcome to share your bug reports! 🐞 4 | title: "🐞 Bug" 5 | labels: ["bug"] 6 | --- 7 | ## Issue Description 🤔 8 | 9 | Kindly elaborate on the problem you are facing, including the environment and steps where the issue occurred. Also, let us know the solutions you have attempted. 10 | 11 | Additionally, if you have referred to other GitHub Issues while troubleshooting, please specify and cite relevant information in your text. 12 | 13 | ## Additional Information 📝 14 | 15 | To expedite the resolution process, please provide the following details: 16 | 17 | - Output logs, including error messages and stack traces 18 | - Relevant code snippets or files 19 | - Your operating system, software versions, and other environmental information 20 | 21 | We appreciate your feedback! 🙏 22 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | paths: 7 | - '**' 8 | - 'website/**' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build-main-app: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out the repo 16 | uses: actions/checkout@v2 17 | 18 | - name: Log in to Docker Hub 19 | uses: docker/login-action@v1 20 | with: 21 | username: ${{ secrets.DOCKER_HUB_ACCOUNT }} 22 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 23 | 24 | - name: Build and push main app Docker image 25 | uses: docker/build-push-action@v2 26 | with: 27 | context: . 28 | file: deploy/Dockerfile 29 | push: true 30 | tags: connectai/gitmaya:latest 31 | 32 | build-website: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Check out the repo 36 | uses: actions/checkout@v2 37 | with: 38 | submodules: 'true' 39 | 40 | - name: Log in to Docker Hub 41 | uses: docker/login-action@v1 42 | with: 43 | username: ${{ secrets.DOCKER_HUB_ACCOUNT }} 44 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 45 | 46 | - name: Build and push website Docker image 47 | uses: docker/build-push-action@v2 48 | with: 49 | context: . 50 | file: deploy/Dockerfile.proxy 51 | push: true 52 | tags: connectai/gitmaya-proxy:latest 53 | -------------------------------------------------------------------------------- /.github/workflows/pr-checker.yml: -------------------------------------------------------------------------------- 1 | name: "PR Checks" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | pre_commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v3 14 | - uses: pre-commit/action@v3.0.0 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm-project.org/#use-with-ide 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | *.pem 165 | .idea 166 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "website"] 2 | path = website 3 | url = https://github.com/ConnectAI-E/GitMaya-Frontend.git 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - repo: https://github.com/pycqa/isort 12 | rev: 5.12.0 13 | hooks: 14 | - id: isort 15 | args: ["--profile", "black", "--filter-files"] 16 | - repo: https://github.com/python/black 17 | rev: 23.11.0 18 | hooks: 19 | - id: black 20 | - repo: https://github.com/pdm-project/pdm 21 | rev: 2.11.1 22 | hooks: 23 | - id: pdm-export 24 | args: ['-o', 'requirements.txt', '--without-hashes'] 25 | files: ^pdm.lock$ 26 | - id: pdm-lock-check 27 | - id: pdm-sync 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ConnectAI-E 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | 3 | build: 4 | @echo "Building..." 5 | docker build -t connectai/gitmaya -f deploy/Dockerfile . 6 | @echo "Done." 7 | 8 | build-proxy: 9 | @echo "Building proxy..." 10 | git submodule update --init && docker build -t connectai/gitmaya-proxy -f deploy/Dockerfile.proxy . 11 | @echo "Done." 12 | 13 | push: push-gitmaya push-proxy 14 | 15 | push-gitmaya: 16 | @echo "Push Image..." 17 | docker push connectai/gitmaya 18 | @echo "Done." 19 | 20 | push-proxy: 21 | @echo "Push Image..." 22 | docker push connectai/gitmaya-proxy 23 | @echo "Done." 24 | 25 | startup: 26 | @echo "Deploy..." 27 | [ -f deploy/.env ] || cp deploy/.env.example deploy/.env 28 | cd deploy && docker-compose up -d 29 | @echo "Waiting Mysql Server..." 30 | sleep 3 31 | @echo "Init Database..." 32 | cd deploy && docker-compose exec gitmaya flask --app model.schema:app create 33 | @echo "Done." 34 | -------------------------------------------------------------------------------- /deploy/.env.example: -------------------------------------------------------------------------------- 1 | 2 | SECRET_KEY="" 3 | FLASK_PERMANENT_SESSION_LIFETIME=2592000 4 | FLASK_SQLALCHEMY_DATABASE_URI="mysql+pymysql://root:gitmaya2023@mysql:3306/gitmaya?charset=utf8mb4&binary_prefix=true" 5 | 6 | GITHUB_APP_NAME=your-deploy-name 7 | GITHUB_APP_ID=114514 8 | GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- 9 | 10 | -----END RSA PRIVATE KEY-----" 11 | 12 | GITHUB_CLIENT_ID=your_client_id 13 | GITHUB_CLIENT_SECRET=your_client_secret 14 | 15 | GITHUB_WEBHOOK_SECRET=secret 16 | DOMAIN=127.0.0.1 17 | LARK_DEPLOY_SERVER="https://deploy.ai2e.cn/feishu/app/internal" 18 | -------------------------------------------------------------------------------- /deploy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim-bookworm 2 | 3 | RUN sed -i "s@http://deb.debian.org@http://mirrors.aliyun.com@g" /etc/apt/sources.list.d/debian.sources 4 | RUN ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 5 | 6 | ADD ./requirements.txt /tmp/requirements.txt 7 | 8 | RUN apt-get update && apt-get install -y libcurl4-openssl-dev libffi-dev libxml2-dev libmariadb-dev\ 9 | && pip install -r /tmp/requirements.txt && pip install gunicorn gevent 10 | 11 | ADD ./deploy/wait-for-it.sh /wait-for-it.sh 12 | ADD ./deploy/entrypoint.sh /entrypoint.sh 13 | 14 | ADD ./server /server 15 | 16 | WORKDIR /server 17 | 18 | ENTRYPOINT ["/entrypoint.sh"] 19 | 20 | CMD ["gunicorn", "--worker-class=gevent", "--workers", "1", "--bind", "0.0.0.0:8888", "-t", "600", "--keep-alive", "60", "--log-level=info", "server:app"] 21 | -------------------------------------------------------------------------------- /deploy/Dockerfile.proxy: -------------------------------------------------------------------------------- 1 | FROM node:20-slim AS build 2 | 3 | ENV PNPM_HOME="/pnpm" 4 | ENV PATH="$PNPM_HOME:$PATH" 5 | 6 | RUN corepack enable 7 | 8 | COPY ./website /app 9 | WORKDIR /app 10 | 11 | RUN pnpm install --frozen-lockfile && pnpm build 12 | 13 | FROM jwilder/nginx-proxy:alpine 14 | 15 | ADD ./deploy/gitmaya.com.conf /etc/nginx/vhost.d/gitmaya.com.conf 16 | ADD ./deploy/proxy.conf /etc/nginx/proxy.conf 17 | 18 | COPY --from=build /app/dist /var/www/html 19 | -------------------------------------------------------------------------------- /deploy/Dockerfile.proxy.saas: -------------------------------------------------------------------------------- 1 | FROM node:20-slim AS build 2 | 3 | ENV PNPM_HOME="/pnpm" 4 | ENV PATH="$PNPM_HOME:$PATH" 5 | 6 | RUN corepack enable 7 | 8 | COPY ./website /app 9 | WORKDIR /app 10 | 11 | RUN pnpm install --frozen-lockfile && pnpm build:saas 12 | 13 | FROM jwilder/nginx-proxy:alpine 14 | 15 | ADD ./deploy/gitmaya.com.conf /etc/nginx/vhost.d/gitmaya.com.conf 16 | ADD ./deploy/proxy.conf /etc/nginx/proxy.conf 17 | 18 | COPY --from=build /app/dist /var/www/html 19 | -------------------------------------------------------------------------------- /deploy/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | worker: 4 | image: connectai/gitmaya 5 | volumes: 6 | - .env:/server/.env 7 | command: celery -A tasks.celery worker -l INFO -c 2 8 | 9 | beat: 10 | extends: worker 11 | command: celery -A tasks.celery beat -l DEBUG 12 | 13 | flower: 14 | extends: worker 15 | command: bash -c 'pip install flower && celery -A tasks.celery flower --basic-auth="gitmaya:gitmaya2023" --persistent=True --db="/data/flower_db"' 16 | ports: 17 | - "5555" 18 | environment: 19 | - VIRTUAL_HOST=flower.gitmaya.com 20 | volumes: 21 | - ./data/flower:/data 22 | 23 | gitmaya: 24 | extends: worker 25 | ports: 26 | - "8888" 27 | environment: 28 | - VIRTUAL_HOST=gitmaya.com 29 | command: gunicorn --worker-class=gevent --workers 1 --bind 0.0.0.0:8888 -t 600 --keep-alive 60 --log-level=info server:app 30 | 31 | redis: 32 | restart: always 33 | image: redis:alpine 34 | ports: 35 | - "6379" 36 | volumes: 37 | - ./data/redis:/data 38 | command: redis-server --save 20 1 --loglevel warning 39 | 40 | mysql: 41 | restart: always 42 | image: mysql:5.7 43 | volumes: 44 | - ./data/mysql/data:/var/lib/mysql 45 | - ./data/mysql/conf.d:/etc/mysql/conf.d 46 | environment: 47 | MYSQL_ROOT_PASSWORD: 'gitmaya2023' 48 | MYSQL_DATABASE: 'gitmaya' 49 | TZ: 'Asia/Shanghai' 50 | ports: 51 | - "3306" 52 | command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION 53 | 54 | proxy: 55 | image: connectai/gitmaya-proxy 56 | ports: 57 | - "8000:80" 58 | - "8001:81" 59 | volumes: 60 | - /var/run/docker.sock:/tmp/docker.sock:ro 61 | -------------------------------------------------------------------------------- /deploy/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | /wait-for-it.sh mysql:3306 -- flask --app model.schema:app create & 4 | 5 | exec "$@" 6 | -------------------------------------------------------------------------------- /deploy/gitmaya.com.conf: -------------------------------------------------------------------------------- 1 | root /var/www/html; 2 | 3 | error_page 404 = /index.html; 4 | 5 | location = / { 6 | try_files $uri /index.html; 7 | } 8 | 9 | location ~ ^/(index|locales|assets|logo|app|login) { 10 | try_files $uri $uri/ /index.html; 11 | } 12 | -------------------------------------------------------------------------------- /deploy/proxy.conf: -------------------------------------------------------------------------------- 1 | # proxy 配置 2 | 3 | # HTTP 1.1 support 4 | proxy_http_version 1.1; 5 | proxy_buffering off; 6 | proxy_set_header Host $http_host; 7 | proxy_set_header Upgrade $http_upgrade; 8 | proxy_set_header Connection $proxy_connection; 9 | proxy_set_header X-Real-IP $remote_addr; 10 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 11 | proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 12 | proxy_set_header X-Forwarded-Ssl $proxy_x_forwarded_ssl; 13 | proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; 14 | proxy_set_header X-Original-URI $request_uri; 15 | 16 | # Mitigate httpoxy attack (see README for details) 17 | proxy_set_header Proxy ""; 18 | 19 | proxy_read_timeout 600; 20 | client_max_body_size 20m; 21 | 22 | 23 | server { 24 | listen 81 default_server; 25 | server_name _; 26 | 27 | location / { 28 | proxy_pass http://gitmaya.com; 29 | include /etc/nginx/vhost.d/gitmaya.com.conf; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /deploy/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # Check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | 148 | WAITFORIT_BUSYTIMEFLAG="" 149 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 150 | WAITFORIT_ISBUSY=1 151 | # Check if busybox timeout uses -t flag 152 | # (recent Alpine versions don't support -t anymore) 153 | if timeout &>/dev/stdout | grep -q -e '-t '; then 154 | WAITFORIT_BUSYTIMEFLAG="-t" 155 | fi 156 | else 157 | WAITFORIT_ISBUSY=0 158 | fi 159 | 160 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 161 | wait_for 162 | WAITFORIT_RESULT=$? 163 | exit $WAITFORIT_RESULT 164 | else 165 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 166 | wait_for_wrapper 167 | WAITFORIT_RESULT=$? 168 | else 169 | wait_for 170 | WAITFORIT_RESULT=$? 171 | fi 172 | fi 173 | 174 | if [[ $WAITFORIT_CLI != "" ]]; then 175 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 176 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 177 | exit $WAITFORIT_RESULT 178 | fi 179 | exec "${WAITFORIT_CLI[@]}" 180 | else 181 | exit $WAITFORIT_RESULT 182 | fi 183 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "GitMaya" 3 | version = "0.0.1" 4 | description = "Default template for PDM package" 5 | authors = [ 6 | {name = "ConnectAI", email = "hi@connectai-e.com"}, 7 | ] 8 | dependencies = [ 9 | "python-dotenv>=1.0.0", 10 | "ca-lark-oauth>=0.0.6", 11 | "ca-lark-webhook>=0.0.5", 12 | "flask-sqlalchemy>=3.1.1", 13 | "flask-cors>=4.0.0", 14 | "pymysql>=1.1.0", 15 | "click>=8.1.7", 16 | "bson>=0.5.10", 17 | "jwt>=1.3.1", 18 | "urllib3>=2.1.0", 19 | "ca-lark-sdk>=0.0.24", 20 | "celery>=5.3.6", 21 | "redis>=5.0.1", 22 | "pydantic>=2.5.3", 23 | ] 24 | requires-python = ">=3.10" 25 | readme = "README.md" 26 | license = {text = "MIT"} 27 | 28 | 29 | [tool.pdm] 30 | package-type = "application" 31 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # Please do not edit it manually. 3 | 4 | amqp==5.2.0 5 | annotated-types==0.6.0 6 | anyio==4.2.0 7 | async-timeout==4.0.3; python_full_version <= "3.11.2" 8 | billiard==4.2.0 9 | blinker==1.7.0 10 | bson==0.5.10 11 | ca-lark-oauth==0.0.6 12 | ca-lark-sdk==0.0.24 13 | ca-lark-webhook==0.0.5 14 | celery==5.3.6 15 | certifi==2023.11.17 16 | cffi==1.16.0 17 | click==8.1.7 18 | click-didyoumean==0.3.0 19 | click-plugins==1.1.1 20 | click-repl==0.3.0 21 | colorama==0.4.6; platform_system == "Windows" 22 | cryptography==41.0.7 23 | exceptiongroup==1.2.0; python_version < "3.11" 24 | Flask==3.0.0 25 | flask-cors==4.0.0 26 | flask-sqlalchemy==3.1.1 27 | greenlet==3.0.3; platform_machine == "win32" or platform_machine == "WIN32" or platform_machine == "AMD64" or platform_machine == "amd64" or platform_machine == "x86_64" or platform_machine == "ppc64le" or platform_machine == "aarch64" 28 | h11==0.14.0 29 | httpcore==1.0.2 30 | httpx==0.26.0 31 | idna==3.6 32 | itsdangerous==2.1.2 33 | Jinja2==3.1.2 34 | jwt==1.3.1 35 | kombu==5.3.4 36 | MarkupSafe==2.1.3 37 | prompt-toolkit==3.0.43 38 | pycparser==2.21 39 | pycryptodome==3.19.1 40 | pydantic==2.5.3 41 | pydantic-core==2.14.6 42 | pymysql==1.1.0 43 | python-dateutil==2.8.2 44 | python-dotenv==1.0.0 45 | redis==5.0.1 46 | six==1.16.0 47 | sniffio==1.3.0 48 | sqlalchemy==2.0.25 49 | typing-extensions==4.9.0 50 | tzdata==2023.4 51 | urllib3==2.1.0 52 | vine==5.1.0 53 | wcwidth==0.2.13 54 | Werkzeug==3.0.1 55 | -------------------------------------------------------------------------------- /server/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from flask import Flask, jsonify 5 | from flask_cors import CORS 6 | from flask_sqlalchemy import SQLAlchemy 7 | 8 | app = Flask(__name__) 9 | app.config.setdefault("SESSION_COOKIE_SAMESITE", "None") 10 | app.config.from_prefixed_env() 11 | app.secret_key = os.environ.get("SECRET_KEY") 12 | db = SQLAlchemy(app, engine_options={"isolation_level": "AUTOCOMMIT"}) 13 | CORS( 14 | app, allow_headers=["Authorization", "X-Requested-With"], supports_credentials=True 15 | ) 16 | 17 | 18 | @app.errorhandler(404) 19 | def page_not_found(error): 20 | response = jsonify({"code": -1, "msg": error.description}) 21 | response.status_code = 404 22 | return response 23 | 24 | 25 | @app.errorhandler(400) 26 | def bad_request(error): 27 | response = jsonify({"code": -1, "msg": error.description}) 28 | response.status_code = 400 29 | return response 30 | 31 | 32 | gunicorn_logger = logging.getLogger("gunicorn.error") 33 | app.logger.handlers = gunicorn_logger.handlers 34 | app.logger.setLevel(gunicorn_logger.level) 35 | -------------------------------------------------------------------------------- /server/celery_app.py: -------------------------------------------------------------------------------- 1 | import env 2 | from app import app 3 | from celery import Celery 4 | 5 | app.config.setdefault("CELERY_BROKER_URL", "redis://redis:6379/0") 6 | app.config.setdefault("CELERY_RESULT_BACKEND", "redis://redis:6379/0") 7 | celery = Celery( 8 | app.import_name, 9 | broker=app.config["CELERY_BROKER_URL"], 10 | backend=app.config["CELERY_RESULT_BACKEND"], 11 | ) 12 | 13 | celery.conf.update(app.config.get("CELERY_CONFIG", {}), timezone="Asia/Shanghai") 14 | TaskBase = celery.Task 15 | 16 | 17 | class ContextTask(TaskBase): 18 | abstract = True 19 | 20 | def __call__(self, *args, **kwargs): 21 | with app.app_context(): 22 | return TaskBase.__call__(self, *args, **kwargs) 23 | 24 | 25 | celery.Task = ContextTask 26 | -------------------------------------------------------------------------------- /server/command/lark.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | from app import app 5 | from model.team import save_im_application 6 | 7 | 8 | # create command function 9 | @app.cli.command(name="larkapp") 10 | @click.option("-a", "--app-id", "app_id", required=True, prompt="Feishu(Lark) APP ID") 11 | @click.option( 12 | "-s", "--app-secret", "app_secret", required=True, prompt="Feishu(Lark) APP SECRET" 13 | ) 14 | @click.option( 15 | "-e", "--encrypt-key", "encrypt_key", default="", prompt="Feishu(Lark) ENCRYPT KEY" 16 | ) 17 | @click.option( 18 | "-v", 19 | "--verification-token", 20 | "verification_token", 21 | default="", 22 | prompt="Feishu(Lark) VERIFICATION TOKEN", 23 | ) 24 | @click.option("-h", "--host", "host", default="https://testapi.gitmaya.com") 25 | def create_lark_app(app_id, app_secret, encrypt_key, verification_token, host): 26 | # click.echo(f'create_lark_app {app_id} {app_secret} {encrypt_key} {verification_token} {host}') 27 | permissions = "\n\t".join( 28 | [ 29 | "contact:contact:readonly_as_app", 30 | "im:chat", 31 | "im:message", 32 | "im:resource", 33 | ] 34 | ) 35 | events = "\n\t".join( 36 | [ 37 | "im.message.receive_v1", 38 | ] 39 | ) 40 | click.echo(f"need permissions: \n{permissions}\n") 41 | click.echo(f"need events: \n{events}\n") 42 | click.echo(f"webhook: \n{host}/api/feishu/oauth") 43 | 44 | save_im_application( 45 | None, "lark", app_id, app_secret, encrypt_key, verification_token 46 | ) 47 | click.echo(f"webhook: \n{host}/api/feishu/hook/{app_id}") 48 | 49 | click.confirm("success to save feishu app?", abort=True) 50 | click.echo(f"you can publish you app.") 51 | -------------------------------------------------------------------------------- /server/env.py: -------------------------------------------------------------------------------- 1 | from dotenv import find_dotenv, load_dotenv 2 | 3 | load_dotenv(find_dotenv()) 4 | -------------------------------------------------------------------------------- /server/model/lark.py: -------------------------------------------------------------------------------- 1 | from .schema import IMApplication, db 2 | 3 | 4 | def get_bot_by_app_id(app_id): 5 | return ( 6 | db.session.query(IMApplication) 7 | .filter( 8 | IMApplication.app_id == app_id, 9 | ) 10 | .first() 11 | ) 12 | -------------------------------------------------------------------------------- /server/model/repo.py: -------------------------------------------------------------------------------- 1 | from app import app, db 2 | from model.schema import BindUser, ObjID, Repo, RepoUser, User 3 | from utils.github.model import Repository 4 | from utils.github.repo import GitHubAppRepo 5 | 6 | 7 | def create_repo_from_github( 8 | repo: dict, org_name: str, application_id: str, github_app: GitHubAppRepo 9 | ) -> Repo: 10 | """Create repo from github 11 | 12 | Args: 13 | repo (dict): repo info from github 14 | org_name (str): organization name 15 | application_id (str): application id 16 | github_app (GitHubAppRepo): github app instance 17 | 18 | Returns: 19 | Repo: repo instance 20 | """ 21 | 22 | repo_extra = Repository(**repo).model_dump() 23 | 24 | # 检查是否已经存在 25 | try: 26 | current_repo = Repo.query.filter_by(repo_id=str(repo["id"])).first() 27 | 28 | if current_repo is None: 29 | new_repo = Repo( 30 | id=ObjID.new_id(), 31 | application_id=application_id, 32 | repo_id=str(repo["id"]), 33 | name=repo["name"], 34 | description=repo["description"], 35 | extra=repo_extra, 36 | ) 37 | db.session.add(new_repo) 38 | db.session.flush() 39 | 40 | current_repo = new_repo 41 | app.logger.debug(f"Repo {current_repo.id} created") 42 | else: 43 | # 更新 repo 信息 44 | # 暂不支持更新 repo 名称 45 | current_repo.description = repo["description"] 46 | current_repo.extra = repo_extra 47 | db.session.add(current_repo) 48 | db.session.flush() 49 | 50 | # 拉取仓库成员,创建 RepoUser 51 | repo_users = github_app.get_repo_collaborators(repo["name"], org_name) 52 | 53 | for repo_user in repo_users: 54 | # 检查是否有 bind_user,没有则跳过 55 | bind_user = ( 56 | db.session.query(BindUser) 57 | .filter( 58 | User.unionid == str(repo_user["id"]), 59 | BindUser.platform == "github", 60 | # 通过 GitHub 创建的 BindUser 不再写入更新 application_id 61 | # BindUser.application_id == application_id, 62 | BindUser.user_id == User.id, 63 | ) 64 | .first() 65 | ) 66 | if bind_user is None: 67 | app.logger.debug(f"RepoUser {repo_user['login']} has no bind user") 68 | continue 69 | 70 | # 检查是否有 repo_user 71 | current_repo_user = ( 72 | db.session.query(RepoUser) 73 | .filter( 74 | RepoUser.repo_id == current_repo.id, 75 | RepoUser.bind_user_id == bind_user.id, 76 | RepoUser.application_id == application_id, 77 | ) 78 | .first() 79 | ) 80 | 81 | # 根据 permission 创建 repo_user 82 | permissions = repo_user["permissions"] 83 | app.logger.debug( 84 | f"RepoUser {repo_user['login']} permissions: {permissions}" 85 | ) 86 | if permissions["admin"]: 87 | permission = "admin" 88 | elif permissions["maintain"]: 89 | permission = "maintain" 90 | elif permissions["push"]: 91 | permission = "push" 92 | else: 93 | continue 94 | 95 | if current_repo_user is not None: 96 | # 更新权限 97 | current_repo_user.permission = permission 98 | db.session.add(current_repo_user) 99 | db.session.flush() 100 | continue 101 | 102 | new_repo_user = RepoUser( 103 | id=ObjID.new_id(), 104 | application_id=application_id, 105 | repo_id=current_repo.id, 106 | bind_user_id=bind_user.id, 107 | permission=permission, 108 | ) 109 | db.session.add(new_repo_user) 110 | db.session.flush() 111 | 112 | app.logger.debug(f"RepoUser {new_repo_user.id} created") 113 | 114 | db.session.commit() 115 | 116 | except Exception as e: 117 | db.session.rollback() 118 | raise e 119 | 120 | return current_repo 121 | -------------------------------------------------------------------------------- /server/model/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import and_, or_ 2 | 3 | from .schema import User, db 4 | 5 | 6 | def get_user_by_id(user_id): 7 | return ( 8 | db.session.query(User) 9 | .filter( 10 | User.id == user_id, 11 | User.status == 0, 12 | ) 13 | .first() 14 | ) 15 | -------------------------------------------------------------------------------- /server/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from .github import * 2 | from .lark import * 3 | from .team import * 4 | from .user import * 5 | -------------------------------------------------------------------------------- /server/routes/github.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from app import app 5 | from flask import Blueprint, jsonify, make_response, redirect, request, session 6 | from model.team import create_code_application, create_team 7 | from tasks.github import pull_github_repo 8 | from tasks.github.issue import on_issue, on_issue_comment 9 | from tasks.github.organization import on_organization 10 | from tasks.github.pull_request import on_pull_request 11 | from tasks.github.push import on_push 12 | from tasks.github.repo import on_fork, on_repository, on_star 13 | from utils.auth import authenticated 14 | from utils.github.application import verify_github_signature 15 | from utils.github.bot import BaseGitHubApp 16 | from utils.user import register 17 | 18 | bp = Blueprint("github", __name__, url_prefix="/api/github") 19 | 20 | 21 | @bp.route("/install", methods=["GET"]) 22 | @authenticated 23 | def github_install(): 24 | """Install GitHub App. 25 | 26 | Redirect to GitHub App installation page. 27 | """ 28 | installation_id = request.args.get("installation_id", None) 29 | if installation_id is None: 30 | return redirect( 31 | f"https://github.com/apps/{(os.environ.get('GITHUB_APP_NAME')).replace(' ', '-')}/installations/new" 32 | ) 33 | 34 | github_app = BaseGitHubApp(installation_id) 35 | 36 | try: 37 | app_info = github_app.get_installation_info() 38 | 39 | if app_info is None: 40 | app.logger.error("Failed to get installation info.") 41 | raise Exception("Failed to get installation info.") 42 | 43 | # 判断安装者的身份是用户还是组织 44 | app_type = app_info["account"]["type"] 45 | if app_type == "User": 46 | app.logger.error("User is not allowed to install.") 47 | raise Exception("User is not allowed to install.") 48 | 49 | team = create_team(app_info, contact_id=session.get("contact_id")) 50 | code_application = create_code_application(team.id, installation_id) 51 | 52 | # if app_info == "organization": 53 | # 在后台任务中拉取仓库 54 | task = pull_github_repo.delay( 55 | org_name=app_info["account"]["login"], 56 | installation_id=installation_id, 57 | application_id=code_application.id, 58 | team_id=team.id, 59 | ) 60 | 61 | message = dict( 62 | status=True, 63 | event="installation", 64 | data=app_info, 65 | team_id=team.id, 66 | task_id=task.id, 67 | app_type=app_type, 68 | ) 69 | 70 | except Exception as e: 71 | # 返回错误信息 72 | app.logger.error(e) 73 | app_info = str(e) 74 | message = dict( 75 | status=False, 76 | event="installation", 77 | data=app_info, 78 | team_id=None, 79 | task_id=None, 80 | app_type=app_type, 81 | ) 82 | 83 | return make_response( 84 | """ 85 | 96 | """, 97 | {"Content-Type": "text/html"}, 98 | ) 99 | 100 | 101 | @bp.route("/oauth", methods=["GET"]) 102 | def github_register(): 103 | """GitHub OAuth register. 104 | 105 | If not `code`, redirect to GitHub OAuth page. 106 | If `code`, register by code. 107 | """ 108 | code = request.args.get("code", None) 109 | 110 | if code is None: 111 | return redirect( 112 | f"https://github.com/login/oauth/authorize?client_id={os.environ.get('GITHUB_CLIENT_ID')}" 113 | ) 114 | 115 | # 通过 code 注册;如果 user 已经存在,则一样会返回 user_id 116 | user_id = register(code) 117 | # if user_id is None: 118 | # return jsonify({"message": "Failed to register."}), 500 119 | 120 | # 保存用户注册状态 121 | if user_id: 122 | session["user_id"] = user_id 123 | # 默认是会话级别的session,关闭浏览器直接就失效了 124 | session.permanent = True 125 | 126 | return make_response( 127 | """ 128 | 139 | """, 140 | {"Content-Type": "text/html"}, 141 | ) 142 | 143 | 144 | @bp.route("/hook", methods=["POST"]) 145 | @verify_github_signature(os.environ.get("GITHUB_WEBHOOK_SECRET")) 146 | def github_hook(): 147 | """Receive GitHub webhook.""" 148 | 149 | x_github_event = request.headers.get("x-github-event", None).lower() 150 | 151 | app.logger.info(x_github_event) 152 | 153 | match x_github_event: 154 | case "repository": 155 | task = on_repository.delay(request.json) 156 | return jsonify({"code": 0, "message": "ok", "task_id": task.id}) 157 | case "issues": 158 | task = on_issue.delay(request.json) 159 | return jsonify({"code": 0, "message": "ok", "task_id": task.id}) 160 | case "issue_comment": 161 | task = on_issue_comment.delay(request.json) 162 | return jsonify({"code": 0, "message": "ok", "task_id": task.id}) 163 | case "pull_request": 164 | task = on_pull_request.delay(request.json) 165 | return jsonify({"code": 0, "message": "ok", "task_id": task.id}) 166 | case "organization": 167 | task = on_organization.delay(request.json) 168 | return jsonify({"code": 0, "message": "ok", "task_id": task.id}) 169 | case "push": 170 | task = on_push.delay(request.json) 171 | return jsonify({"code": 0, "message": "ok", "task_id": task.id}) 172 | case "star": 173 | task = on_star.delay(request.json) 174 | return jsonify({"code": 0, "message": "ok", "task_id": task.id}) 175 | case "fork": 176 | task = on_fork.delay(request.json) 177 | return jsonify({"code": 0, "message": "ok", "task_id": task.id}) 178 | case _: 179 | app.logger.info(f"Unhandled GitHub webhook event: {x_github_event}") 180 | return jsonify({"code": -1, "message": "Unhandled GitHub webhook event."}) 181 | 182 | return jsonify({"code": 0, "message": "ok"}) 183 | 184 | 185 | app.register_blueprint(bp) 186 | -------------------------------------------------------------------------------- /server/routes/lark.py: -------------------------------------------------------------------------------- 1 | import os 2 | from argparse import ArgumentError 3 | 4 | from app import app 5 | from connectai.lark.oauth import Server as OauthServerBase 6 | from connectai.lark.sdk import Bot, MarketBot 7 | from connectai.lark.webhook import LarkServer as LarkServerBase 8 | from flask import session 9 | from model.lark import get_bot_by_app_id 10 | from tasks.lark import get_bot_by_application_id, get_contact_by_lark_application 11 | from utils.lark.parser import GitMayaLarkParser 12 | from utils.lark.post_message import post_content_to_markdown 13 | 14 | 15 | def get_bot(app_id): 16 | with app.app_context(): 17 | bot, _ = get_bot_by_application_id(app_id) 18 | return bot 19 | 20 | 21 | class LarkServer(LarkServerBase): 22 | def get_bot(self, app_id): 23 | return get_bot(app_id) 24 | 25 | 26 | class OauthServer(OauthServerBase): 27 | def get_bot(self, app_id): 28 | return get_bot(app_id) 29 | 30 | 31 | hook = LarkServer(prefix="/api/feishu/hook") 32 | oauth = OauthServer(prefix="/api/feishu/oauth") 33 | parser = GitMayaLarkParser() 34 | 35 | 36 | @hook.on_bot_event(event_type="card:action") 37 | def on_card_action(bot, token, data, message, *args, **kwargs): 38 | # TODO 将action中的按钮,或者选择的东西,重新组织成 command 继续走parser的逻辑 39 | if "action" in data and "command" in data["action"].get("value", {}): 40 | command = data["action"]["value"]["command"] 41 | suffix = data["action"]["value"].get("suffix") 42 | # 将选择的直接拼接到后面 43 | if suffix == "$option" and "option" in data["action"]: 44 | command = command + data["action"]["option"] 45 | try: 46 | parser.parse_args( 47 | command, 48 | bot.app_id, 49 | data["open_message_id"], 50 | data, 51 | message, 52 | **kwargs, 53 | ) 54 | except Exception as e: 55 | app.logger.exception(e) 56 | else: 57 | app.logger.error("unkown card_action %r", (bot, token, data, message, *args)) 58 | 59 | 60 | @hook.on_bot_message(message_type="post") 61 | def on_post_message(bot, message_id, content, message, *args, **kwargs): 62 | text, title = post_content_to_markdown(content, False) 63 | content["text"] = text 64 | try: 65 | parser.parse_args(text, bot.app_id, message_id, content, message, **kwargs) 66 | except ArgumentError: 67 | # 命令解析错误,直接调用里面的回复消息逻辑 68 | parser.on_comment(text, bot.app_id, message_id, content, message, **kwargs) 69 | except Exception as e: 70 | app.logger.exception(e) 71 | 72 | 73 | @hook.on_bot_message(message_type="text") 74 | def on_text_message(bot, message_id, content, message, *args, **kwargs): 75 | text = content["text"] 76 | # print("reply_text", message_id, text) 77 | # bot.reply_text(message_id, "reply: " + text) 78 | try: 79 | parser.parse_args(text, bot.app_id, message_id, content, message, **kwargs) 80 | except ArgumentError: 81 | # 命令解析错误,直接调用里面的回复消息逻辑 82 | parser.on_comment(text, bot.app_id, message_id, content, message, **kwargs) 83 | except Exception as e: 84 | app.logger.exception(e) 85 | 86 | 87 | @hook.on_bot_event(event_type="p2p_chat_create") 88 | def on_bot_event(bot, event_id, event, message, *args, **kwargs): 89 | parser.on_welcome(bot.app_id, event_id, event, message, **kwargs) 90 | 91 | 92 | @oauth.on_bot_event(event_type="oauth:user_info") 93 | def on_oauth_user_info(bot, event_id, user_info, *args, **kwargs): 94 | # oauth user_info 95 | print("oauth", user_info) 96 | # TODO save bind user 97 | session["user_id"] = user_info["union_id"] 98 | session.permanent = True 99 | # TODO ISV application 100 | if isinstance(bot, MarketBot): 101 | with app.app_context(): 102 | task = get_contact_by_lark_application.delay(bot.app_id) 103 | app.logger.info("try get_contact_by_lark_application %r", bot.app_id) 104 | return user_info 105 | 106 | 107 | app.register_blueprint(oauth.get_blueprint()) 108 | app.register_blueprint(hook.get_blueprint()) 109 | -------------------------------------------------------------------------------- /server/routes/team.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from app import app 5 | from flask import Blueprint, abort, jsonify, make_response, redirect, request, session 6 | from model.team import ( 7 | create_repo_chat_group_by_repo_id, 8 | get_application_info_by_team_id, 9 | get_im_user_by_team_id, 10 | get_team_by_id, 11 | get_team_list_by_user_id, 12 | get_team_member, 13 | get_team_repo, 14 | is_team_admin, 15 | save_im_application, 16 | save_team_contact, 17 | set_team_member, 18 | ) 19 | from tasks import get_contact_by_lark_application, get_status_by_id, pull_github_members 20 | from utils.auth import authenticated 21 | 22 | bp = Blueprint("team", __name__, url_prefix="/api/team") 23 | 24 | 25 | @bp.route("/", methods=["GET"]) 26 | @authenticated 27 | def get_team_list(): 28 | """ 29 | get team list 30 | TODO 31 | """ 32 | data, total = get_team_list_by_user_id(session["user_id"]) 33 | return jsonify({"code": 0, "msg": "success", "data": data, "total": total}) 34 | 35 | 36 | @bp.route("/", methods=["GET"]) 37 | @authenticated 38 | def get_team_detail(team_id): 39 | team = get_team_by_id(team_id, session["user_id"]) 40 | code_application, im_application = get_application_info_by_team_id(team_id) 41 | return jsonify( 42 | { 43 | "code": 0, 44 | "msg": "success", 45 | "data": { 46 | "team": team, 47 | "code_application": code_application, 48 | "im_application": im_application, 49 | }, 50 | } 51 | ) 52 | 53 | 54 | @bp.route("//member", methods=["GET"]) 55 | @authenticated 56 | def get_team_member_by_team_id(team_id): 57 | page = request.args.get("page", default=1, type=int) 58 | size = request.args.get("size", default=20, type=int) 59 | 60 | current_user = session["user_id"] 61 | data, total = get_team_member(team_id, current_user, page, size) 62 | return jsonify({"code": 0, "msg": "success", "data": data, "total": total}) 63 | 64 | 65 | @bp.route("///user", methods=["GET"]) 66 | @authenticated 67 | def get_im_user_by_team_id_and_platform(team_id, platform): 68 | page = request.args.get("page", default=1, type=int) 69 | size = request.args.get("size", default=20, type=int) 70 | 71 | if platform not in ["lark"]: # TODO lark/slack... 72 | return abort(400, "params error") 73 | 74 | current_user = session["user_id"] 75 | data, total = get_im_user_by_team_id(team_id, page, size) 76 | return jsonify( 77 | { 78 | "code": 0, 79 | "msg": "success", 80 | "data": [ 81 | { 82 | "value": i.id, 83 | "label": i.name or i.email, 84 | "email": i.email, 85 | "avatar": i.avatar, 86 | } 87 | for i in data 88 | ], 89 | "total": total, 90 | } 91 | ) 92 | 93 | 94 | @bp.route("//member", methods=["PUT"]) 95 | @authenticated 96 | def save_team_member_by_team_id(team_id): 97 | code_user_id = request.json.get("code_user_id") 98 | im_user_id = request.json.get("im_user_id") 99 | if not code_user_id or not im_user_id: 100 | return abort(400, "params error") 101 | 102 | current_user = session["user_id"] 103 | is_admin = is_team_admin(team_id, current_user) 104 | if current_user != code_user_id and not is_admin: 105 | return abort(400, "permission error") 106 | 107 | set_team_member(team_id, code_user_id, im_user_id) 108 | return jsonify({"code": 0, "msg": "success"}) 109 | 110 | 111 | @bp.route("//member", methods=["POST"]) 112 | @authenticated 113 | def refresh_team_member_by_team_id(team_id): 114 | code_application, _ = get_application_info_by_team_id(team_id) 115 | if not code_application: 116 | app.logger.error("code_application not found") 117 | return abort(400, "params error") 118 | 119 | team = get_team_by_id(team_id, session["user_id"]) 120 | 121 | task = pull_github_members.delay( 122 | code_application.installation_id, 123 | team.name, 124 | team_id, 125 | code_application.id, 126 | ) 127 | 128 | return jsonify({"code": 0, "msg": "success", "data": {"task_id": task.id}}) 129 | 130 | 131 | @bp.route("///app", methods=["POST"]) 132 | @authenticated 133 | def install_im_application_to_team(team_id, platform): 134 | # install lark app 135 | if platform not in ["lark"]: # TODO lark/slack... 136 | return abort(400, "params error") 137 | 138 | app_id = request.json.get("app_id") 139 | app_secret = request.json.get("app_secret") 140 | encrypt_key = request.json.get("encrypt_key") 141 | verification_token = request.json.get("verification_token") 142 | if not app_id or not app_secret: 143 | return abort(400, "params error") 144 | 145 | result = save_im_application( 146 | team_id, platform, app_id, app_secret, encrypt_key, verification_token 147 | ) 148 | app.logger.info("result %r", result) 149 | return jsonify({"code": 0, "msg": "success"}) 150 | 151 | 152 | @bp.route("///app", methods=["GET"]) 153 | @authenticated 154 | def install_im_application_to_team_by_get_method(team_id, platform): 155 | # install lark app 156 | if platform not in ["lark"]: # TODO lark/slack... 157 | return abort(400, "params error") 158 | 159 | redirect_uri = request.base_url 160 | if request.headers.get("X-Forwarded-Proto") == "https": 161 | redirect_uri = redirect_uri.replace("http://", "https://") 162 | app_id = request.args.get("app_id", "") 163 | name = request.args.get("name", "") 164 | if app_id: 165 | # 2. deploy server重定向过来:带app_id以及app_secret,保存,并带上redirect_uri重定向到deploy server 166 | app_secret = request.args.get("app_secret") 167 | if app_secret: 168 | encrypt_key = request.args.get("encrypt_key", "") 169 | verification_token = request.args.get("verification_token", "") 170 | if not app_id or not app_secret: 171 | return abort(400, "params error") 172 | 173 | result = save_im_application( 174 | team_id, platform, app_id, app_secret, encrypt_key, verification_token 175 | ) 176 | app.logger.info("result %r", result) 177 | events = [ 178 | "20", # 用户和机器人的会话首次被创建 179 | "im.message.message_read_v1", # 消息已读 180 | "im.message.reaction.created_v1", # 新增消息表情回复 181 | "im.message.reaction.deleted_v1", # 删除消息表情回复 182 | "im.message.recalled_v1", # 撤回消息 183 | "im.message.receive_v1", # 接收消息 184 | ] 185 | scope_ids = [ 186 | "8002", # 获取应用信息 187 | "100032", # 获取通讯录基本信息 188 | "6081", # 以应用身份读取通讯录 189 | "14", # 获取用户基本信息 190 | "1", # 获取用户邮箱信息 191 | "21001", # 获取与更新群组信息 192 | "20001", # 获取与发送单聊、群组消息 193 | "20011", # 获取用户在群组中 @ 机器人的消息 194 | "3001", # 接收群聊中 @ 机器人消息事件 195 | "20012", # 获取群组中所有消息 196 | "20010", # 获取用户发给机器人的单聊消息 197 | "3000", # 读取用户发给机器人的单聊消息 198 | "20008", # 获取单聊、群组消息 199 | "1000", # 以应用的身份发消息 200 | "20009", # 获取上传图片或文件资源 201 | ] 202 | hook_url = f"{os.environ.get('DOMAIN')}/api/feishu/hook/{app_id}" 203 | return redirect( 204 | f"{os.environ.get('LARK_DEPLOY_SERVER')}/publish?redirect_uri={redirect_uri}&app_id={app_id}&events={','.join(events)}&encrypt_key={encrypt_key}&verification_token={verification_token}&scopes={','.join(scope_ids)}&hook_url={hook_url}" 205 | ) 206 | else: 207 | # 3. deploy server只带app_id重定向过来:说明已经安装成功,这个时候通知前端成功 208 | if not name: 209 | return make_response( 210 | """ 211 | 222 | """, 223 | {"Content-Type": "text/html"}, 224 | ) 225 | 226 | # 1. 前端重定向过来:重定向到deploy server 227 | desc = request.args.get("desc", "") 228 | avatar = request.args.get( 229 | "avatar", 230 | "https://s1-imfile.feishucdn.com/static-resource/v1/v3_0074_5ae2ba69-5729-445e-afe7-4a19d1fb0a2g", 231 | ) 232 | if not name and not desc: 233 | return abort(400, "params error") 234 | # 如果传app_id就是更新app 235 | return redirect( 236 | f"{os.environ.get('LARK_DEPLOY_SERVER')}?redirect_uri={redirect_uri}&app_id={app_id}&name={name}&desc={desc}&avatar={avatar}" 237 | ) 238 | 239 | 240 | @bp.route("///user", methods=["POST"]) 241 | @authenticated 242 | def refresh_im_user_by_team_id_and_platform(team_id, platform): 243 | # trigger task 244 | if platform not in ["lark"]: # TODO lark/slack... 245 | return abort(400, "params error") 246 | _, im_application = get_application_info_by_team_id(team_id) 247 | task = get_contact_by_lark_application.delay(im_application.id) 248 | return jsonify({"code": 0, "msg": "success", "data": {"task_id": task.id}}) 249 | 250 | 251 | @bp.route("//task/", methods=["GET"]) 252 | @authenticated 253 | def get_task_result_by_id(team_id, task_id): 254 | # get task result 255 | task = get_status_by_id(task_id) 256 | return jsonify( 257 | { 258 | "code": 0, 259 | "msg": "success", 260 | "data": { 261 | "task_id": task.id, 262 | "status": task.status, 263 | "result": ( 264 | task.result if isinstance(task.result, list) else str(task.result) 265 | ), 266 | }, 267 | } 268 | ) 269 | 270 | 271 | @bp.route("//repo", methods=["GET"]) 272 | @authenticated 273 | def get_team_repo_by_team_id(team_id): 274 | page = request.args.get("page", default=1, type=int) 275 | size = request.args.get("size", default=20, type=int) 276 | 277 | current_user = session["user_id"] 278 | data, total = get_team_repo(team_id, current_user, page, size) 279 | return jsonify({"code": 0, "msg": "success", "data": data, "total": total}) 280 | 281 | 282 | @bp.route("//repo//chat", methods=["POST"]) 283 | @authenticated 284 | def create_repo_chat_group(team_id, repo_id): 285 | name = request.json.get("name") 286 | current_user = session["user_id"] 287 | create_repo_chat_group_by_repo_id(current_user, team_id, repo_id, name) 288 | return jsonify({"code": 0, "msg": "success"}) 289 | 290 | 291 | @bp.route("/contact", methods=["POST"]) 292 | @authenticated 293 | def _save_team_contact(): 294 | current_user = session["user_id"] 295 | first_name = request.json.get("first_name") 296 | last_name = request.json.get("last_name") 297 | email = request.json.get("email") 298 | role = request.json.get("role") 299 | newsletter = request.json.get("newsletter") 300 | contact_id = save_team_contact( 301 | current_user, first_name, last_name, email, role, newsletter 302 | ) 303 | session["contact_id"] = contact_id 304 | session.permanent = True 305 | return jsonify({"code": 0, "msg": "success"}) 306 | 307 | 308 | app.register_blueprint(bp) 309 | -------------------------------------------------------------------------------- /server/routes/user.py: -------------------------------------------------------------------------------- 1 | from app import app 2 | from flask import Blueprint, Response, abort, jsonify, request, session 3 | from model.team import ( 4 | get_application_info_by_team_id, 5 | get_team_list_by_user_id, 6 | is_team_admin, 7 | ) 8 | from model.user import get_user_by_id 9 | from tasks.lark.base import get_bot_by_application_id, get_repo_by_repo_id 10 | from utils.auth import authenticated 11 | from utils.utils import download_file 12 | 13 | bp = Blueprint("user", __name__, url_prefix="/api") 14 | 15 | 16 | @bp.route("/logout", methods=["GET"]) 17 | @authenticated 18 | def logout(): 19 | # clear session 20 | session.clear() 21 | return jsonify( 22 | { 23 | "code": 0, 24 | "msg": "success", 25 | } 26 | ) 27 | 28 | 29 | @bp.route("/account", methods=["GET"]) 30 | @authenticated 31 | def get_account(): 32 | current_user = session["user_id"] 33 | user = get_user_by_id(current_user) 34 | teams, _ = get_team_list_by_user_id(current_user) 35 | current_team = session.get("team_id") 36 | if not current_team and len(teams) > 0: 37 | current_team = teams[0].id 38 | is_admin = is_team_admin(current_team, current_user) if current_team else False 39 | return jsonify( 40 | { 41 | "code": 0, 42 | "msg": "success", 43 | "data": { 44 | "user": user, 45 | "current_team": current_team, 46 | "is_team_admin": is_admin, 47 | }, 48 | } 49 | ) 50 | 51 | 52 | @bp.route("/account", methods=["POST"]) 53 | @authenticated 54 | def set_account(): 55 | current_team = request.json.get("current_team") 56 | 57 | if current_team: 58 | session["team_id"] = current_team 59 | # 默认是会话级别的session,关闭浏览器直接就失效了 60 | session.permanent = True 61 | 62 | return jsonify({"code": 0, "msg": "success"}) 63 | 64 | 65 | @bp.route("////image/", methods=["GET"]) 66 | def get_image(team_id, message_id, repo_id, img_key): 67 | """ 68 | 1. 用 img_key 请求飞书接口下载 image 69 | 2. 判断请求来源,如果是 GitHub 调用,则直接返回 image 70 | 3. 用户调用 校验权限 71 | """ 72 | 73 | def download_and_respond(): 74 | _, im_application = get_application_info_by_team_id(team_id) 75 | bot, _ = get_bot_by_application_id(im_application.id) 76 | image_content = download_file(img_key, message_id, bot, "image") 77 | return Response(image_content, mimetype="image/png") 78 | 79 | # GitHub调用 80 | user_agent = request.headers.get("User-Agent") 81 | if user_agent and user_agent.startswith("github-camo"): 82 | return download_and_respond() 83 | 84 | # TODO 用户调用(弱需求, 通常来讲此接口不会被暴露), 需要进一步校验权限 85 | referer = request.headers.get("Referer") 86 | if not referer: 87 | # 公开仓库不校验 88 | repo = get_repo_by_repo_id(repo_id) 89 | if not repo: 90 | return abort(404, "Not found repo!") 91 | is_private = repo.extra.get("private", False) 92 | app.logger.debug(f"is_private: {is_private}") 93 | 94 | # 私有仓库校验,先登录 95 | if is_private: 96 | return abort(403, "Not support private repo!") 97 | 98 | return download_and_respond() 99 | 100 | 101 | app.register_blueprint(bp) 102 | -------------------------------------------------------------------------------- /server/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import env 4 | import routes 5 | from app import app 6 | 7 | if __name__ == "__main__": 8 | # gunicorn -w 1 -b :8888 "server:app" 9 | app.run(host=os.environ.get("HOST", "0.0.0.0"), port=os.environ.get("PORT", 8888)) 10 | -------------------------------------------------------------------------------- /server/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from celery import chain 4 | from celery_app import celery 5 | 6 | from .github import * 7 | from .lark import * 8 | 9 | celery.conf.beat_schedule = { 10 | # 定时拉取用户数据 11 | "get_contact_for_all_lark_application": { 12 | "task": "tasks.lark.lark.get_contact_for_all_lark_application", 13 | "schedule": timedelta(minutes=10), # 定时1minutes执行一次 14 | "args": (), # 函数传参的值 15 | }, 16 | # 定时拉取(更新)所有 GitHub 侧信息 17 | "pull_github_repo_all": { 18 | "task": "tasks.github.github.pull_github_repo_all", 19 | "schedule": timedelta(hours=24), 20 | "args": (), 21 | }, 22 | } 23 | 24 | 25 | def get_status_by_id(task_id): 26 | return celery.AsyncResult(task_id) 27 | -------------------------------------------------------------------------------- /server/tasks/github/__init__.py: -------------------------------------------------------------------------------- 1 | from .github import * 2 | from .issue import * 3 | from .organization import * 4 | from .pull_request import * 5 | from .push import * 6 | from .repo import * 7 | -------------------------------------------------------------------------------- /server/tasks/github/github.py: -------------------------------------------------------------------------------- 1 | from celery_app import celery 2 | from model.repo import create_repo_from_github 3 | from model.schema import CodeApplication, Team, db 4 | from utils.github.organization import GitHubAppOrg 5 | from utils.github.repo import GitHubAppRepo 6 | from utils.user import create_github_member 7 | 8 | 9 | @celery.task() 10 | def pull_github_repo( 11 | org_name: str, installation_id: str, application_id: str, team_id: str 12 | ): 13 | """Pull repo from GitHub, build Repo and RepoUser. 14 | 15 | Args: 16 | org_name: GitHub organization name. 17 | installation_id: GitHub App installation id. 18 | application_id: Code application id. 19 | """ 20 | # 获取 jwt 和 installation_token 21 | 22 | github_app = GitHubAppOrg(installation_id) 23 | 24 | # 拉取所有组织成员,创建 User 和 BindUser 25 | members = github_app.get_org_members(org_name) 26 | # org 只有一个人的时候,members = 0 27 | if members is None: 28 | raise Exception("Failed to get org members.") 29 | 30 | # 创建 user 和 team member 31 | create_github_member(members, application_id, team_id) 32 | 33 | # 拉取所有组织仓库,创建 Repo 34 | repos = github_app.get_org_repos_accessible() 35 | github_app = GitHubAppRepo(installation_id) 36 | try: 37 | for repo in repos: 38 | create_repo_from_github( 39 | repo=repo, 40 | org_name=org_name, 41 | application_id=application_id, 42 | github_app=github_app, 43 | ) 44 | 45 | except Exception as e: 46 | db.session.rollback() 47 | raise e 48 | 49 | 50 | @celery.task() 51 | def pull_github_members( 52 | installation_id: str, org_name: str, team_id: str, application_id: str = None 53 | ) -> list | None: 54 | """Background task to pull members from GitHub. 55 | 56 | Args: 57 | installation_id: GitHub App installation id. 58 | org_name: GitHub organization name. 59 | 60 | Returns: 61 | list: GitHub members. 62 | """ 63 | github_app = GitHubAppOrg(installation_id) 64 | 65 | members = github_app.get_org_members(org_name) 66 | 67 | create_github_member(members, application_id, team_id) 68 | return True 69 | 70 | 71 | @celery.task() 72 | def pull_github_repo_all(): 73 | """Pull all repo from GitHub, build Repo and RepoUser.""" 74 | 75 | task_ids = [] 76 | # 查询所有的 application 和对应的 team 77 | for team in db.session.query(Team).all(): 78 | application = CodeApplication.query.filter_by( 79 | team_id=team.id, status=0, platform="github" 80 | ).first() 81 | 82 | if application is None: 83 | continue 84 | 85 | task = pull_github_repo.delay( 86 | org_name=team.name, 87 | installation_id=application.installation_id, 88 | application_id=application.id, 89 | team_id=team.id, 90 | ) 91 | task_ids.append(task.id) 92 | 93 | return task_ids 94 | -------------------------------------------------------------------------------- /server/tasks/github/issue.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from app import app, db 4 | from celery_app import celery 5 | from model.schema import Issue, ObjID, PullRequest, Repo 6 | from tasks.github.repo import on_repository_updated 7 | from tasks.lark.issue import send_issue_card, send_issue_comment, update_issue_card 8 | from tasks.lark.pull_request import send_pull_request_comment 9 | from utils.github.model import IssueCommentEvent, IssueEvent 10 | 11 | 12 | @celery.task() 13 | def on_issue_comment(data: dict) -> list: 14 | """Parse and handle issue commit event. 15 | 16 | Args: 17 | data (dict): Payload from GitHub webhook. 18 | 19 | Returns: 20 | str: Celery task ID. 21 | """ 22 | try: 23 | event = IssueCommentEvent(**data) 24 | except Exception as e: 25 | app.logger.error(f"Failed to parse issue event: {e}") 26 | raise e 27 | 28 | if event.comment.performed_via_github_app and ( 29 | event.comment.performed_via_github_app.name 30 | ).replace(" ", "-") == (os.environ.get("GITHUB_APP_NAME")).replace(" ", "-"): 31 | return [] 32 | 33 | action = event.action 34 | match action: 35 | case "created": 36 | task = on_issue_comment_created.delay(event.model_dump()) 37 | return [task.id] 38 | case "edited": 39 | task = on_issue_comment_created.delay(event.model_dump()) 40 | return [task.id] 41 | case _: 42 | app.logger.info(f"Unhandled issue event action: {action}") 43 | return [] 44 | 45 | 46 | @celery.task() 47 | def on_issue_comment_created(event_dict: dict | list | None) -> list: 48 | """Handle issue comment created event. 49 | 50 | Send issue card message to Repo Owner. 51 | """ 52 | try: 53 | event = IssueCommentEvent(**event_dict) 54 | except Exception as e: 55 | app.logger.error(f"Failed to parse issue event: {e}") 56 | return [] 57 | 58 | repo = db.session.query(Repo).filter(Repo.repo_id == event.repository.id).first() 59 | if repo: 60 | if hasattr(event.issue, "pull_request") and event.issue.pull_request: 61 | pr = ( 62 | db.session.query(PullRequest) 63 | .filter( 64 | PullRequest.repo_id == repo.id, 65 | PullRequest.pull_request_number == event.issue.number, 66 | ) 67 | .first() 68 | ) 69 | if pr: 70 | task = send_pull_request_comment.delay( 71 | pr.id, event.comment.body, event.sender.login 72 | ) 73 | return [task.id] 74 | else: 75 | issue = ( 76 | db.session.query(Issue) 77 | .filter( 78 | Issue.repo_id == repo.id, 79 | Issue.issue_number == event.issue.number, 80 | ) 81 | .first() 82 | ) 83 | if issue: 84 | task = send_issue_comment.delay( 85 | issue.id, event.comment.body, event.sender.login 86 | ) 87 | return [task.id] 88 | 89 | return [] 90 | 91 | 92 | @celery.task() 93 | def on_issue(data: dict) -> list: 94 | """Parse and handle issue event. 95 | 96 | Args: 97 | data (dict): Payload from GitHub webhook. 98 | 99 | Returns: 100 | str: Celery task ID. 101 | """ 102 | try: 103 | event = IssueEvent(**data) 104 | except Exception as e: 105 | app.logger.error(f"Failed to parse issue event: {e}") 106 | raise e 107 | 108 | action = event.action 109 | match action: 110 | case "opened": 111 | task = on_issue_opened.delay(event.model_dump()) 112 | return [task.id] 113 | # TODO: 区分已关闭的 Issue 114 | case _: 115 | task = on_issue_updated.delay(event.model_dump()) 116 | # app.logger.info(f"Unhandled issue event action: {action}") 117 | return [task.id] 118 | 119 | 120 | @celery.task() 121 | def on_issue_opened(event_dict: dict | None) -> list: 122 | """Handle issue opened event. 123 | 124 | Send issue card message to Repo Owner. 125 | 126 | Args: 127 | event_dict (dict | None): Payload from GitHub webhook. 128 | 129 | Returns: 130 | list: Celery task ID. 131 | """ 132 | try: 133 | event = IssueEvent(**event_dict) 134 | except Exception as e: 135 | app.logger.error(f"Failed to parse issue event: {e}") 136 | return [] 137 | 138 | issue_info = event.issue 139 | 140 | repo = db.session.query(Repo).filter(Repo.repo_id == event.repository.id).first() 141 | if not repo: 142 | app.logger.error(f"Failed to find repo: {event_dict}") 143 | return [] 144 | # 检查是否已经创建过 issue 145 | issue = ( 146 | db.session.query(Issue) 147 | .filter(Issue.repo_id == repo.id, Issue.issue_number == issue_info.number) 148 | .first() 149 | ) 150 | if issue: 151 | app.logger.info(f"Issue already exists: {issue.id}") 152 | return [] 153 | 154 | # 限制 body 长度 155 | issue_info.body = issue_info.body[:1000] if issue_info.body else None 156 | 157 | # 创建 issue 158 | new_issue = Issue( 159 | id=ObjID.new_id(), 160 | repo_id=repo.id, 161 | issue_number=issue_info.number, 162 | title=issue_info.title, 163 | # TODO 这里超过1024的长度了,暂时不想单纯的增加字段长度,因为飞书那边消息也是有限制的 164 | description=issue_info.body, 165 | extra=issue_info.model_dump(), 166 | ) 167 | db.session.add(new_issue) 168 | db.session.commit() 169 | 170 | task = send_issue_card.delay(issue_id=new_issue.id) 171 | 172 | # 新建issue之后也要更新 repo info 173 | on_repository_updated(event.model_dump()) 174 | 175 | return [task.id] 176 | 177 | 178 | @celery.task() 179 | def on_issue_updated(event_dict: dict) -> list: 180 | try: 181 | event = IssueEvent(**event_dict) 182 | except Exception as e: 183 | app.logger.error(f"Failed to parse issue event: {e}") 184 | return [] 185 | 186 | issue_info = event.issue 187 | 188 | repo = db.session.query(Repo).filter(Repo.repo_id == event.repository.id).first() 189 | if not repo: 190 | app.logger.error(f"Failed to find repo: {event_dict}") 191 | return [] 192 | # 修改 issue 193 | issue = ( 194 | db.session.query(Issue) 195 | .filter(Issue.repo_id == repo.id, Issue.issue_number == issue_info.number) 196 | .first() 197 | ) 198 | 199 | if issue: 200 | issue.title = issue_info.title 201 | issue.description = issue_info.body[:1000] if issue_info.body else None 202 | issue.extra = issue_info.model_dump() 203 | 204 | db.session.commit() 205 | 206 | else: 207 | app.logger.error(f"Failed to find issue: {event_dict}") 208 | return [] 209 | 210 | task = update_issue_card.delay(issue.id) 211 | 212 | return [task.id] 213 | -------------------------------------------------------------------------------- /server/tasks/github/organization.py: -------------------------------------------------------------------------------- 1 | from app import app, db 2 | from celery_app import celery 3 | from model.schema import CodeApplication, Team 4 | from tasks.github.github import pull_github_repo 5 | from utils.github.model import OrganizationEvent 6 | 7 | 8 | @celery.task 9 | def on_organization(event_dict: dict | None): 10 | try: 11 | event = OrganizationEvent(**event_dict) 12 | except Exception as e: 13 | app.logger.error(f"Failed to parse Organization event: {e}") 14 | return [] 15 | 16 | action = event.action 17 | match action: 18 | case "member_added": 19 | task = on_organization_member_added.delay(event.model_dump()) 20 | return [task.id] 21 | # case "member_removed": 22 | # task = on_organization_member_removed.delay(event.model_dump()) 23 | # return [task.id] 24 | case _: 25 | app.logger.info(f"Unhandled Organization event action: {action}") 26 | return [] 27 | 28 | 29 | @celery.task() 30 | def on_organization_member_added(event_dict: dict) -> list: 31 | """Handle Organization member added event. 32 | 33 | Send Organization member added message to Repo Owner. 34 | 35 | Args: 36 | event_dict (dict): The dict of the event. 37 | 38 | Returns: 39 | list: The list of task id. 40 | """ 41 | try: 42 | event = OrganizationEvent(**event_dict) 43 | except Exception as e: 44 | app.logger.error(f"Failed to parse Organization event: {e}") 45 | return [] 46 | 47 | # 根据 installation id 查询 48 | installation_id = event.installation.id 49 | code_application = ( 50 | db.session.query(CodeApplication) 51 | .filter_by(installation_id=installation_id) 52 | .first() 53 | ) 54 | 55 | if code_application is None: 56 | app.logger.error(f"Failed to get code application: {installation_id}") 57 | return [] 58 | 59 | team = db.session.query(Team).filter_by(id=code_application.team_id).first() 60 | if team is None: 61 | app.logger.error(f"Failed to get team: {code_application.team_id}") 62 | return [] 63 | 64 | task = pull_github_repo.delay( 65 | org_name=team.name, 66 | installation_id=code_application.installation_id, 67 | application_id=code_application.id, 68 | team_id=team.id, 69 | ) 70 | 71 | return [task.id] 72 | -------------------------------------------------------------------------------- /server/tasks/github/pull_request.py: -------------------------------------------------------------------------------- 1 | from app import app, db 2 | from celery_app import celery 3 | from model.schema import ObjID, PullRequest, Repo 4 | from tasks.lark.pull_request import send_pull_request_card, update_pull_request_card 5 | from utils.github.model import PullRequestEvent 6 | 7 | 8 | @celery.task() 9 | def on_pull_request(data: dict) -> list: 10 | """Parse and handle PullRequest event. 11 | 12 | Args: 13 | data (dict): Payload from GitHub webhook. 14 | 15 | Returns: 16 | str: Celery task ID. 17 | """ 18 | try: 19 | event = PullRequestEvent(**data) 20 | except Exception as e: 21 | app.logger.error(f"Failed to parse PullRequest event: {e}") 22 | raise e 23 | 24 | action = event.action 25 | match action: 26 | case "opened": 27 | task = on_pull_request_opened.delay(event.model_dump()) 28 | return [task.id] 29 | case _: 30 | task = on_pull_request_updated.delay(event.model_dump()) 31 | app.logger.info(f"Unhandled PullRequest event action: {action}") 32 | return [] 33 | 34 | 35 | @celery.task() 36 | def on_pull_request_opened(event_dict: dict | list | None) -> list: 37 | """Handle PullRequest opened event. 38 | 39 | Send PullRequest card message to Repo Owner. 40 | """ 41 | try: 42 | event = PullRequestEvent(**event_dict) 43 | except Exception as e: 44 | app.logger.error(f"Failed to parse PullRequest event: {e}") 45 | return [] 46 | 47 | pr_info = event.pull_request 48 | 49 | repo = db.session.query(Repo).filter(Repo.repo_id == event.repository.id).first() 50 | if not repo: 51 | app.logger.error(f"Failed to find repo: {event_dict}") 52 | return [] 53 | # 检查是否已经创建过 pullrequest 54 | pr = ( 55 | db.session.query(PullRequest) 56 | .filter( 57 | PullRequest.repo_id == repo.id, 58 | PullRequest.pull_request_number == pr_info.number, 59 | ) 60 | .first() 61 | ) 62 | if pr: 63 | app.logger.info(f"PullRequest already exists: {pr.id}") 64 | return [] 65 | 66 | # 限制 body 长度 67 | pr_info.body = pr_info.body[:1000] if pr_info.body else "" 68 | 69 | # 创建 pullrequest 70 | new_pr = PullRequest( 71 | id=ObjID.new_id(), 72 | repo_id=repo.id, 73 | pull_request_number=pr_info.number, 74 | title=pr_info.title, 75 | description=pr_info.body, 76 | base=pr_info.base.ref, 77 | head=pr_info.head.ref, 78 | state=pr_info.state, 79 | extra=pr_info.model_dump(), 80 | ) 81 | db.session.add(new_pr) 82 | db.session.commit() 83 | 84 | task = send_pull_request_card.delay(new_pr.id) 85 | 86 | return [task.id] 87 | 88 | 89 | @celery.task() 90 | def on_pull_request_updated(event_dict: dict) -> list: 91 | """Handle PullRequest updated event. 92 | 93 | Send PullRequest card message to Repo Owner. 94 | """ 95 | try: 96 | event = PullRequestEvent(**event_dict) 97 | except Exception as e: 98 | app.logger.error(f"Failed to parse PullRequest event: {e}") 99 | return [] 100 | 101 | repo = db.session.query(Repo).filter(Repo.repo_id == event.repository.id).first() 102 | if repo is None: 103 | app.logger.error(f"Failed to find Repo: {event.repository.id}") 104 | return [] 105 | 106 | pr = ( 107 | db.session.query(PullRequest) 108 | .filter(PullRequest.repo_id == repo.id) 109 | .filter(PullRequest.pull_request_number == event.pull_request.number) 110 | .first() 111 | ) 112 | if pr: 113 | pr.title = event.pull_request.title 114 | pr.description = ( 115 | event.pull_request.body[:1000] if event.pull_request.body else "" 116 | ) 117 | pr.base = event.pull_request.base.ref 118 | pr.head = event.pull_request.head.ref 119 | pr.state = event.pull_request.state 120 | pr.extra = event.pull_request.model_dump() 121 | db.session.commit() 122 | 123 | task = update_pull_request_card.delay(pr.id) 124 | 125 | return [task.id] 126 | else: 127 | app.logger.error(f"Failed to find PullRequest: {event.pull_request.number}") 128 | return [] 129 | -------------------------------------------------------------------------------- /server/tasks/github/push.py: -------------------------------------------------------------------------------- 1 | from app import app, db 2 | from celery_app import celery 3 | from model.schema import ChatGroup, PullRequest, Repo 4 | from tasks.lark.base import get_bot_by_application_id 5 | from utils.github.model import PushEvent 6 | from utils.lark.pr_tip_commit_history import PrTipCommitHistory 7 | 8 | 9 | @celery.task() 10 | def on_push(data: dict | None) -> list: 11 | try: 12 | event = PushEvent(**data) 13 | except Exception as e: 14 | app.logger.error(f"Failed to parse PullRequest event: {e}") 15 | raise e 16 | 17 | # 查找有没有对应的 repo 18 | repo = db.session.query(Repo).filter(Repo.repo_id == event.repository.id).first() 19 | if not repo: 20 | app.logger.info(f"Repo not found: {event.repository.name}") 21 | return [] 22 | 23 | pr = ( 24 | db.session.query(PullRequest) 25 | .filter( 26 | PullRequest.repo_id == repo.id, 27 | PullRequest.head == "/".join((event.ref).split("/")[2:]), 28 | PullRequest.state == "open", 29 | ) 30 | .first() 31 | ) 32 | 33 | if pr is None: 34 | app.logger.info(f"PullRequest not found: {repo.name} {event.ref}") 35 | return [] 36 | 37 | # 发送 Commit Log 信息 38 | chat_group = ( 39 | db.session.query(ChatGroup).filter(ChatGroup.id == repo.chat_group_id).first() 40 | ) 41 | if not chat_group: 42 | app.logger.info(f"ChatGroup not found: {repo.name}") 43 | return [] 44 | 45 | bot, application = get_bot_by_application_id(chat_group.im_application_id) 46 | reply_result = bot.reply( 47 | pr.message_id, 48 | PrTipCommitHistory( 49 | commits=event.commits, 50 | ), 51 | ) 52 | app.logger.info(f"Reply result: {reply_result}") 53 | return reply_result.json() 54 | -------------------------------------------------------------------------------- /server/tasks/github/repo.py: -------------------------------------------------------------------------------- 1 | from app import app, db 2 | from celery_app import celery 3 | from model.repo import create_repo_from_github 4 | from model.schema import ( 5 | BindUser, 6 | CodeApplication, 7 | IMApplication, 8 | Repo, 9 | RepoUser, 10 | Team, 11 | TeamMember, 12 | ) 13 | from tasks.lark import update_repo_info 14 | from tasks.lark.manage import send_detect_repo 15 | from utils.github.model import ForkEvent, RepoEvent, StarEvent 16 | from utils.github.repo import GitHubAppRepo 17 | 18 | 19 | @celery.task() 20 | def on_repository(data: dict) -> list: 21 | """Parse and handle repository event. 22 | 23 | Args: 24 | data (dict): Payload from GitHub webhook. 25 | 26 | Returns: 27 | str: Celery task ID. 28 | """ 29 | try: 30 | event = RepoEvent(**data) 31 | except Exception as e: 32 | app.logger.error(f"Failed to parse repository event: {e}") 33 | raise e 34 | 35 | action = event.action 36 | match action: 37 | case "created": 38 | task = on_repository_created.delay(event.model_dump()) 39 | return [task.id] 40 | case _: 41 | task = on_repository_updated.delay(event.model_dump()) 42 | return [] 43 | 44 | 45 | @celery.task() 46 | def on_repository_created(event_dict: dict | list | None) -> list: 47 | """Handle repository created event. 48 | 49 | Send message to Repo Owner and create chat group for repo. 50 | """ 51 | try: 52 | event = RepoEvent(**event_dict) 53 | except Exception as e: 54 | app.logger.error(f"Failed to parse repository event: {e}") 55 | return [] 56 | 57 | github_app = GitHubAppRepo(str(event.installation.id)) 58 | 59 | # repo_info = github_app.get_repo_info(event.repository.id) 60 | repo_info = event.repository.model_dump() 61 | 62 | code_application = ( 63 | db.session.query(CodeApplication) 64 | .filter( 65 | CodeApplication.installation_id == str(event.installation.id), 66 | CodeApplication.status == 0, 67 | ) 68 | .first() 69 | ) 70 | 71 | team = ( 72 | db.session.query(Team) 73 | .filter( 74 | Team.id == code_application.team_id, 75 | Team.status == 0, 76 | ) 77 | .first() 78 | ) 79 | if team is None: 80 | app.logger.error(f"Team {code_application.team_id} not found") 81 | return [] 82 | 83 | # 创建 repo,同时创建配套的 repo_user 84 | new_repo = create_repo_from_github( 85 | repo=repo_info, 86 | org_name=team.name, 87 | application_id=code_application.id, 88 | github_app=github_app, 89 | ) 90 | 91 | # 查找 RepoUser 中具有 admin 权限的用户 92 | admin_github_bind_users = ( 93 | db.session.query(BindUser) 94 | .join( 95 | RepoUser, 96 | RepoUser.bind_user_id == BindUser.id, 97 | ) 98 | .filter( 99 | RepoUser.repo_id == new_repo.id, 100 | RepoUser.permission == "admin", 101 | BindUser.platform == "github", 102 | ) 103 | .all() 104 | ) 105 | 106 | if len(admin_github_bind_users) == 0: 107 | app.logger.error(f"Repo {new_repo.id} has no github admin user") 108 | return [] 109 | 110 | # 从 github_bind_users 中筛选出 lark_bind_users 111 | admin_lark_bind_users = ( 112 | db.session.query(BindUser) 113 | .join( 114 | TeamMember, 115 | TeamMember.im_user_id == BindUser.id, 116 | ) 117 | .filter( 118 | TeamMember.team_id == team.id, 119 | TeamMember.code_user_id.in_([user.id for user in admin_github_bind_users]), 120 | TeamMember.status == 0, 121 | BindUser.status == 0, 122 | ) 123 | .all() 124 | ) 125 | 126 | if len(admin_lark_bind_users) == 0: 127 | app.logger.error(f"Repo {new_repo.id} has no lark admin user") 128 | return [] 129 | 130 | # 查找 im application 131 | im_application = ( 132 | db.session.query(IMApplication) 133 | .filter( 134 | IMApplication.team_id == team.id, 135 | ) 136 | .first() 137 | ) 138 | 139 | task_ids = [] 140 | for bind_user in admin_lark_bind_users: 141 | task = send_detect_repo.delay( 142 | repo_id=new_repo.id, 143 | app_id=im_application.app_id, 144 | open_id=bind_user.openid, 145 | topics=repo_info.get("topics", []), 146 | visibility="Private" if repo_info.get("private") else "Public", 147 | ) 148 | 149 | task_ids.append(task.id) 150 | 151 | return task_ids 152 | 153 | 154 | @celery.task() 155 | def on_star(data: dict) -> list: 156 | """Handler for repository starred event. 157 | 158 | Args: 159 | data (dict): Payload from GitHub webhook. 160 | 161 | Returns: 162 | str: Celery task ID. 163 | """ 164 | try: 165 | event = StarEvent(**data) 166 | except Exception as e: 167 | app.logger.error(f"Failed to parse star event: {e}") 168 | raise e 169 | 170 | task = on_repository_updated.delay(event.model_dump()) 171 | 172 | return [task.id] 173 | 174 | 175 | @celery.task() 176 | def on_fork(data: dict) -> list: 177 | """Handler for repository starred event. 178 | 179 | Args: 180 | data (dict): Payload from GitHub webhook. 181 | 182 | Returns: 183 | str: Celery task ID. 184 | """ 185 | try: 186 | event = ForkEvent(**data) 187 | except Exception as e: 188 | app.logger.error(f"Failed to parse fork event: {e}") 189 | raise e 190 | 191 | # fork 事件没有action属性,先暂时添加一个 192 | # TODO unfork 事件实际是 delete repo事件,比较复杂,需求比较边缘,目前还没实现,暂且放着 193 | event.action = "fork" 194 | task = on_repository_updated.delay(event.model_dump()) 195 | 196 | return [task.id] 197 | 198 | 199 | @celery.task() 200 | def on_repository_updated(event_dict: dict | None) -> list[str]: 201 | """Handler for repository update. 202 | 203 | Update info for repo ino card. 204 | 205 | Args: 206 | event_dict (dict): Payload from GitHub webhook. 207 | 208 | Returns: 209 | list[str]: Celery task IDs. 210 | """ 211 | 212 | try: 213 | event = RepoEvent(**event_dict) 214 | except Exception as e: 215 | app.logger.error(f"Failed to parse repository event: {e}") 216 | return [] 217 | 218 | # 更新数据库 219 | repo = ( 220 | db.session.query(Repo) 221 | .filter( 222 | Repo.repo_id == event.repository.id, 223 | ) 224 | .first() 225 | ) 226 | 227 | if repo is None: 228 | app.logger.error(f"Repo {event.repository.id} not found") 229 | return [] 230 | 231 | repo.name = event.repository.name 232 | repo.description = event.repository.description 233 | repo.extra = event.repository.model_dump() 234 | 235 | db.session.commit() 236 | 237 | task = update_repo_info.delay(repo.id) 238 | 239 | return [task.id] 240 | -------------------------------------------------------------------------------- /server/tasks/lark/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from .chat import * 3 | from .issue import * 4 | from .lark import * 5 | from .manage import * 6 | from .pull_request import * 7 | from .repo import * 8 | -------------------------------------------------------------------------------- /server/tasks/lark/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from functools import wraps 5 | 6 | from connectai.lark.sdk import Bot 7 | from model.schema import ChatGroup, IMApplication, Issue, ObjID, PullRequest, Repo, db 8 | from sqlalchemy import or_ 9 | from utils.constant import GitHubPermissionError 10 | from utils.redis import RedisStorage 11 | 12 | 13 | def get_chat_group_by_chat_id(chat_id): 14 | chat_group = ( 15 | db.session.query(ChatGroup) 16 | .filter( 17 | ChatGroup.chat_id == chat_id, 18 | ChatGroup.status == 0, 19 | ) 20 | .first() 21 | ) 22 | 23 | return chat_group 24 | 25 | 26 | def get_repo_name_by_repo_id(repo_id): 27 | repo = get_repo_by_repo_id(repo_id) 28 | return repo.name 29 | 30 | 31 | def get_repo_by_repo_id(repo_id): 32 | repo = ( 33 | db.session.query(Repo) 34 | .filter( 35 | Repo.id == repo_id, 36 | Repo.status == 0, 37 | ) 38 | .first() 39 | ) 40 | return repo 41 | 42 | 43 | def get_bot_by_application_id(app_id): 44 | application = ( 45 | db.session.query(IMApplication) 46 | .filter( 47 | or_( 48 | IMApplication.app_id == app_id, 49 | IMApplication.id == app_id, 50 | ) 51 | ) 52 | .first() 53 | ) 54 | if application: 55 | return ( 56 | Bot( 57 | app_id=application.app_id, 58 | app_secret=application.app_secret, 59 | encrypt_key=application.extra.get("encrypt_key"), 60 | verification_token=application.extra.get("verification_token"), 61 | storage=RedisStorage(), 62 | ), 63 | application, 64 | ) 65 | return None, None 66 | 67 | 68 | def get_git_object_by_message_id(message_id): 69 | """ 70 | 根据message_id区分Repo、Issue、PullRequest对象 71 | 72 | 参数: 73 | message_id:消息ID 74 | 75 | 返回值: 76 | repo:Repo对象,如果存在 77 | issue:Issue对象,如果存在 78 | pr:PullRequest对象,如果存在 79 | """ 80 | issue = ( 81 | db.session.query(Issue) 82 | .filter( 83 | Issue.message_id == message_id, 84 | ) 85 | .first() 86 | ) 87 | if issue: 88 | return None, issue, None 89 | pr = ( 90 | db.session.query(PullRequest) 91 | .filter( 92 | PullRequest.message_id == message_id, 93 | ) 94 | .first() 95 | ) 96 | if pr: 97 | return None, None, pr 98 | repo = ( 99 | db.session.query(Repo) 100 | .filter( 101 | Repo.message_id == message_id, 102 | ) 103 | .first() 104 | ) 105 | if repo: 106 | return repo, None, None 107 | 108 | return None, None, None 109 | 110 | 111 | def with_authenticated_github(): 112 | def decorate(func): 113 | @wraps(func) 114 | def wrapper(*args, **kwargs): 115 | """ 116 | 1. 这个装饰器用来统一处理错误消息 117 | 2. github rest api调用出错的时候抛出异常 118 | 3. 这个装饰器捕获特定的异常,给操作者特定的报错消息 119 | """ 120 | try: 121 | return func(*args, **kwargs) 122 | except GitHubPermissionError as e: 123 | try: 124 | from .manage import send_manage_fail_message 125 | 126 | app_id, message_id, content, raw_message = args[-4:] 127 | host = os.environ.get("DOMAIN") 128 | send_manage_fail_message( 129 | f"[请点击绑定 GitHub 账号后重试]({host}/api/github/oauth)", 130 | app_id, 131 | message_id, 132 | content, 133 | raw_message, 134 | ) 135 | except Exception as e: 136 | logging.error(e) 137 | except Exception as e: 138 | raise e 139 | 140 | return wrapper 141 | 142 | return decorate 143 | -------------------------------------------------------------------------------- /server/tasks/lark/lark.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from celery_app import app, celery 4 | from model.schema import ( 5 | BindUser, 6 | ChatGroup, 7 | CodeApplication, 8 | IMApplication, 9 | ObjID, 10 | Repo, 11 | Team, 12 | User, 13 | db, 14 | ) 15 | from sqlalchemy import func, or_ 16 | from utils.lark.manage_manual import ManageManual 17 | 18 | from .base import get_bot_by_application_id 19 | 20 | 21 | def get_contact_by_bot_and_department(bot, department_id): 22 | page_token, page_size = "", 50 23 | while True: 24 | url = f"{bot.host}/open-apis/contact/v3/users/find_by_department?page_token={page_token}&page_size={page_size}&department_id={department_id}" 25 | result = bot.get(url).json() 26 | for department_user_info in result.get("data", {}).get("items", []): 27 | yield department_user_info 28 | has_more = result.get("data", {}).get("has_more") 29 | if not has_more: 30 | break 31 | page_token = result.get("data", {}).get("page_token", "") 32 | 33 | 34 | def get_contact_by_bot(bot): 35 | page_token, page_size = "", 100 36 | while True: 37 | url = f"{bot.host}/open-apis/contact/v3/scopes?page_token={page_token}&page_size={page_size}" 38 | result = bot.get(url).json() 39 | for open_id in result.get("data", {}).get("user_ids", []): 40 | # https://open.feishu.cn/open-apis/contact/v3/users/:user_id 41 | user_info_url = ( 42 | f"{bot.host}/open-apis/contact/v3/users/{open_id}?user_id_type=open_id" 43 | ) 44 | user_info = bot.get(user_info_url).json() 45 | if user_info.get("data", {}).get("user"): 46 | yield user_info.get("data", {}).get("user") 47 | else: 48 | app.logger.error("can not get user_info %r", user_info) 49 | 50 | for department_id in result.get("data", {}).get("department_ids", []): 51 | for department_user_info in get_contact_by_bot_and_department( 52 | bot, department_id 53 | ): 54 | yield department_user_info 55 | 56 | has_more = result.get("data", {}).get("has_more") 57 | if not has_more: 58 | break 59 | page_token = result.get("data", {}).get("page_token", "") 60 | 61 | 62 | @celery.task() 63 | def get_contact_by_lark_application(application_id): 64 | """ 65 | 1. 按application_id找到application 66 | 2. 获取所有能用当前应用的人员 67 | 3. 尝试创建bind_user + user 68 | 4. 标记已经拉取过应用人员 69 | """ 70 | user_ids = [] 71 | bot, application = get_bot_by_application_id(application_id) 72 | if application: 73 | try: 74 | for item in get_contact_by_bot(bot): 75 | # add bind_user and user 76 | bind_user_id = ( 77 | db.session.query(BindUser.id) 78 | .filter( 79 | BindUser.openid == item["open_id"], 80 | BindUser.status == 0, 81 | ) 82 | .limit(1) 83 | .scalar() 84 | ) 85 | if not bind_user_id: 86 | user_id = ( 87 | db.session.query(User.id) 88 | .filter( 89 | User.unionid == item["union_id"], 90 | User.status == 0, 91 | ) 92 | .limit(1) 93 | .scalar() 94 | ) 95 | if not user_id: 96 | user_id = ObjID.new_id() 97 | user = User( 98 | id=user_id, 99 | unionid=item["union_id"], 100 | name=item["name"], 101 | avatar=item["avatar"]["avatar_origin"], 102 | ) 103 | db.session.add(user) 104 | db.session.flush() 105 | bind_user_id = ObjID.new_id() 106 | bind_user = BindUser( 107 | id=bind_user_id, 108 | user_id=user_id, 109 | platform="lark", 110 | application_id=application_id, 111 | unionid=item["union_id"], 112 | openid=item["open_id"], 113 | name=item["name"], 114 | avatar=item["avatar"]["avatar_origin"], 115 | extra=item, 116 | ) 117 | db.session.add(bind_user) 118 | db.session.commit() 119 | user_ids.append(bind_user_id) 120 | db.session.query(IMApplication).filter( 121 | IMApplication.id == application.id, 122 | ).update(dict(status=1)) 123 | db.session.commit() 124 | except Exception as e: 125 | # can not get contacts 126 | app.logger.exception(e) 127 | 128 | return user_ids 129 | 130 | 131 | @celery.task() 132 | def get_contact_for_all_lark_application(): 133 | for application in db.session.query(IMApplication).filter( 134 | IMApplication.status == 0, 135 | ): 136 | user_ids = get_contact_by_lark_application(application.id) 137 | app.logger.info( 138 | "success to get_contact_fo_lark_application %r %r", 139 | application.id, 140 | len(user_ids), 141 | ) 142 | -------------------------------------------------------------------------------- /server/utils/auth.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from flask import abort, session 4 | 5 | 6 | def authenticated(func): 7 | @wraps(func) 8 | def wrapper(*args, **kwargs): 9 | if not session.get("user_id"): 10 | return abort(401) 11 | return func(*args, **kwargs) 12 | 13 | return wrapper 14 | -------------------------------------------------------------------------------- /server/utils/constant.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ErrorMsg(Enum): 5 | APP_NOT_FOUND = "找不到对应的应用" 6 | REPO_CHAT_GROUP_NOT_FOUND = "找不到项目群" 7 | REPO_NOT_FOUND = "找不到项目群对应项目" 8 | INVALID_INPUT = "输入无效" 9 | OPERATION_FAILED = "操作失败" 10 | 11 | 12 | class SuccessMsg(Enum): 13 | OPERATION_SUCCESS = "操作成功" 14 | 15 | 16 | class TopicType(Enum): 17 | REPO = "repo" 18 | ISSUE = "issue" 19 | PR = "pull_request" 20 | PULL_REQUEST = "pull_request" 21 | CHAT = "chat" 22 | 23 | 24 | class GitHubPermissionError(Exception): 25 | pass 26 | 27 | 28 | MAX_COMMIT_MESSAGE_LENGTH = 40 29 | -------------------------------------------------------------------------------- /server/utils/github/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConnectAI-E/GitMaya/8333533f8dc852e6d940b12b674d23523c0fb21d/server/utils/github/__init__.py -------------------------------------------------------------------------------- /server/utils/github/account.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from app import app 3 | from utils.github.bot import BaseGitHubApp 4 | 5 | 6 | class GitHubAppAccount(BaseGitHubApp): 7 | def __init__(self, installation_id: str = None, user_id: str = None) -> None: 8 | super().__init__(installation_id, user_id) 9 | 10 | def _get_user_info(self): 11 | """Get user info. 12 | 13 | Returns: 14 | dict: User info. 15 | """ 16 | 17 | return get_user_info(self.user_token) 18 | 19 | def _get_email(self): 20 | """Get user email. 21 | 22 | Returns: 23 | str: User email. 24 | """ 25 | 26 | return get_email(self.user_token) 27 | 28 | 29 | def get_user_info(access_token: str) -> dict | None: 30 | """Get user info by access token. 31 | 32 | Args: 33 | access_token (str): The user access token. 34 | 35 | Returns: 36 | dict: User info. 37 | """ 38 | 39 | with httpx.Client( 40 | timeout=httpx.Timeout(10.0, connect=10.0, read=10.0), 41 | transport=httpx.HTTPTransport(retries=3), 42 | ) as client: 43 | response = client.get( 44 | "https://api.github.com/user", 45 | headers={ 46 | "Accept": "application/vnd.github.v3+json", 47 | "Authorization": f"token {access_token}", 48 | }, 49 | ) 50 | if response.status_code != 200: 51 | app.logger.debug(f"Failed to get user info. {response.text}") 52 | return None 53 | 54 | user_info = response.json() 55 | return user_info 56 | 57 | app.logger.debug("Failed to get user info.") 58 | return None 59 | 60 | 61 | def get_email(access_token: str) -> str | None: 62 | """Get user email by access token. 63 | 64 | Args: 65 | access_token (str): The user access token. 66 | 67 | Returns: 68 | str: User email. 69 | """ 70 | 71 | with httpx.Client( 72 | timeout=httpx.Timeout(10.0, connect=10.0, read=10.0), 73 | transport=httpx.HTTPTransport(retries=3), 74 | ) as client: 75 | response = client.get( 76 | "https://api.github.com/user/emails", 77 | headers={ 78 | "Accept": "application/vnd.github.v3+json", 79 | "Authorization": f"Bearer {access_token}", 80 | "X-GitHub-Api-Version": "2022-11-28", 81 | }, 82 | ) 83 | if response.status_code != 200: 84 | app.logger.debug(f"Failed to get user email. {response.text}") 85 | return None 86 | 87 | user_emails = response.json() 88 | if len(user_emails) == 0: 89 | app.logger.debug("Failed to get user email.") 90 | return None 91 | 92 | for user_email in user_emails: 93 | if user_email["primary"]: 94 | return user_email["email"] 95 | 96 | return user_emails[0]["email"] 97 | 98 | app.logger.debug("Failed to get user email.") 99 | return None 100 | -------------------------------------------------------------------------------- /server/utils/github/application.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import os 4 | from functools import wraps 5 | from urllib.parse import parse_qs 6 | 7 | import httpx 8 | from app import app 9 | from flask import abort, request 10 | 11 | 12 | def oauth_by_code(code: str) -> dict | None: 13 | """Register by code 14 | 15 | Args: 16 | code (str): The code returned by GitHub OAuth. 17 | 18 | Returns: 19 | str: The user access token. 20 | """ 21 | 22 | with httpx.Client( 23 | timeout=httpx.Timeout(10.0, connect=10.0, read=10.0), 24 | transport=httpx.HTTPTransport(retries=3), 25 | ) as client: 26 | response = client.post( 27 | "https://github.com/login/oauth/access_token", 28 | params={ 29 | "client_id": os.environ.get("GITHUB_CLIENT_ID"), 30 | "client_secret": os.environ.get("GITHUB_CLIENT_SECRET"), 31 | "code": code, 32 | }, 33 | ) 34 | if response.status_code != 200: 35 | app.logger.debug(f"Failed to get access token. {response.text}") 36 | return None 37 | 38 | try: 39 | oauth_info = parse_qs(response.text) 40 | except Exception as e: 41 | app.logger.debug(e) 42 | return None 43 | 44 | return oauth_info 45 | 46 | 47 | def verify_github_signature( 48 | secret: str = os.environ.get("GITHUB_WEBHOOK_SECRET", "secret") 49 | ): 50 | """Decorator to verify the signature of a GitHub webhook request. 51 | 52 | Args: 53 | secret (str): The secret key used to sign the webhook request. 54 | 55 | Returns: 56 | function: The decorated function. 57 | """ 58 | 59 | def decorator(func): 60 | @wraps(func) 61 | def wrapper(*args, **kwargs): 62 | signature = request.headers.get("x-hub-signature-256") 63 | if not signature: 64 | return func(*args, **kwargs) 65 | 66 | # Verify the signature 67 | body = request.get_data() 68 | 69 | hash_object = hmac.new( 70 | secret.encode("utf-8"), 71 | msg=body, 72 | digestmod=hashlib.sha256, 73 | ) 74 | expected_signature = "sha256=" + hash_object.hexdigest() 75 | 76 | app.logger.debug(f"{expected_signature} {signature}") 77 | 78 | if not hmac.compare_digest(expected_signature, signature): 79 | app.logger.debug("Invalid signature.") 80 | abort(403, "Invalid signature.") 81 | 82 | return func(*args, **kwargs) 83 | 84 | return wrapper 85 | 86 | return decorator 87 | -------------------------------------------------------------------------------- /server/utils/github/bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import time 4 | from datetime import datetime 5 | 6 | import httpx 7 | from jwt import JWT, jwk_from_pem 8 | from model.schema import BindUser 9 | from utils.constant import GitHubPermissionError 10 | 11 | 12 | class BaseGitHubApp: 13 | def __init__(self, installation_id: str = None, user_id: str = None) -> None: 14 | self.app_id = os.environ.get("GITHUB_APP_ID") 15 | self.client_id = os.environ.get("GITHUB_CLIENT_ID") 16 | self.installation_id = installation_id 17 | self.user_id = user_id 18 | 19 | self._jwt_created_at: float = None 20 | self._jwt: str = None 21 | 22 | self._installation_token_created_at: float = None 23 | self._installation_token: str = None 24 | 25 | self._user_token_created_at: float = None 26 | self._user_token: str = None 27 | 28 | def base_github_rest_api( 29 | self, 30 | url: str, 31 | method: str = "GET", 32 | auth_type: str = "jwt", 33 | json: dict = None, 34 | raw: bool = False, 35 | ) -> dict | list | httpx.Response | None: 36 | """Base GitHub REST API. 37 | 38 | Args: 39 | url (str): The url of the GitHub REST API. 40 | method (str, optional): The method of the GitHub REST API. Defaults to "GET". 41 | auth_type (str, optional): The type of the authentication. Defaults to "jwt", can be "jwt" or "install_token" or "user_token". 42 | 43 | Returns: 44 | dict | list | None: The response of the GitHub REST API. 45 | """ 46 | 47 | auth = "" 48 | 49 | match auth_type: 50 | case "jwt": 51 | auth = self.jwt 52 | case "install_token": 53 | auth = self.installation_token 54 | case "user_token": 55 | auth = self.user_token 56 | case _: 57 | raise ValueError( 58 | "auth_type must be 'jwt' or 'install_token' or 'user_token'" 59 | ) 60 | 61 | with httpx.Client( 62 | timeout=httpx.Timeout(10.0, connect=10.0, read=10.0), 63 | transport=httpx.HTTPTransport(retries=3), 64 | ) as client: 65 | response = client.request( 66 | method, 67 | url, 68 | headers={ 69 | "Accept": "application/vnd.github+json", 70 | "Authorization": f"Bearer {auth}", 71 | "X-GitHub-Api-Version": "2022-11-28", 72 | }, 73 | json=json, 74 | ) 75 | if response.status_code == 401: 76 | logging.error("base_github_rest_api: GitHub Permission Error") 77 | raise GitHubPermissionError(response.json().get("message")) 78 | if raw: 79 | return response 80 | return response.json() 81 | 82 | @property 83 | def jwt(self) -> str: 84 | """Get a JWT for the GitHub App. 85 | 86 | Returns: 87 | str: A JWT for the GitHub App. 88 | """ 89 | # 判断是否初始化了 jwt,或者 jwt 是否过期 90 | if ( 91 | self._jwt is None 92 | or self._jwt_created_at is None 93 | or datetime.now().timestamp() - self._jwt_created_at > 60 * 10 94 | ): 95 | self._jwt_created_at = datetime.now().timestamp() 96 | 97 | if os.environ.get("GITHUB_APP_PRIVATE_KEY"): 98 | signing_key = jwk_from_pem( 99 | os.environ.get("GITHUB_APP_PRIVATE_KEY").encode() 100 | ) 101 | else: 102 | with open( 103 | os.environ.get("GITHUB_APP_PRIVATE_KEY_PATH", "pem.pem"), "rb" 104 | ) as pem_file: 105 | signing_key = jwk_from_pem(pem_file.read()) 106 | 107 | payload = { 108 | # Issued at time 109 | "iat": int(time.time()), 110 | # JWT expiration time (10 minutes maximum) 111 | "exp": int(time.time()) + 600, 112 | # GitHub App's identifier 113 | "iss": self.app_id, 114 | } 115 | 116 | # Create JWT 117 | jwt_instance = JWT() 118 | self._jwt = jwt_instance.encode(payload, signing_key, alg="RS256") 119 | 120 | return self._jwt 121 | 122 | @property 123 | def installation_token(self) -> str: 124 | """Get an installation token for the GitHub App. 125 | 126 | Returns: 127 | str: An installation token for the GitHub App. 128 | """ 129 | if ( 130 | self._installation_token is None 131 | or self._installation_token_created_at is None 132 | or datetime.now().timestamp() - self._installation_token_created_at 133 | > 60 * 60 134 | ): 135 | res = self.base_github_rest_api( 136 | f"https://api.github.com/app/installations/{self.installation_id}/access_tokens", 137 | method="POST", 138 | ) 139 | 140 | self._installation_token_created_at = datetime.now().timestamp() 141 | self._installation_token = res.get("token", None) 142 | 143 | return self._installation_token 144 | 145 | @property 146 | def user_token(self) -> str: 147 | """Get a user token for the GitHub App. 148 | 149 | Returns: 150 | str: A user token for the GitHub App. 151 | """ 152 | 153 | # TODO: 当前使用的是无期限的 token,可能需要考虑刷新 token 的问题 154 | if (self._user_token is None or self._user_token_created_at is None,): 155 | bind_user = BindUser.query.filter_by( 156 | user_id=self.user_id, platform="github" 157 | ).first() 158 | if bind_user is None: 159 | raise Exception("Failed to get bind user.") 160 | 161 | if bind_user.access_token is None: 162 | # 这种情况下可能是用户没有绑定 GitHub 163 | raise Exception("Failed to get access token.") 164 | 165 | self._user_token_created_at = datetime.now().timestamp() 166 | self._user_token = bind_user.access_token 167 | 168 | return self._user_token 169 | 170 | def get_installation_info(self) -> dict | None: 171 | """Get installation info 172 | 173 | Returns: 174 | dict: The installation info. 175 | https://docs.github.com/zh/rest/apps/apps?apiVersion=2022-11-28#get-an-installation-for-the-authenticated-app 176 | """ 177 | 178 | return self.base_github_rest_api( 179 | f"https://api.github.com/app/installations/{self.installation_id}" 180 | ) 181 | -------------------------------------------------------------------------------- /server/utils/github/model.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Installation(BaseModel): 7 | id: int 8 | 9 | 10 | class Sender(BaseModel): 11 | type: str 12 | login: str # name 13 | id: int 14 | 15 | 16 | class Organization(BaseModel): 17 | login: str 18 | id: int 19 | 20 | 21 | class BaseEvent(BaseModel): 22 | organization: Optional[Organization] = None 23 | sender: Sender 24 | installation: Optional[Installation] = None 25 | 26 | 27 | class Repository(BaseModel): 28 | id: int 29 | name: str 30 | description: Optional[str] = None 31 | topics: Optional[list[str]] = [] 32 | visibility: str # public/private 33 | private: bool 34 | archived: bool 35 | homepage: Optional[str] = None 36 | open_issues_count: int 37 | stargazers_count: int 38 | forks_count: int 39 | 40 | updated_at: str 41 | 42 | 43 | class Label(BaseModel): 44 | id: int 45 | name: str 46 | description: Optional[str] = None 47 | 48 | 49 | class User(BaseModel): 50 | id: int 51 | login: str 52 | type: str 53 | avatar_url: Optional[str] = None 54 | email: Optional[str] = None 55 | 56 | 57 | class PRInIssue(BaseModel): 58 | url: str 59 | 60 | 61 | class Issue(BaseModel): 62 | id: int 63 | number: int 64 | title: str 65 | body: Optional[str] = None 66 | state: str # open/closed 67 | labels: Optional[list[Label]] = [] 68 | comments: int 69 | created_at: str 70 | updated_at: str 71 | user: User 72 | assignee: Optional[User] = None 73 | assignees: Optional[list[User]] = [] 74 | pull_request: Optional[PRInIssue] = None 75 | 76 | 77 | class PerformedViaGithubApp(BaseModel): 78 | id: int 79 | name: str # == GITHUB_APP_NAME in env 80 | owner: User 81 | 82 | 83 | class IssueComment(BaseModel): 84 | id: int 85 | body: str 86 | performed_via_github_app: Optional[PerformedViaGithubApp] = None 87 | 88 | 89 | class Branch(BaseModel): 90 | label: str 91 | ref: str 92 | sha: str 93 | 94 | 95 | class PullRequest(BaseModel): 96 | id: int 97 | number: int 98 | title: str 99 | body: Optional[str] = None 100 | state: str # open/closed 101 | merged: Optional[bool] = False 102 | labels: Optional[list[Label]] = [] 103 | comments: int 104 | created_at: str 105 | updated_at: str 106 | assignee: Optional[User] = None 107 | assignees: Optional[list[User]] = [] 108 | base: Branch 109 | head: Branch 110 | user: User 111 | comments: int 112 | review_comments: int 113 | commits: int 114 | additions: int 115 | deletions: int 116 | changed_files: int 117 | requested_reviewers: Optional[list[User]] = [] 118 | 119 | 120 | class MemberShip(BaseModel): 121 | role: str 122 | state: str 123 | user: User 124 | 125 | 126 | class Committer(BaseModel): 127 | date: Optional[str] = None 128 | name: str 129 | email: str 130 | username: str 131 | 132 | 133 | Author = Committer 134 | 135 | 136 | class Commit(BaseModel): 137 | id: str 138 | message: str 139 | author: Author 140 | committer: Committer 141 | url: str 142 | 143 | 144 | class RepoEvent(BaseEvent): 145 | action: str 146 | repository: Repository 147 | 148 | 149 | class StarEvent(BaseEvent): 150 | action: str 151 | starred_at: Optional[str] = None 152 | repository: Repository 153 | 154 | 155 | class ForkEvent(BaseEvent): 156 | action: Optional[str] = None 157 | forkee: object 158 | repository: Repository 159 | 160 | 161 | class IssueEvent(BaseEvent): 162 | action: str 163 | issue: Issue 164 | repository: Repository 165 | 166 | 167 | class IssueCommentEvent(BaseEvent): 168 | action: str 169 | issue: Issue 170 | comment: IssueComment 171 | repository: Repository 172 | 173 | 174 | class PullRequestEvent(BaseEvent): 175 | action: str 176 | pull_request: PullRequest 177 | repository: Repository 178 | 179 | 180 | class OrganizationEvent(BaseEvent): 181 | action: str 182 | organization: Organization 183 | membership: Optional[MemberShip] = None 184 | 185 | 186 | class PushEvent(BaseEvent): 187 | after: str 188 | before: str 189 | ref: str 190 | commits: list[Commit] 191 | repository: Repository 192 | -------------------------------------------------------------------------------- /server/utils/github/organization.py: -------------------------------------------------------------------------------- 1 | from utils.github.bot import BaseGitHubApp 2 | 3 | 4 | class GitHubAppOrg(BaseGitHubApp): 5 | def __init__(self, installation_id: str): 6 | super().__init__(installation_id) 7 | 8 | def get_org_repos(self, org_name: str) -> list | None: 9 | """Get org repos. 10 | 11 | Args: 12 | org_name (str): The name of the org. 13 | 14 | Returns: 15 | list: The org repos. 16 | https://docs.github.com/zh/rest/repos/repos?apiVersion=2022-11-28#list-organization-repositories 17 | """ 18 | 19 | page = 1 20 | while True: 21 | repos = self.base_github_rest_api( 22 | f"https://api.github.com/orgs/{org_name}/repos?page={page}", 23 | auth_type="install_token", 24 | ) 25 | if len(repos) == 0: 26 | break 27 | for repo in repos: 28 | yield repo 29 | page = page + 1 30 | 31 | def get_org_repos_accessible(self) -> list | None: 32 | """Get accessible org repos. 33 | 34 | Returns: 35 | list: The accessible org repos. 36 | https://docs.github.com/zh/rest/apps/installations?apiVersion=2022-11-28#list-repositories-accessible-to-the-app-installation 37 | """ 38 | 39 | page = 1 40 | while True: 41 | repos_json = self.base_github_rest_api( 42 | f"https://api.github.com/installation/repositories?page={page}", 43 | auth_type="install_token", 44 | ) 45 | repos = repos_json.get("repositories", []) 46 | if len(repos) == 0: 47 | break 48 | for repo in repos: 49 | yield repo 50 | page = page + 1 51 | 52 | def get_org_members(self, org_name: str) -> list | None: 53 | """Get a list of members of an organization. 54 | 55 | Args: 56 | org_name (str): The name of the organization. 57 | 58 | Returns: 59 | list | None: A list of members of the organization. 60 | https://docs.github.com/zh/rest/orgs/members?apiVersion=2022-11-28#list-organization-members 61 | """ 62 | page = 1 63 | while True: 64 | members = self.base_github_rest_api( 65 | f"https://api.github.com/orgs/{org_name}/members?page={page}", 66 | auth_type="install_token", 67 | ) 68 | if len(members) == 0: 69 | break 70 | for member in members: 71 | yield member 72 | page = page + 1 73 | -------------------------------------------------------------------------------- /server/utils/github/repo.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from model.schema import CodeApplication, Repo, Team 3 | from utils.github.bot import BaseGitHubApp 4 | 5 | 6 | class GitHubAppRepo(BaseGitHubApp): 7 | def __init__(self, installation_id: str = None, user_id: str = None) -> None: 8 | super().__init__(installation_id=installation_id, user_id=user_id) 9 | 10 | def get_repo_info(self, repo_id: str) -> dict | None: 11 | """Get repo info by repo ID. 12 | 13 | Args: 14 | repo_id (str): The repo ID from GitHub. 15 | 16 | Returns: 17 | dict: Repo info. 18 | """ 19 | # 检查 repo 是否存在,是否属于当前 app,如果不存在,返回 None 20 | # 注意:这里的 repo_id 是 GitHub 的 repo_id,不是数据库中 id 21 | repo = Repo.query.filter_by(repo_id=repo_id).first() 22 | if not repo: 23 | return None 24 | 25 | team = ( 26 | db.session.query(Team) 27 | .join( 28 | CodeApplication, 29 | CodeApplication.team_id == Team.id, 30 | ) 31 | .filter( 32 | CodeApplication.id == repo.application_id, 33 | ) 34 | .first() 35 | ) 36 | if not team: 37 | return None 38 | 39 | return self.get_repo_info_by_name(team.name, repo.name) 40 | 41 | def get_repo_info_by_name(self, team_name: str, repo_name: str) -> dict | None: 42 | """Get repo info by repo name. 43 | 44 | Args: 45 | team_name (str): The organization name from GitHub. 46 | repo_name (str): The repo name from GitHub. 47 | 48 | Returns: 49 | dict: Repo info. 50 | """ 51 | return self.base_github_rest_api( 52 | f"https://api.github.com/repos/{team_name}/{repo_name}", 53 | "GET", 54 | "install_token", 55 | ) 56 | 57 | def get_repo_collaborators(self, repo_name: str, owner_name: str) -> list | None: 58 | """Get repo collaborators. 59 | 60 | Args: 61 | repo_name (str): The name of the repo. 62 | owner_name (str): The name of the owner. 63 | 64 | Returns: 65 | list: The repo collaborators. 66 | https://docs.github.com/zh/rest/collaborators/collaborators?apiVersion=2022-11-28#list-repository-collaborators 67 | """ 68 | page = 1 69 | while True: 70 | collaborators = self.base_github_rest_api( 71 | f"https://api.github.com/repos/{owner_name}/{repo_name}/collaborators?page={page}", 72 | auth_type="install_token", 73 | ) 74 | if len(collaborators) == 0: 75 | break 76 | for collaborator in collaborators: 77 | yield collaborator 78 | page = page + 1 79 | 80 | def update_repo( 81 | self, 82 | repo_onwer: str, 83 | repo_name: str, 84 | name: str = None, 85 | description: str = None, 86 | homepage: str = None, 87 | private: bool = None, 88 | visibility: str = None, 89 | archived: bool = None, 90 | ) -> dict | None: 91 | """Update GitHub repo Info 92 | 93 | Args: 94 | repo_onwer (str): The repo owner. 95 | repo_name (str): The repo name. 96 | name (str): The repo name. 97 | description (str): The repo description. 98 | homepage (str): The repo homepage. 99 | private (bool): The repo private. 100 | visibility (str): The repo visibility. 101 | archived (bool): The repo archived. 102 | 103 | Returns: 104 | dict: The repo info. 105 | """ 106 | 107 | json = dict( 108 | name=name, 109 | description=description, 110 | homepage=homepage, 111 | private=private, 112 | visibility=visibility, 113 | archived=archived, 114 | ) 115 | json = {k: v for k, v in json.items() if v is not None} 116 | 117 | return self.base_github_rest_api( 118 | f"https://api.github.com/repos/{repo_onwer}/{repo_name}", 119 | "PATCH", 120 | "user_token", 121 | json=json, 122 | ) 123 | 124 | def replace_topics( 125 | self, repo_onwer: str, repo_name: str, topics: list[str] 126 | ) -> dict | None: 127 | """Replace topics 128 | 129 | Args: 130 | repo_onwer (str): The repo owner. 131 | repo_name (str): The repo name. 132 | topics (list[str]): The repo topics. 133 | 134 | Returns: 135 | dict: The repo info. 136 | """ 137 | 138 | return self.base_github_rest_api( 139 | f"https://api.github.com/repos/{repo_onwer}/{repo_name}/topics", 140 | "PUT", 141 | "user_token", 142 | json={"names": topics}, 143 | ) 144 | 145 | def add_repo_collaborator( 146 | self, 147 | repo_onwer: str, 148 | repo_name: str, 149 | username: str, 150 | permission: str = "pull", 151 | ) -> dict | None: 152 | """Add repo collaborator 153 | 154 | Note that username should be included in the organization. 155 | The GitHub API **DO** supports adding a collaborator who 156 | is not a member of the organization, but here we restrict 157 | members only. 158 | 159 | Args: 160 | repo_onwer (str): The repo owner. 161 | repo_name (str): The repo name. 162 | username (str): The username. 163 | permission (str): The permission. Defaults to "pull". 164 | 165 | Returns: 166 | dict: The repo info 167 | """ 168 | res = self.base_github_rest_api( 169 | f"https://api.github.com/repos/{repo_onwer}/{repo_name}/collaborators/{username}", 170 | "PUT", 171 | "user_token", 172 | json={"permission": permission}, 173 | raw=True, 174 | ) 175 | 176 | if res.status_code == 204: 177 | return {"status": "success"} 178 | else: 179 | return {"status": "failed", "message": res.json()["message"]} 180 | 181 | def create_issue( 182 | self, 183 | repo_onwer: str, 184 | repo_name: str, 185 | title: str, 186 | body: str, 187 | assignees: list[str] = None, 188 | labels: list[str] = None, 189 | ) -> dict | None: 190 | """Create issue 191 | 192 | Args: 193 | repo_onwer (str): The repo owner. 194 | repo_name (str): The repo name. 195 | title (str): The issue title. 196 | body (str): The issue body. 197 | assignees (list[str]): The issue assignees. 198 | labels (list[str]): The issue labels. 199 | 200 | Returns: 201 | dict: The issue info. 202 | """ 203 | 204 | return self.base_github_rest_api( 205 | f"https://api.github.com/repos/{repo_onwer}/{repo_name}/issues", 206 | "POST", 207 | "user_token", 208 | json={ 209 | "title": title, 210 | "body": body, 211 | "assignees": assignees, 212 | "labels": labels, 213 | }, 214 | ) 215 | 216 | def get_one_issue( 217 | self, 218 | repo_onwer: str, 219 | repo_name: str, 220 | issue_number: int, 221 | ) -> dict | None: 222 | """Get an issue 223 | 224 | Args: 225 | repo_onwer (str): The repo owner. 226 | repo_name (str): The repo name. 227 | issue_number (int): The issue id 228 | 229 | Returns: 230 | dict: The issue info. 231 | """ 232 | 233 | return self.base_github_rest_api( 234 | f"https://api.github.com/repos/{repo_onwer}/{repo_name}/issues/{issue_number}", 235 | auth_type="install_token", 236 | ) 237 | 238 | def create_issue_comment( 239 | self, repo_onwer: str, repo_name: str, issue_number: int, body: str 240 | ) -> dict | None: 241 | """Create issue comment 242 | 243 | Args: 244 | repo_onwer (str): The repo owner. 245 | repo_name (str): The repo name. 246 | issue_number (int): The issue number. 247 | body (str): The comment body. 248 | 249 | Returns: 250 | dict: The comment info. 251 | """ 252 | 253 | return self.base_github_rest_api( 254 | f"https://api.github.com/repos/{repo_onwer}/{repo_name}/issues/{issue_number}/comments", 255 | "POST", 256 | "user_token", 257 | json={"body": body}, 258 | ) 259 | 260 | def update_issue( 261 | self, 262 | repo_onwer: str, 263 | repo_name: str, 264 | issue_number: int, 265 | title: str = None, 266 | body: str = None, 267 | state: str = None, 268 | state_reason: str = None, 269 | assignees: list[str] = None, 270 | labels: list[str] = None, 271 | ) -> dict | None: 272 | """Reopen issue 273 | 274 | Args: 275 | repo_onwer (str): The repo owner. 276 | repo_name (str): The repo name. 277 | issue_number (int): The issue number. 278 | 279 | Returns: 280 | dict: The issue info. 281 | """ 282 | 283 | json = dict( 284 | title=title, 285 | body=body, 286 | state=state, 287 | state_reason=state_reason, 288 | assignees=assignees, 289 | labels=labels, 290 | ) 291 | json = {k: v for k, v in json.items() if v is not None} 292 | 293 | return self.base_github_rest_api( 294 | f"https://api.github.com/repos/{repo_onwer}/{repo_name}/issues/{issue_number}", 295 | "PATCH", 296 | "user_token", 297 | json=json, 298 | ) 299 | 300 | def get_one_pull_request( 301 | self, 302 | repo_onwer: str, 303 | repo_name: str, 304 | pull_number: int, 305 | ) -> dict | None: 306 | """Get an issue 307 | 308 | Args: 309 | repo_onwer (str): The repo owner. 310 | repo_name (str): The repo name. 311 | pull_number (int): The pull_request id 312 | 313 | Returns: 314 | dict: The issue info. 315 | """ 316 | 317 | return self.base_github_rest_api( 318 | f"https://api.github.com/repos/{repo_onwer}/{repo_name}/pulls/{pull_number}", 319 | auth_type="install_token", 320 | ) 321 | 322 | def requested_reviewers( 323 | self, 324 | repo_onwer: str, 325 | repo_name: str, 326 | pull_number: int, 327 | reviewers: list[str] = None, 328 | ): 329 | """Merge Pull Request 330 | https://docs.github.com/en/rest/pulls/review-requests?apiVersion=2022-11-28#request-reviewers-for-a-pull-request 331 | 332 | Args: 333 | repo_onwer (str): The repo owner. 334 | repo_name (str): The repo name. 335 | pull_number (int): The pull number. 336 | reviewers list[str]: The reviewers. 337 | 338 | Returns: 339 | dict: The pull request info. 340 | """ 341 | json = dict(reviewers=reviewers) 342 | 343 | return self.base_github_rest_api( 344 | f"https://api.github.com/repos/{repo_onwer}/{repo_name}/pulls/{pull_number}/requested_reviewers", 345 | "POST", 346 | "user_token", 347 | json=json, 348 | ) 349 | 350 | def merge_pull_request( 351 | self, 352 | repo_onwer: str, 353 | repo_name: str, 354 | pull_number: int, 355 | merge_method: str = "merge", 356 | commit_title: str = None, 357 | commit_message: str = None, 358 | ) -> dict | None: 359 | """Merge Pull Request 360 | 361 | Args: 362 | repo_onwer (str): The repo owner. 363 | repo_name (str): The repo name. 364 | pull_number (int): The pull number. 365 | commit_title (str): The comment title. 366 | commit_message (str): The comment message. 367 | merge_method (str): The merge method (merge / squash / rebase). 368 | 369 | Returns: 370 | dict: The merged info. 371 | """ 372 | json = dict( 373 | merge_method=merge_method, 374 | commit_title=commit_title, 375 | commit_message=commit_message, 376 | ) 377 | json = {k: v for k, v in json.items() if v is not None} 378 | 379 | return self.base_github_rest_api( 380 | f"https://api.github.com/repos/{repo_onwer}/{repo_name}/pulls/{pull_number}/merge", 381 | "PUT", 382 | "user_token", 383 | json=json, 384 | ) 385 | -------------------------------------------------------------------------------- /server/utils/lark/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConnectAI-E/GitMaya/8333533f8dc852e6d940b12b674d23523c0fb21d/server/utils/lark/__init__.py -------------------------------------------------------------------------------- /server/utils/lark/base.py: -------------------------------------------------------------------------------- 1 | from connectai.lark.sdk import * 2 | 3 | 4 | class GitMayaTitle(FeishuMessageDiv): 5 | def __init__(self): 6 | repo_url = "https://github.com/ConnectAI-E/GitMaya" 7 | super().__init__( 8 | content="** 🤠 haloooo,我是 Maya~ **\n对 GitMaya 有新想法? 来 GitHub 贡献你的代码吧。", 9 | tag="lark_md", 10 | extra=FeishuMessageButton( 11 | "⭐️ Star Maya", 12 | tag="lark_md", 13 | type="primary", 14 | multi_url={ 15 | "url": repo_url, 16 | "android_url": repo_url, 17 | "ios_url": repo_url, 18 | "pc_url": repo_url, 19 | }, 20 | ), 21 | ) 22 | 23 | 24 | class GitMayaCardNote(FeishuMessageNote): 25 | @property 26 | def img_key(self): 27 | # TODO 这里似乎应该按机器人生成不同的key,不同租户不同机器人,可能访问的权限是不一样的 28 | # 已经测试过了,这个跨租户可以使用,可能的原因是,这个是在飞书的卡片构建平台创建的,不是机器人上传的,卡片模板是可以跨租户共享的,所以这个图也能跨租户使用 29 | return "img_v3_026k_3b6ce6be-4ede-46b0-96d7-61f051ff44fg" 30 | 31 | def __init__(self, content="GitMaya"): 32 | super().__init__( 33 | FeishuMessageImage( 34 | img_key=self.img_key, 35 | alt="", 36 | ), 37 | FeishuMessagePlainText(content), 38 | ) 39 | -------------------------------------------------------------------------------- /server/utils/lark/chat_action_choose.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class ChatActionChoose(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content="", 8 | actions=[], 9 | action_url="https://github.com/ConnectAI-E/GitMaya/actions", 10 | ): 11 | elements = [ 12 | FeishuMessageDiv( 13 | content=f"** 🚀 运行 Action **\n*{action_url}*", 14 | tag="lark_md", 15 | extra=FeishuMessageSelect( 16 | *[FeishuMessageOption(value=action) for action in actions], 17 | placeholder="选择想要执行的 Action", 18 | value={ 19 | "key": "value", # TODO 20 | }, 21 | ), 22 | ), 23 | GitMayaCardNote("GitMaya Chat Action"), 24 | ] 25 | header = FeishuMessageCardHeader("🎉 操作成功!") 26 | config = FeishuMessageCardConfig() 27 | 28 | super().__init__(*elements, header=header, config=config) 29 | 30 | 31 | if __name__ == "__main__": 32 | import json 33 | import os 34 | 35 | import httpx 36 | from dotenv import find_dotenv, load_dotenv 37 | 38 | load_dotenv(find_dotenv()) 39 | message = ChatActionChoose(actions=["aaa", "bbb"]) 40 | print("message", json.dumps(message)) 41 | result = httpx.post( 42 | os.environ.get("TEST_BOT_HOOK"), 43 | json={"card": message, "msg_type": "interactive"}, 44 | ).json() 45 | print("result", result) 46 | -------------------------------------------------------------------------------- /server/utils/lark/chat_action_result.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class PrTipSuccess(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content="", 8 | action_detail_url="https://github.com/ConnectAI-E/GitMaya/actions/runs/7406888938", 9 | ): 10 | elements = [ 11 | FeishuMessageDiv( 12 | content=f'1. 已修改 Issue 标题为 "sss"\n2. 已分配任务给 @xx\n3. 已关闭 issue\n\n \n[查看更多 WorkFlow 运行信息](action_detail_url)', 13 | tag="lark_md", 14 | ), 15 | GitMayaCardNote("GitMaya Pr Action"), 16 | ] 17 | header = FeishuMessageCardHeader("🚀 Action 运行结果") 18 | config = FeishuMessageCardConfig() 19 | 20 | super().__init__(*elements, header=header, config=config) 21 | 22 | 23 | if __name__ == "__main__": 24 | import json 25 | import os 26 | 27 | import httpx 28 | from dotenv import find_dotenv, load_dotenv 29 | 30 | load_dotenv(find_dotenv()) 31 | message = PrTipSuccess() 32 | print("message", json.dumps(message)) 33 | result = httpx.post( 34 | os.environ.get("TEST_BOT_HOOK"), 35 | json={"card": message, "msg_type": "interactive"}, 36 | ).json() 37 | print("result", result) 38 | -------------------------------------------------------------------------------- /server/utils/lark/chat_manual.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class ChatManual(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | repo_url="https://github.com/ConnectAI-E/GitMaya", 8 | actions=[], 9 | repo_name="GitMaya", 10 | ): 11 | github_url = "https://github.com" 12 | elements = [ 13 | GitMayaTitle(), 14 | FeishuMessageHr(), 15 | FeishuMessageDiv( 16 | content="**📄 创建 Issue **\n*群聊下回复「/issue + 新 Issue 标题 + @分配成员」 *\n*群聊绑定多仓库时,请在对应仓库话题下创建 Issue *", 17 | tag="lark_md", 18 | ), 19 | # FeishuMessageDiv( 20 | # content="**🚀 运行 Action **\n*群聊下回复「/action」 *", 21 | # tag="lark_md", 22 | # extra=FeishuMessageSelect( 23 | # *[FeishuMessageOption(value=action) for action in actions], 24 | # placeholder="选择想要执行的 Action", 25 | # value={ 26 | # "key": "value", # TODO 27 | # }, 28 | # ) 29 | # if len(actions) > 0 30 | # else None, 31 | # ), 32 | FeishuMessageDiv( 33 | content="**🗄 关联新仓库至当前群聊 **\n*群聊下回复「/match + repo url」 *", 34 | tag="lark_md", 35 | ), 36 | FeishuMessageDiv( 37 | content=f"**⚡️ 前往 GitHub 查看 Repo 主页 **\n*群聊下回复「/view」 *", 38 | tag="lark_md", 39 | extra=FeishuMessageButton( 40 | "打开 GitHub 主页", 41 | tag="lark_md", 42 | type="default", 43 | multi_url={ 44 | "url": repo_url, 45 | "android_url": repo_url, 46 | "ios_url": repo_url, 47 | "pc_url": repo_url, 48 | }, 49 | ), 50 | ), 51 | FeishuMessageDiv( 52 | content=f"**📈 前往 GitHub 查看 Repo Insight **\n*群聊下回复「/insight」 *", 53 | tag="lark_md", 54 | extra=FeishuMessageButton( 55 | "打开 Insight 面板", 56 | tag="lark_md", 57 | type="default", 58 | multi_url={ 59 | "url": f"{repo_url}/pulse", 60 | "android_url": f"{repo_url}/pulse", 61 | "ios_url": f"{repo_url}/pulse", 62 | "pc_url": f"{repo_url}/pulse", 63 | }, 64 | ), 65 | ), 66 | GitMayaCardNote("GitMaya Chat Manual"), 67 | ] 68 | header = FeishuMessageCardHeader("GitMaya Chat Manual\n", template="grey") 69 | config = FeishuMessageCardConfig() 70 | 71 | super().__init__(*elements, header=header, config=config) 72 | 73 | 74 | class ChatView(FeishuMessageCard): 75 | def __init__( 76 | self, 77 | repo_url="https://github.com/ConnectAI-E/GitMaya", 78 | ): 79 | elements = [ 80 | FeishuMessageDiv( 81 | content=f"** ⚡️ 前往 GitHub 查看信息 **", 82 | tag="lark_md", 83 | extra=FeishuMessageButton( 84 | "在浏览器打开", 85 | tag="lark_md", 86 | type="default", 87 | multi_url={ 88 | "url": repo_url, 89 | "android_url": repo_url, 90 | "ios_url": repo_url, 91 | "pc_url": repo_url, 92 | }, 93 | ), 94 | ), 95 | GitMayaCardNote("GitMaya Chat Action"), 96 | ] 97 | header = FeishuMessageCardHeader("🎉 操作成功!") 98 | config = FeishuMessageCardConfig() 99 | 100 | super().__init__(*elements, header=header, config=config) 101 | 102 | 103 | if __name__ == "__main__": 104 | import json 105 | import os 106 | 107 | import httpx 108 | from dotenv import find_dotenv, load_dotenv 109 | 110 | load_dotenv(find_dotenv()) 111 | message = ChatManual(actions=["aaa", "bbb"]) 112 | print(json.dumps(message)) 113 | result = httpx.post( 114 | os.environ.get("TEST_BOT_HOOK"), 115 | json={"card": message, "msg_type": "interactive"}, 116 | ).json() 117 | print(result) 118 | -------------------------------------------------------------------------------- /server/utils/lark/chat_tip_failed.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class ChatTipFailed(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content="没有执行此操作的权限\n\n", 8 | ): 9 | elements = [ 10 | FeishuMessageDiv( 11 | content=content, 12 | tag="lark_md", 13 | ), 14 | GitMayaCardNote("GitMaya Chat Action"), 15 | ] 16 | header = FeishuMessageCardHeader("😕 操作失败!") 17 | config = FeishuMessageCardConfig() 18 | 19 | super().__init__(*elements, header=header, config=config) 20 | 21 | 22 | if __name__ == "__main__": 23 | import json 24 | import os 25 | 26 | import httpx 27 | from dotenv import find_dotenv, load_dotenv 28 | 29 | load_dotenv(find_dotenv()) 30 | message = ChatTipFailed() 31 | print("message", json.dumps(message)) 32 | result = httpx.post( 33 | os.environ.get("TEST_BOT_HOOK"), 34 | json={"card": message, "msg_type": "interactive"}, 35 | ).json() 36 | print("result", result) 37 | -------------------------------------------------------------------------------- /server/utils/lark/issue_card.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class IssueCard(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | repo_url="https://github.com/ConnectAI-E/GitMaya", 8 | id=16, 9 | title="", 10 | description=None, 11 | status="待完成", 12 | persons=[], 13 | assignees=[], 14 | creater=None, 15 | is_creater_outside=False, 16 | tags=[], 17 | updated="2022年12月23日 16:32", 18 | ): 19 | issue_url = f"{repo_url}/issues/{id}" 20 | template = "blue" if status == "已关闭" else "red" 21 | # 这里使用飞书的用户 22 | users = ( 23 | "".join([f"" for open_id in assignees]) 24 | if len(assignees) > 0 25 | else "**待分配**" 26 | ) 27 | creater = ( 28 | f"{creater}(组织外用户)" if is_creater_outside else f"" 29 | ) 30 | labels = "、".join(tags) if len(tags) > 0 else "**待补充**" 31 | action_button = ( 32 | FeishuMessageButton("重新打开", type="primary", value={"command": f"/reopen"}) 33 | if status == "已关闭" 34 | else FeishuMessageButton( 35 | "关闭 Issue", type="danger", value={"command": f"/close"} 36 | ) 37 | ) 38 | desc_block = ( 39 | [ 40 | FeishuMessageDiv( 41 | "💬 **主要内容**", tag="lark_md" 42 | ), 43 | FeishuMessageMarkdown( 44 | # TODO 替换content 45 | description, 46 | text_align="left", 47 | ), 48 | ] 49 | if description 50 | else [] 51 | ) 52 | elements = [ 53 | FeishuMessageColumnSet( 54 | FeishuMessageColumn( 55 | *desc_block, 56 | FeishuMessageColumnSet( 57 | FeishuMessageColumn( 58 | FeishuMessageMarkdown( 59 | f"🚧 **状态** \n**{status} **", 60 | text_align="left", 61 | ), 62 | width="weighted", 63 | weight=1, 64 | vertical_align="top", 65 | ), 66 | FeishuMessageColumn( 67 | FeishuMessageMarkdown( 68 | f"👋 **分配人**\n{users}", 69 | text_align="left", 70 | ), 71 | width="weighted", 72 | weight=1, 73 | vertical_align="top", 74 | ), 75 | FeishuMessageColumn( 76 | FeishuMessageMarkdown( 77 | f"🏷 **标签** \n{labels}", 78 | text_align="left", 79 | ), 80 | width="weighted", 81 | weight=1, 82 | vertical_align="top", 83 | ), 84 | FeishuMessageColumn( 85 | FeishuMessageMarkdown( 86 | f"🧔 **创建人**\n{creater}", 87 | text_align="left", 88 | ), 89 | width="weighted", 90 | weight=1, 91 | vertical_align="top", 92 | ), 93 | flex_mode="bisect", 94 | background_style="grey", 95 | ), 96 | width="weighted", 97 | weight=1, 98 | vertical_align="top", 99 | ), 100 | flex_mode="none", 101 | background_style="grey", 102 | ), 103 | FeishuMessageAction( 104 | action_button, 105 | FeishuMessageSelectPerson( 106 | *[FeishuMessageOption(value=open_id) for open_id in persons], 107 | placeholder="修改负责人", 108 | value={ 109 | # /match_repo_id + select repo_id, with chat_id 110 | # 这里直接使用前面选中的项目名字拼接到github_url后面,就与用户输入match指令一致了 111 | "command": f"/assign ", 112 | "suffix": "$option", 113 | }, 114 | ), 115 | FeishuMessageButton( 116 | "在浏览器中打开", 117 | tag="lark_md", 118 | type="default", 119 | multi_url={ 120 | "url": issue_url, 121 | "android_url": issue_url, 122 | "ios_url": issue_url, 123 | "pc_url": issue_url, 124 | }, 125 | ), 126 | ), 127 | GitMayaCardNote(f"最近更新 {updated}"), 128 | ] 129 | header = FeishuMessageCardHeader(f"#Issue{id} {title}", template=template) 130 | config = FeishuMessageCardConfig() 131 | 132 | super().__init__(*elements, header=header, config=config) 133 | 134 | 135 | if __name__ == "__main__": 136 | import json 137 | import os 138 | 139 | import httpx 140 | from dotenv import find_dotenv, load_dotenv 141 | 142 | load_dotenv(find_dotenv()) 143 | message = IssueCard( 144 | title="优化 OpenAI 默认返回的表格在飞书对话中的呈现", 145 | description="💬 **主要内容**\n功能改善建议 🚀\n优化 OpenAI 默认返回的表格在飞书对话中的呈现。\n\n## 您的建议是什么? 🤔\n\n当前问题1:当要求 OpenAI 使用表格对内容进行格式化返回时,默认会返回 Markdown 格式的文本形式,在飞书对话中显示会很混乱,特别是在手机上查看时。\n\n当前问题2:飞书对话默认不支持 Markdown 语法表格的可视化。\n\n功能预期:返回对话消息如果识别为包含表格内容,支持将内容输出至飞书多维表格,并在对话中返回相应链接。", 146 | status="待完成", 147 | # assignees=[("River", "https://github.com/Leizhenpeng")], 148 | persons=os.environ.get("TEST_USER_OPEN_ID").split(","), 149 | tags=["bug", "doc"], 150 | updated="2022年12月23日 16:32", 151 | ) 152 | print("message", json.dumps(message)) 153 | result = httpx.post( 154 | os.environ.get("TEST_BOT_HOOK"), 155 | json={"card": message, "msg_type": "interactive"}, 156 | ).json() 157 | print("result", result) 158 | message = IssueCard( 159 | title="优化 OpenAI 默认返回的表格在飞书对话中的呈现", 160 | description="💬 **主要内容**\n功能改善建议 🚀\n优化 OpenAI 默认返回的表格在飞书对话中的呈现。\n\n## 您的建议是什么? 🤔\n\n当前问题1:当要求 OpenAI 使用表格对内容进行格式化返回时,默认会返回 Markdown 格式的文本形式,在飞书对话中显示会很混乱,特别是在手机上查看时。\n\n当前问题2:飞书对话默认不支持 Markdown 语法表格的可视化。\n\n功能预期:返回对话消息如果识别为包含表格内容,支持将内容输出至飞书多维表格,并在对话中返回相应链接。", 161 | status="已关闭", 162 | # assignees=[("River", "https://github.com/Leizhenpeng")], 163 | persons=os.environ.get("TEST_USER_OPEN_ID").split(","), 164 | tags=["bug", "doc"], 165 | updated="2022年12月23日 16:32", 166 | ) 167 | print("message", json.dumps(message)) 168 | result = httpx.post( 169 | os.environ.get("TEST_BOT_HOOK"), 170 | json={"card": message, "msg_type": "interactive"}, 171 | ).json() 172 | print("result", result) 173 | -------------------------------------------------------------------------------- /server/utils/lark/issue_manual_help.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class IssueManualHelp(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | repo_url="https://github.com/ConnectAI-E/GitMaya", 8 | issue_id=16, 9 | persons=[], 10 | assignees=[], 11 | status="待完成", 12 | tags=[], 13 | ): 14 | issue_url = f"{repo_url}/issues/{issue_id}" 15 | action_button = ( 16 | FeishuMessageButton("重新打开", type="primary", value={"command": f"/reopen"}) 17 | if status == "已关闭" 18 | else FeishuMessageButton( 19 | "关闭 Issue", type="danger", value={"command": f"/close"} 20 | ) 21 | ) 22 | elements = [ 23 | GitMayaTitle(), 24 | FeishuMessageHr(), 25 | FeishuMessageDiv( 26 | content="** 🕹️ 更新 Issue 状态**\n*话题下回复「/close、/reopen」*", 27 | tag="lark_md", 28 | extra=action_button, 29 | ), 30 | FeishuMessageDiv( 31 | content="** 🏖️ 重新分配 Issue 负责人**\n*话题下回复「/assign + @成员」*", 32 | tag="lark_md", 33 | extra=FeishuMessageSelectPerson( 34 | # *[FeishuMessageOption(value=open_id) for open_id in persons], 35 | placeholder="修改负责人", 36 | value={ 37 | "command": "/assign ", 38 | "suffix": "$option", 39 | }, 40 | ), 41 | ), 42 | FeishuMessageDiv( 43 | content="** 🏷️ 修改 Issue 标签**\n*话题下回复「/label + 标签名」 *", 44 | tag="lark_md", 45 | extra=FeishuMessageSelect( 46 | *[FeishuMessageOption(value=tag) for tag in tags], 47 | placeholder="修改标签", 48 | value={ 49 | "command": "/label ", 50 | "suffix": "$option", 51 | }, 52 | ) 53 | if len(tags) 54 | else None, 55 | ), 56 | # FeishuMessageDiv( 57 | # content="** 🔝 置顶 Issue**\n*话题下回复「/pin、/unpin」 *", 58 | # tag="lark_md", 59 | # extra=FeishuMessageButton( 60 | # "Pin Issue", 61 | # tag="lark_md", 62 | # type="primary", 63 | # multi_url={ 64 | # "url": issue_url, 65 | # "android_url": issue_url, 66 | # "ios_url": issue_url, 67 | # "pc_url": issue_url, 68 | # }, 69 | # ), 70 | # ), 71 | FeishuMessageDiv( 72 | content="** 📑 修改 Issue 标题**\n*话题下回复「/rename + 新 Issue 标题」 *", 73 | tag="lark_md", 74 | ), 75 | FeishuMessageDiv( 76 | content="** 📝 编辑 Issue 描述**\n*话题下回复「/edit + 另起一行 + 新 Issue 描述」 *", 77 | tag="lark_md", 78 | ), 79 | FeishuMessageDiv( 80 | content="** ⌨️ 在 Issue 下评论**\n*话题下回复「 你的评论」 *", 81 | tag="lark_md", 82 | ), 83 | FeishuMessageDiv( 84 | content="** ⚡️ 查看更多 Issue 信息 **\n*话题下回复「/view」*", 85 | tag="lark_md", 86 | extra=FeishuMessageButton( 87 | "在浏览器中打开", 88 | tag="lark_md", 89 | type="default", 90 | multi_url={ 91 | "url": issue_url, 92 | "android_url": issue_url, 93 | "ios_url": issue_url, 94 | "pc_url": issue_url, 95 | }, 96 | ), 97 | ), 98 | GitMayaCardNote("GitMaya Issue Manual"), 99 | ] 100 | header = FeishuMessageCardHeader("ISSUE MANUAL\n", template="grey") 101 | config = FeishuMessageCardConfig() 102 | 103 | super().__init__(*elements, header=header, config=config) 104 | 105 | 106 | class IssueView(FeishuMessageCard): 107 | def __init__( 108 | self, 109 | repo_url="https://github.com/ConnectAI-E/GitMaya", 110 | issue_id=17, 111 | ): 112 | issue_url = f"{repo_url}/issues/{issue_id}" 113 | elements = [ 114 | FeishuMessageDiv( 115 | content=f"** ⚡️ 前往 GitHub 查看信息 **", 116 | tag="lark_md", 117 | extra=FeishuMessageButton( 118 | "在浏览器打开", 119 | tag="lark_md", 120 | type="default", 121 | multi_url={ 122 | "url": issue_url, 123 | "android_url": issue_url, 124 | "ios_url": issue_url, 125 | "pc_url": issue_url, 126 | }, 127 | ), 128 | ), 129 | GitMayaCardNote("GitMaya Issue Action"), 130 | ] 131 | header = FeishuMessageCardHeader("🎉 操作成功!") 132 | config = FeishuMessageCardConfig() 133 | 134 | super().__init__(*elements, header=header, config=config) 135 | 136 | 137 | if __name__ == "__main__": 138 | import json 139 | import os 140 | 141 | import httpx 142 | from dotenv import find_dotenv, load_dotenv 143 | 144 | load_dotenv(find_dotenv()) 145 | message = IssueManualHelp( 146 | persons=os.environ.get("TEST_USER_OPEN_ID").split(","), tags=["bug", "doc"] 147 | ) 148 | print("message", json.dumps(message)) 149 | result = httpx.post( 150 | os.environ.get("TEST_BOT_HOOK"), 151 | json={"card": message, "msg_type": "interactive"}, 152 | ).json() 153 | print("result", result) 154 | -------------------------------------------------------------------------------- /server/utils/lark/issue_open_in_browser.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class IssueOpenInBrowser(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | repo_url="https://github.com/ConnectAI-E/GitMaya", 8 | issue_id=16, 9 | ): 10 | issue_url = f"{repo_url}/issues/{issue_id}" 11 | elements = [ 12 | FeishuMessageDiv( 13 | content=f"** ⚡️ 前往 GitHub 查看更多 Issue 信息 **\n*{issue_url}*", 14 | tag="lark_md", 15 | extra=FeishuMessageButton( 16 | "在浏览器中打开", 17 | tag="lark_md", 18 | type="primary", 19 | multi_url={ 20 | "url": issue_url, 21 | "android_url": issue_url, 22 | "ios_url": issue_url, 23 | "pc_url": issue_url, 24 | }, 25 | ), 26 | ), 27 | GitMayaCardNote("GitMaya Issue Manual"), 28 | ] 29 | header = FeishuMessageCardHeader("🎉 操作成功!") 30 | config = FeishuMessageCardConfig() 31 | 32 | super().__init__(*elements, header=header, config=config) 33 | 34 | 35 | if __name__ == "__main__": 36 | import json 37 | import os 38 | 39 | import httpx 40 | from dotenv import find_dotenv, load_dotenv 41 | 42 | load_dotenv(find_dotenv()) 43 | message = IssueOpenInBrowser() 44 | print("message", json.dumps(message)) 45 | result = httpx.post( 46 | os.environ.get("TEST_BOT_HOOK"), 47 | json={"card": message, "msg_type": "interactive"}, 48 | ).json() 49 | print("result", result) 50 | -------------------------------------------------------------------------------- /server/utils/lark/issue_tip_failed.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class IssueTipFailed(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content="不要重复关闭 issue\n", 8 | ): 9 | elements = [ 10 | FeishuMessageDiv( 11 | content=content, 12 | tag="lark_md", 13 | ), 14 | GitMayaCardNote("GitMaya Issue Action"), 15 | ] 16 | header = FeishuMessageCardHeader("😕 操作失败!") 17 | config = FeishuMessageCardConfig() 18 | 19 | super().__init__(*elements, header=header, config=config) 20 | 21 | 22 | if __name__ == "__main__": 23 | import json 24 | import os 25 | 26 | import httpx 27 | from dotenv import find_dotenv, load_dotenv 28 | 29 | load_dotenv(find_dotenv()) 30 | message = IssueTipFailed() 31 | print("message", json.dumps(message)) 32 | result = httpx.post( 33 | os.environ.get("TEST_BOT_HOOK"), 34 | json={"card": message, "msg_type": "interactive"}, 35 | ).json() 36 | print("result", result) 37 | -------------------------------------------------------------------------------- /server/utils/lark/issue_tip_success.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class IssueTipSuccess(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content='1. 已修改 Issue 标题为 "sss"\n2. 已分配任务给 @xx\n3. 已关闭 issue\n', 8 | ): 9 | elements = [ 10 | FeishuMessageDiv( 11 | content=content, 12 | tag="lark_md", 13 | ), 14 | GitMayaCardNote("GitMaya Issue Action"), 15 | ] 16 | header = FeishuMessageCardHeader("🎉 操作成功!") 17 | config = FeishuMessageCardConfig() 18 | 19 | super().__init__(*elements, header=header, config=config) 20 | 21 | 22 | if __name__ == "__main__": 23 | import json 24 | import os 25 | 26 | import httpx 27 | from dotenv import find_dotenv, load_dotenv 28 | 29 | load_dotenv(find_dotenv()) 30 | message = IssueTipSuccess() 31 | print("message", json.dumps(message)) 32 | result = httpx.post( 33 | os.environ.get("TEST_BOT_HOOK"), 34 | json={"card": message, "msg_type": "interactive"}, 35 | ).json() 36 | print("result", result) 37 | -------------------------------------------------------------------------------- /server/utils/lark/manage_fail.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class ManageFaild(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content="不允许重复创建项目群\n", 8 | title="😕 操作失败!", 9 | ): 10 | elements = [ 11 | FeishuMessageDiv( 12 | content=content, 13 | tag="lark_md", 14 | ), 15 | GitMayaCardNote("GitMaya Manage Action"), 16 | ] 17 | header = FeishuMessageCardHeader(title) 18 | config = FeishuMessageCardConfig() 19 | 20 | super().__init__(*elements, header=header, config=config) 21 | 22 | 23 | if __name__ == "__main__": 24 | import json 25 | import os 26 | 27 | import httpx 28 | from dotenv import find_dotenv, load_dotenv 29 | 30 | load_dotenv(find_dotenv()) 31 | message = ManageFaild() 32 | print("message", json.dumps(message)) 33 | result = httpx.post( 34 | os.environ.get("TEST_BOT_HOOK"), 35 | json={"card": message, "msg_type": "interactive"}, 36 | ).json() 37 | print("result", result) 38 | -------------------------------------------------------------------------------- /server/utils/lark/manage_manual.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base import * 4 | 5 | 6 | class ManageManual(FeishuMessageCard): 7 | def __init__( 8 | self, 9 | org_name="GitMaya", 10 | repos=[], 11 | team_id="", 12 | ): 13 | github_url = "https://github.com" 14 | new_repo_url = f"{github_url}/new" 15 | profile_url = f"{github_url}/{org_name}" 16 | gitmaya_host = os.environ.get("DOMAIN") 17 | setting_url = f"{gitmaya_host}/app/setting" 18 | elements = [ 19 | GitMayaTitle(), 20 | FeishuMessageHr(), 21 | FeishuMessageDiv( 22 | content="** 👀 关联历史 GitHub 项目**\n*回复「/match + repo url + chat name 」 *", 23 | tag="lark_md", 24 | extra=FeishuMessageSelect( 25 | *[ 26 | FeishuMessageOption(value=repo_name, content=repo_name) 27 | for _, repo_name in repos 28 | ], 29 | placeholder="", 30 | value={ 31 | # /match_repo_id + select repo_id, with chat_id 32 | # 这里直接使用前面选中的项目名字拼接到github_url后面,就与用户输入match指令一致了 33 | "command": f"/match {github_url}/{org_name}/", 34 | "suffix": "$option", 35 | }, 36 | ) 37 | if len(repos) > 0 38 | else None, 39 | ), 40 | FeishuMessageDiv( 41 | content="** 📦 新建 GitHub Repo**\n*回复「/new」 *", 42 | tag="lark_md", 43 | extra=FeishuMessageButton( 44 | "新建 GitHub Repo", 45 | tag="lark_md", 46 | type="default", 47 | multi_url={ 48 | "url": new_repo_url, 49 | "android_url": new_repo_url, 50 | "ios_url": new_repo_url, 51 | "pc_url": new_repo_url, 52 | }, 53 | ), 54 | ), 55 | FeishuMessageDiv( 56 | content=f"** ⚡️ 查看个人主页 **\n*回复「/view」 *", 57 | tag="lark_md", 58 | extra=FeishuMessageButton( 59 | "打开 GitHub 主页", 60 | tag="lark_md", 61 | type="default", 62 | multi_url={ 63 | "url": profile_url, 64 | "android_url": profile_url, 65 | "ios_url": profile_url, 66 | "pc_url": profile_url, 67 | }, 68 | ), 69 | ), 70 | FeishuMessageDiv( 71 | content=f"** ⚙️ 修改 {org_name} 设置**\n*回复「/setting 」*", 72 | tag="lark_md", 73 | extra=FeishuMessageButton( 74 | "前往 setting 面版", 75 | tag="lark_md", 76 | type="primary", 77 | multi_url={ 78 | "url": setting_url, 79 | "android_url": setting_url, 80 | "ios_url": setting_url, 81 | "pc_url": setting_url, 82 | }, 83 | ), 84 | ), 85 | GitMayaCardNote("GitMaya Manage Manual"), 86 | ] 87 | header = FeishuMessageCardHeader("GitMaya Manage Manual\n", template="violet") 88 | config = FeishuMessageCardConfig() 89 | 90 | super().__init__(*elements, header=header, config=config) 91 | 92 | 93 | class ManageView(FeishuMessageCard): 94 | def __init__(self, org_name="GitMaya"): 95 | github_url = "https://github.com" 96 | profile_url = f"{github_url}/{org_name}" 97 | elements = [ 98 | FeishuMessageDiv( 99 | content=f"** ⚡️ 前往 GitHub 查看个人主页 **", 100 | tag="lark_md", 101 | extra=FeishuMessageButton( 102 | "在浏览器打开", 103 | tag="lark_md", 104 | type="default", 105 | multi_url={ 106 | "url": profile_url, 107 | "android_url": profile_url, 108 | "ios_url": profile_url, 109 | "pc_url": profile_url, 110 | }, 111 | ), 112 | ), 113 | GitMayaCardNote("GitMaya Manage Action"), 114 | ] 115 | header = FeishuMessageCardHeader("🎉 操作成功!") 116 | config = FeishuMessageCardConfig() 117 | 118 | super().__init__(*elements, header=header, config=config) 119 | 120 | 121 | class ManageNew(FeishuMessageCard): 122 | def __init__(self): 123 | github_url = "https://github.com" 124 | new_repo_url = f"{github_url}/new" 125 | elements = [ 126 | FeishuMessageDiv( 127 | content=f"** ⚡️ 前往 GitHub 新建 Repo **", 128 | tag="lark_md", 129 | extra=FeishuMessageButton( 130 | "在浏览器打开", 131 | tag="lark_md", 132 | type="default", 133 | multi_url={ 134 | "url": new_repo_url, 135 | "android_url": new_repo_url, 136 | "ios_url": new_repo_url, 137 | "pc_url": new_repo_url, 138 | }, 139 | ), 140 | ), 141 | GitMayaCardNote("GitMaya Manage Action"), 142 | ] 143 | header = FeishuMessageCardHeader("🎉 操作成功!") 144 | config = FeishuMessageCardConfig() 145 | 146 | super().__init__(*elements, header=header, config=config) 147 | 148 | 149 | class ManageSetting(FeishuMessageCard): 150 | def __init__(self): 151 | gitmaya_host = os.environ.get("DOMAIN") 152 | setting_url = f"{gitmaya_host}/app/setting" 153 | elements = [ 154 | FeishuMessageDiv( 155 | content=f"** ⚡️ 前往 GitHub 查看 **", 156 | tag="lark_md", 157 | extra=FeishuMessageButton( 158 | "在浏览器打开", 159 | tag="lark_md", 160 | type="default", 161 | multi_url={ 162 | "url": setting_url, 163 | "android_url": setting_url, 164 | "ios_url": setting_url, 165 | "pc_url": setting_url, 166 | }, 167 | ), 168 | ), 169 | GitMayaCardNote("GitMaya Manage Action"), 170 | ] 171 | header = FeishuMessageCardHeader("🎉 操作成功!") 172 | config = FeishuMessageCardConfig() 173 | 174 | super().__init__(*elements, header=header, config=config) 175 | 176 | 177 | if __name__ == "__main__": 178 | import json 179 | import os 180 | 181 | import httpx 182 | from dotenv import find_dotenv, load_dotenv 183 | 184 | load_dotenv(find_dotenv()) 185 | message = ManageManual(repos=[("GitMaya", "GitMaya")]) 186 | print("message", json.dumps(message)) 187 | result = httpx.post( 188 | os.environ.get("TEST_BOT_HOOK"), 189 | json={"card": message, "msg_type": "interactive"}, 190 | ).json() 191 | print("result", result) 192 | -------------------------------------------------------------------------------- /server/utils/lark/manage_repo_detect.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class ManageRepoDetect(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | repo_url="https://github.com/ConnectAI-E/GitMaya", 8 | repo_name="GitMaya", 9 | repo_description="待补充", 10 | repo_topic=[], 11 | homepage="", 12 | visibility="私有仓库", 13 | ): 14 | new_issue_url = f"{repo_url}/issues/new" 15 | github_url = "https://github.com" 16 | setting_url = f"{repo_url}/settings" 17 | homepage = ( 18 | f"[{homepage}]({homepage})" 19 | if homepage is not None 20 | else "**待补充**" 21 | ) 22 | repo_description = ( 23 | repo_description 24 | if repo_description is not None 25 | else "**待补充**" 26 | ) 27 | labels = ( 28 | "、".join(repo_topic) 29 | if len(repo_topic) > 0 30 | else "**待补充**" 31 | ) 32 | elements = [ 33 | FeishuMessageColumnSet( 34 | FeishuMessageColumn( 35 | FeishuMessageColumnSet( 36 | FeishuMessageColumn( 37 | FeishuMessageMarkdown( 38 | f"**📦 仓库名:** \n{repo_name}", 39 | text_align="left", 40 | ), 41 | width="weighted", 42 | weight=1, 43 | vertical_align="top", 44 | ), 45 | FeishuMessageColumn( 46 | FeishuMessageMarkdown( 47 | f"**👀 可见性:**\n{visibility}", 48 | text_align="left", 49 | ), 50 | width="weighted", 51 | weight=1, 52 | vertical_align="top", 53 | ), 54 | FeishuMessageColumn( 55 | FeishuMessageMarkdown( 56 | f"**🌐 Homepage:**\n{homepage}", 57 | text_align="left", 58 | ), 59 | width="weighted", 60 | weight=1, 61 | vertical_align="top", 62 | ), 63 | flex_mode="stretch", 64 | background_style="grey", 65 | ), 66 | FeishuMessageMarkdown( 67 | f"**🗒️ 描述:**\n{repo_description}", text_align="left" 68 | ), 69 | FeishuMessageMarkdown(f"**🏷️ 标签:**\n{labels}", text_align="left"), 70 | width="weighted", 71 | weight=1, 72 | vertical_align="top", 73 | ), 74 | flex_mode="flow", 75 | background_style="grey", 76 | ), 77 | FeishuMessageAction( 78 | FeishuMessageButton( 79 | "创建项目群", 80 | type="primary", 81 | value={"command": f"/match {repo_url}"}, 82 | ), 83 | FeishuMessageButton( 84 | "在浏览器中打开", 85 | type="default", 86 | multi_url={ 87 | "url": repo_url, 88 | "android_url": repo_url, 89 | "ios_url": repo_url, 90 | "pc_url": repo_url, 91 | }, 92 | ), 93 | FeishuMessageOverflow( 94 | FeishuMessageOption(value="appStore", content="关联已有项目群") 95 | ), 96 | ), 97 | GitMayaCardNote("GitMaya Manage Action"), 98 | ] 99 | header = FeishuMessageCardHeader("发现了新的 GitHub 仓库", template="violet") 100 | config = FeishuMessageCardConfig() 101 | 102 | super().__init__(*elements, header=header, config=config) 103 | 104 | 105 | if __name__ == "__main__": 106 | import json 107 | import os 108 | 109 | import httpx 110 | from dotenv import find_dotenv, load_dotenv 111 | 112 | load_dotenv(find_dotenv()) 113 | message = ManageRepoDetect( 114 | repo_url="https://github.com/ConnectAI-E/GitMaya", 115 | repo_name="GitMaya", 116 | repo_description="🖲️ Next generation gitops for boosting developer-teams productivity", 117 | repo_topic=["GitMaya", "git", "feishu", "lark"], 118 | ) 119 | print("message", json.dumps(message)) 120 | result = httpx.post( 121 | os.environ.get("TEST_BOT_HOOK"), 122 | json={"card": message, "msg_type": "interactive"}, 123 | ).json() 124 | print("result", result) 125 | -------------------------------------------------------------------------------- /server/utils/lark/manage_success.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class ManageSuccess(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content="", 8 | ): 9 | elements = [ 10 | FeishuMessageDiv( 11 | content=content, 12 | tag="lark_md", 13 | ), 14 | GitMayaCardNote("GitMaya Manage Action"), 15 | ] 16 | header = FeishuMessageCardHeader("🎉 操作成功!") 17 | config = FeishuMessageCardConfig() 18 | 19 | super().__init__(*elements, header=header, config=config) 20 | 21 | 22 | if __name__ == "__main__": 23 | import json 24 | import os 25 | 26 | import httpx 27 | from dotenv import find_dotenv, load_dotenv 28 | 29 | load_dotenv(find_dotenv()) 30 | message = ManageSuccess( 31 | "1. 成功创建了名为「feishu-openai 项目群」的新项目群。\n2. 已向 @river 和 @zoe 发送邀请,等待其加入群聊。" 32 | ) 33 | print("message", json.dumps(message)) 34 | result = httpx.post( 35 | os.environ.get("TEST_BOT_HOOK"), 36 | json={"card": message, "msg_type": "interactive"}, 37 | ).json() 38 | print("result", result) 39 | -------------------------------------------------------------------------------- /server/utils/lark/post_message.py: -------------------------------------------------------------------------------- 1 | def post_content_to_markdown(content, merge_title=True, on_at=None, on_img=None): 2 | text = [] 3 | for row in content.get("content", []): 4 | line_text = [] 5 | for item in row: 6 | item_text = "" 7 | if "text" == item["tag"]: 8 | item_text = item["text"] 9 | elif "at" == item["tag"]: 10 | user_name = on_at(item) if on_at else item["user_name"] 11 | user_id = on_at(item) if on_at else item["user_id"] 12 | item_text = f"{user_id}" 13 | elif "a" == item["tag"]: 14 | item_text = f"[{item['text']}]({item['href']})" 15 | elif "img" == item["tag"]: 16 | image_key = on_img(item) if on_img else item["image_key"] 17 | item_text = f"![]({image_key})" 18 | elif "media" == item["tag"]: 19 | pass 20 | elif "emotion" == item["tag"]: 21 | pass 22 | for s in item.get("style", []): 23 | if "bold" == s: 24 | item_text = f"**{item_text}**" 25 | elif "underline" == s: 26 | item_text = f"{item_text}" 27 | elif "italic" == s: 28 | item_text = f"_{item_text}_" 29 | elif "lineThrough" == s: 30 | item_text = f"~{item_text}~" 31 | line_text.append(item_text) 32 | text.append("".join(line_text)) 33 | title = content.get("title", "") 34 | content_text = " \n".join(text) 35 | if merge_title and title: 36 | content_text = f"# {title} \n{content_text}" 37 | 38 | return content_text, title 39 | 40 | 41 | if __name__ == "__main__": 42 | content = { 43 | "title": "", 44 | "content": [ 45 | [{"tag": "text", "text": "/edit", "style": []}], 46 | [{"tag": "text", "text": "测试描述", "style": []}], 47 | [{"tag": "text", "text": "测试quote", "style": []}], 48 | [{"tag": "text", "text": "测试字体", "style": ["bold"]}], 49 | [{"tag": "text", "text": "测试横线", "style": ["lineThrough"]}], 50 | [{"tag": "a", "href": "http://baidu.com", "text": "测试链接 ", "style": []}], 51 | [{"tag": "text", "text": "测试图片", "style": []}], 52 | [ 53 | { 54 | "tag": "img", 55 | "image_key": "img_v3_0275_f817893e-89c4-429d-a97d-85d0899a84bg", 56 | "width": 651, 57 | "height": 297, 58 | } 59 | ], 60 | ], 61 | } 62 | content = { 63 | "title": "", 64 | "content": [ 65 | [{"tag": "text", "text": "测试回复post消息", "style": ["bold"]}], 66 | [ 67 | {"tag": "text", "text": "1. ", "style": []}, 68 | {"tag": "text", "text": "aaa", "style": []}, 69 | ], 70 | [ 71 | {"tag": "text", "text": "2. ", "style": []}, 72 | {"tag": "text", "text": "sda", "style": []}, 73 | ], 74 | [ 75 | {"tag": "text", "text": "3. ", "style": []}, 76 | {"tag": "text", "text": "fff", "style": []}, 77 | ], 78 | [{"tag": "text", "text": "测试列表", "style": ["bold"]}], 79 | [ 80 | {"tag": "text", "text": "- ", "style": []}, 81 | {"tag": "text", "text": "a", "style": []}, 82 | ], 83 | [ 84 | {"tag": "text", "text": "- ", "style": []}, 85 | {"tag": "text", "text": "b", "style": []}, 86 | ], 87 | [ 88 | {"tag": "text", "text": "- ", "style": []}, 89 | {"tag": "text", "text": "c", "style": []}, 90 | ], 91 | [{"tag": "text", "text": "测试quote", "style": ["bold"]}], 92 | [{"tag": "text", "text": "测试quote", "style": []}], 93 | [{"tag": "a", "href": "http://baidu.com", "text": "测试链接", "style": []}], 94 | [{"tag": "text", "text": "", "style": []}], 95 | [{"tag": "text", "text": "", "style": []}], 96 | [{"tag": "unknown", "text": ""}], 97 | ], 98 | } 99 | 100 | text, title = post_content_to_markdown(content) 101 | 102 | print("content: ", content) 103 | print("post_content_to_markdown: ", title, text) 104 | -------------------------------------------------------------------------------- /server/utils/lark/pr_card.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class PullCard(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | repo_url="https://github.com/ConnectAI-E/GitMaya", 8 | id=16, 9 | title="", 10 | base=None, 11 | head=None, 12 | description=None, 13 | persons=[], 14 | assignees=[], 15 | reviewers=[], 16 | creater=None, 17 | is_creater_outside=False, 18 | status="待合并", 19 | merged=False, 20 | labels=[], 21 | updated="2022年12月23日 16:32", 22 | ): 23 | pr_url = f"{repo_url}/pull/{id}" 24 | template = "red" 25 | assignees = ( 26 | "".join([f"" for open_id in assignees]) 27 | if len(assignees) > 0 28 | else "**待分配**" 29 | ) 30 | reviewers = ( 31 | "".join([f"" for open_id in reviewers]) 32 | if len(reviewers) > 0 33 | else "**待分配**" 34 | ) 35 | creater = ( 36 | f"{creater}(组织外用户)" if is_creater_outside else f"" 37 | ) 38 | label = ( 39 | "、".join(labels) if len(labels) > 0 else "**待补充**" 40 | ) 41 | desc_block = ( 42 | [ 43 | FeishuMessageDiv( 44 | "💬 **主要内容**", tag="lark_md" 45 | ), 46 | FeishuMessageMarkdown( 47 | # TODO 替换content 48 | description, 49 | text_align="left", 50 | ), 51 | ] 52 | if description 53 | else [] 54 | ) 55 | elements = [ 56 | FeishuMessageColumnSet( 57 | FeishuMessageColumn( 58 | *desc_block, 59 | FeishuMessageMarkdown( 60 | # TODO 替换content 61 | f"🌿 **分支合并**\n[{head['ref']}]({repo_url}/tree/{head['ref']}) -> [{base['ref']}]({repo_url}/tree/{base['ref']})", 62 | text_align="left", 63 | ), 64 | FeishuMessageColumnSet( 65 | FeishuMessageColumn( 66 | FeishuMessageMarkdown( 67 | # TODO 68 | f"🚧 **状态** \n**{status} **", 69 | text_align="left", 70 | ), 71 | width="weighted", 72 | weight=1, 73 | vertical_align="top", 74 | ), 75 | FeishuMessageColumn( 76 | FeishuMessageMarkdown( 77 | # TODO 78 | f"👋 **负责人**\n{assignees}", 79 | text_align="left", 80 | ), 81 | width="weighted", 82 | weight=1, 83 | vertical_align="top", 84 | ), 85 | FeishuMessageColumn( 86 | FeishuMessageMarkdown( 87 | # TODO 88 | f"👋 **审核人**\n{reviewers}", 89 | text_align="left", 90 | ), 91 | width="weighted", 92 | weight=1, 93 | vertical_align="top", 94 | ), 95 | FeishuMessageColumn( 96 | FeishuMessageMarkdown( 97 | # TODO 98 | f"🏷 **标签** \n{label}", 99 | text_align="left", 100 | ), 101 | width="weighted", 102 | weight=1, 103 | vertical_align="top", 104 | ), 105 | FeishuMessageColumn( 106 | FeishuMessageMarkdown( 107 | f"🧔 **创建人**\n{creater}", 108 | text_align="left", 109 | ), 110 | width="weighted", 111 | weight=1, 112 | vertical_align="top", 113 | ), 114 | flex_mode="bisect", 115 | background_style="grey", 116 | ), 117 | width="weighted", 118 | weight=1, 119 | vertical_align="top", 120 | ), 121 | flex_mode="none", 122 | background_style="grey", 123 | ), 124 | FeishuMessageAction( 125 | ( 126 | FeishuMessageButton("已合并", type="default", value={"value": ""}) 127 | if merged 128 | else FeishuMessageButton( 129 | "合并 PR", type="primary", value={"command": f"/merge"} 130 | ) 131 | ), 132 | ( 133 | FeishuMessageButton( 134 | "重新打开 PR", 135 | type="primary", 136 | value={"command": "/deny" if merged else "/reopen"}, 137 | ) 138 | if status == "已关闭" 139 | else FeishuMessageButton( 140 | "关闭 PR", type="danger", value={"command": f"/close"} 141 | ) 142 | ), 143 | FeishuMessageButton( 144 | "查看 File Changed", 145 | type="plain_text", 146 | multi_url={ 147 | "url": f"{pr_url}/files", 148 | "android_url": f"{pr_url}/files", 149 | "ios_url": f"{pr_url}/files", 150 | "pc_url": f"{pr_url}/files", 151 | }, 152 | ), 153 | FeishuMessageButton( 154 | "Commits Log", 155 | type="plain_text", 156 | multi_url={ 157 | "url": f"{pr_url}/commits", 158 | "android_url": f"{pr_url}/commits", 159 | "ios_url": f"{pr_url}/commits", 160 | "pc_url": f"{pr_url}/commits", 161 | }, 162 | ), 163 | FeishuMessageButton( 164 | "AI Explain", 165 | type="plain_text", 166 | value={ 167 | "command": f"/explain", 168 | }, 169 | ), 170 | FeishuMessageSelectPerson( 171 | *[FeishuMessageOption(value=open_id) for open_id in persons], 172 | placeholder="修改负责人", 173 | value={ 174 | "command": f"/assign ", 175 | "suffix": "$option", 176 | }, 177 | ), 178 | FeishuMessageSelectPerson( 179 | *[FeishuMessageOption(value=open_id) for open_id in persons], 180 | placeholder="修改审核人", 181 | value={ 182 | "command": f"/review ", 183 | "suffix": "$option", 184 | }, 185 | ), 186 | FeishuMessageButton( 187 | "在浏览器中打开", 188 | tag="lark_md", 189 | type="default", 190 | multi_url={ 191 | "url": pr_url, 192 | "android_url": pr_url, 193 | "ios_url": pr_url, 194 | "pc_url": pr_url, 195 | }, 196 | ), 197 | ), 198 | GitMayaCardNote(f"最近更新 {updated}"), 199 | ] 200 | header = FeishuMessageCardHeader(f"#PR{id} {title}", template=template) 201 | config = FeishuMessageCardConfig() 202 | 203 | super().__init__(*elements, header=header, config=config) 204 | 205 | 206 | if __name__ == "__main__": 207 | import json 208 | import os 209 | 210 | import httpx 211 | from dotenv import find_dotenv, load_dotenv 212 | 213 | load_dotenv(find_dotenv()) 214 | message = PullCard( 215 | title="优化 OpenAI 默认返回的表格在飞书对话中的呈现", 216 | description="💬 **主要内容**\n功能改善建议 🚀\n优化 OpenAI 默认返回的表格在飞书对话中的呈现。\n\n## 您的建议是什么? 🤔\n\n当前问题1:当要求 OpenAI 使用表格对内容进行格式化返回时,默认会返回 Markdown 格式的文本形式,在飞书对话中显示会很混乱,特别是在手机上查看时。\n\n当前问题2:飞书对话默认不支持 Markdown 语法表格的可视化。\n\n功能预期:返回对话消息如果识别为包含表格内容,支持将内容输出至飞书多维表格,并在对话中返回相应链接。", 217 | assignees=[("River", "https://github.com/Leizhenpeng")], 218 | persons=os.environ.get("TEST_USER_OPEN_ID").split(","), 219 | tags=["bug", "doc"], 220 | updated="2022年12月23日 16:32", 221 | ) 222 | print("message", json.dumps(message)) 223 | result = httpx.post( 224 | os.environ.get("TEST_BOT_HOOK"), 225 | json={"card": message, "msg_type": "interactive"}, 226 | ).json() 227 | print("result", result) 228 | -------------------------------------------------------------------------------- /server/utils/lark/pr_manual.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class PrManual(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | repo_url="https://github.com/ConnectAI-E/GitMaya", 8 | pr_id=17, 9 | persons=[], 10 | assignees=[], 11 | tags=[], 12 | merged=False, 13 | ): 14 | pr_url = f"{repo_url}/pull/{pr_id}" 15 | elements = [ 16 | FeishuMessageDiv( 17 | content="** 🤠 haloooo,我是 Maya~ **\n对 GitMaya 有新想法? 来 GitHub 贡献你的代码吧。", 18 | tag="lark_md", 19 | extra=FeishuMessageButton( 20 | "⭐️ Star Maya", 21 | tag="lark_md", 22 | type="primary", 23 | multi_url={ 24 | "url": repo_url, 25 | "android_url": repo_url, 26 | "ios_url": repo_url, 27 | "pc_url": repo_url, 28 | }, 29 | ), 30 | ), 31 | FeishuMessageHr(), 32 | FeishuMessageDiv( 33 | content="** 🕹️ 更新 Pr 状态**\n*话题下回复「 /merge, /close, /reopen」 *", 34 | tag="lark_md", 35 | extra=FeishuMessageButton( 36 | "Merge PR", 37 | tag="lark_md", 38 | type="primary", 39 | value={ 40 | "command": f"/merge ", 41 | }, 42 | ) 43 | if not merged 44 | else None, 45 | ), 46 | FeishuMessageDiv( 47 | content="** 🕹️ 查看 Commits Log**\n*话题下回复「 /log 」*", 48 | tag="lark_md", 49 | extra=FeishuMessageButton( 50 | "查看 Commits Log", 51 | tag="lark_md", 52 | type="primary", 53 | multi_url={ 54 | "url": f"{pr_url}/commits", 55 | "android_url": f"{pr_url}/commits", 56 | "ios_url": f"{pr_url}/commits", 57 | "pc_url": f"{pr_url}/commits", 58 | }, 59 | ), 60 | ), 61 | FeishuMessageDiv( 62 | content="** 🔄 查看 File Changed**\n*话题下回复「 /diff 」 *", 63 | tag="lark_md", 64 | extra=FeishuMessageButton( 65 | "查看 File changed", 66 | tag="lark_md", 67 | type="default", 68 | multi_url={ 69 | "url": f"{pr_url}/files", 70 | "android_url": f"{pr_url}/files", 71 | "ios_url": f"{pr_url}/files", 72 | "pc_url": f"{pr_url}/files", 73 | }, 74 | ), 75 | ), 76 | FeishuMessageDiv( 77 | content="** 🔥 AI Summary **\n*话题下回复「 /summary 」 *", 78 | tag="lark_md", 79 | extra=FeishuMessageButton( 80 | "Run", 81 | tag="lark_md", 82 | type="default", 83 | multi_url={ 84 | "url": pr_url, 85 | "android_url": pr_url, 86 | "ios_url": pr_url, 87 | "pc_url": pr_url, 88 | }, 89 | ), 90 | ), 91 | FeishuMessageDiv( 92 | content="** 🏖️ 重新分配 Pr 负责人**\n*话题下回复「/assign + @成员」 *", 93 | tag="lark_md", 94 | extra=FeishuMessageSelectPerson( 95 | *[FeishuMessageOption(value=open_id) for open_id in persons], 96 | placeholder="", 97 | value={ 98 | "command": f"/assign ", 99 | "suffix": "$option", 100 | }, 101 | ), 102 | ), 103 | FeishuMessageDiv( 104 | content="** 🏖️ 分配 Pr 审核人**\n*话题下回复「/review + @成员」 *", 105 | tag="lark_md", 106 | extra=FeishuMessageSelectPerson( 107 | *[FeishuMessageOption(value=open_id) for open_id in persons], 108 | placeholder="", 109 | value={ 110 | "command": f"/review ", 111 | "suffix": "$option", 112 | }, 113 | ), 114 | ), 115 | FeishuMessageDiv( 116 | content="** 🏷️ 修改 Pr 标签**\n*话题下回复「/label + 标签名」 *", 117 | tag="lark_md", 118 | ), 119 | FeishuMessageDiv( 120 | content="** 📑 修改 Pr 标题**\n*话题下回复「 /rename + 新 Pr 标题 」 *", 121 | tag="lark_md", 122 | ), 123 | FeishuMessageDiv( 124 | content="** 📝 编辑 Pr 描述**\n*话题下回复「 /edit + 另起一行 + 新 pr 描述 」 *", 125 | tag="lark_md", 126 | ), 127 | FeishuMessageDiv( 128 | content="** ⌨️ 在 Pr 下评论**\n*话题下直接回复「 你的评论」 *", 129 | tag="lark_md", 130 | ), 131 | FeishuMessageDiv( 132 | content="** ⚡️ 查看更多 Pr 信息 **\n*话题下回复「 /view 」 *", 133 | tag="lark_md", 134 | extra=FeishuMessageButton( 135 | "在浏览器中打开", 136 | tag="lark_md", 137 | type="default", 138 | multi_url={ 139 | "url": pr_url, 140 | "android_url": pr_url, 141 | "ios_url": pr_url, 142 | "pc_url": pr_url, 143 | }, 144 | ), 145 | ), 146 | GitMayaCardNote("GitMaya Pr Manual"), 147 | ] 148 | header = FeishuMessageCardHeader("PR MANUAL\n", template="grey") 149 | config = FeishuMessageCardConfig() 150 | 151 | super().__init__(*elements, header=header, config=config) 152 | 153 | 154 | class PullRequestView(FeishuMessageCard): 155 | def __init__( 156 | self, 157 | repo_url="https://github.com/ConnectAI-E/GitMaya", 158 | pr_id=17, 159 | ): 160 | pr_url = f"{repo_url}/pull/{pr_id}" 161 | elements = [ 162 | FeishuMessageDiv( 163 | content=f"** ⚡️ 前往 GitHub 查看信息 **", 164 | tag="lark_md", 165 | extra=FeishuMessageButton( 166 | "在浏览器打开", 167 | tag="lark_md", 168 | type="default", 169 | multi_url={ 170 | "url": pr_url, 171 | "android_url": pr_url, 172 | "ios_url": pr_url, 173 | "pc_url": pr_url, 174 | }, 175 | ), 176 | ), 177 | GitMayaCardNote("GitMaya PullRequest Action"), 178 | ] 179 | header = FeishuMessageCardHeader("🎉 操作成功!") 180 | config = FeishuMessageCardConfig() 181 | 182 | super().__init__(*elements, header=header, config=config) 183 | 184 | 185 | class PullRequestLog(FeishuMessageCard): 186 | def __init__( 187 | self, 188 | repo_url="https://github.com/ConnectAI-E/GitMaya", 189 | pr_id=17, 190 | ): 191 | pr_url = f"{repo_url}/pull/{pr_id}/commits" 192 | elements = [ 193 | FeishuMessageDiv( 194 | content=f"** ⚡️ 前往 GitHub 查看信息 **", 195 | tag="lark_md", 196 | extra=FeishuMessageButton( 197 | "在浏览器打开", 198 | tag="lark_md", 199 | type="default", 200 | multi_url={ 201 | "url": pr_url, 202 | "android_url": pr_url, 203 | "ios_url": pr_url, 204 | "pc_url": pr_url, 205 | }, 206 | ), 207 | ), 208 | GitMayaCardNote("GitMaya PullRequest Action"), 209 | ] 210 | header = FeishuMessageCardHeader("🎉 操作成功!") 211 | config = FeishuMessageCardConfig() 212 | 213 | super().__init__(*elements, header=header, config=config) 214 | 215 | 216 | class PullRequestDiff(FeishuMessageCard): 217 | def __init__( 218 | self, 219 | repo_url="https://github.com/ConnectAI-E/GitMaya", 220 | pr_id=17, 221 | ): 222 | pr_url = f"{repo_url}/pull/{pr_id}/files" 223 | elements = [ 224 | FeishuMessageDiv( 225 | content=f"** ⚡️ 前往 GitHub 查看信息 **", 226 | tag="lark_md", 227 | extra=FeishuMessageButton( 228 | "在浏览器打开", 229 | tag="lark_md", 230 | type="default", 231 | multi_url={ 232 | "url": pr_url, 233 | "android_url": pr_url, 234 | "ios_url": pr_url, 235 | "pc_url": pr_url, 236 | }, 237 | ), 238 | ), 239 | GitMayaCardNote("GitMaya PullRequest Action"), 240 | ] 241 | header = FeishuMessageCardHeader("🎉 操作成功!") 242 | config = FeishuMessageCardConfig() 243 | 244 | super().__init__(*elements, header=header, config=config) 245 | 246 | 247 | if __name__ == "__main__": 248 | import json 249 | import os 250 | from pprint import pprint 251 | 252 | import httpx 253 | from dotenv import find_dotenv, load_dotenv 254 | 255 | load_dotenv(find_dotenv()) 256 | message = PrManual( 257 | pr_id=17, 258 | persons=os.environ.get("TEST_USER_OPEN_ID").split(","), 259 | tags=["bug", "doc"], 260 | ) 261 | pprint(json.dumps(message)) 262 | result = httpx.post( 263 | os.environ.get("TEST_BOT_HOOK"), 264 | json={"card": message, "msg_type": "interactive"}, 265 | ).json() 266 | pprint(result) 267 | -------------------------------------------------------------------------------- /server/utils/lark/pr_tip_commit_history.py: -------------------------------------------------------------------------------- 1 | from utils.constant import MAX_COMMIT_MESSAGE_LENGTH 2 | from utils.github.model import Commit 3 | 4 | from .base import * 5 | 6 | 7 | class PrTipCommitHistory(FeishuMessageCard): 8 | def __init__( 9 | self, 10 | commits: list[Commit], 11 | ): 12 | def process_commit_message(message: str) -> str: 13 | title = message.split("\n")[0] if "\n" in message else message 14 | if len(title) > (MAX_COMMIT_MESSAGE_LENGTH + 3): 15 | title = title[:MAX_COMMIT_MESSAGE_LENGTH] + "..." 16 | return title 17 | 18 | content = "\n".join( 19 | [ 20 | f"[-@{commit.author.username} - {process_commit_message(commit.message)}]({commit.url})" 21 | for commit in commits 22 | ] 23 | ) 24 | 25 | elements = [ 26 | FeishuMessageDiv( 27 | content=content, 28 | tag="lark_md", 29 | ), 30 | GitMayaCardNote("GitMaya PR Action"), 31 | ] 32 | header = FeishuMessageCardHeader("📚 Commit History") 33 | config = FeishuMessageCardConfig() 34 | 35 | super().__init__(*elements, header=header, config=config) 36 | 37 | 38 | if __name__ == "__main__": 39 | import json 40 | import os 41 | 42 | import httpx 43 | from dotenv import find_dotenv, load_dotenv 44 | 45 | load_dotenv(find_dotenv()) 46 | message = PrTipCommitHistory() 47 | print("message", json.dumps(message)) 48 | result = httpx.post( 49 | os.environ.get("TEST_BOT_HOOK"), 50 | json={"card": message, "msg_type": "interactive"}, 51 | ).json() 52 | print("result", result) 53 | -------------------------------------------------------------------------------- /server/utils/lark/pr_tip_failed.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class PrTipFailed(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content="无法重复合并 pr\n\n", 8 | ): 9 | elements = [ 10 | FeishuMessageDiv( 11 | content=content, 12 | tag="lark_md", 13 | ), 14 | GitMayaCardNote("GitMaya Pr Action"), 15 | ] 16 | header = FeishuMessageCardHeader("😕 操作失败!") 17 | config = FeishuMessageCardConfig() 18 | 19 | super().__init__(*elements, header=header, config=config) 20 | 21 | 22 | if __name__ == "__main__": 23 | import json 24 | import os 25 | 26 | import httpx 27 | from dotenv import find_dotenv, load_dotenv 28 | 29 | load_dotenv(find_dotenv()) 30 | message = PrTipFailed() 31 | print("message", json.dumps(message)) 32 | result = httpx.post( 33 | os.environ.get("TEST_BOT_HOOK"), 34 | json={"card": message, "msg_type": "interactive"}, 35 | ).json() 36 | print("result", result) 37 | -------------------------------------------------------------------------------- /server/utils/lark/pr_tip_success.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class PrTipSuccess(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content='1. 已修改 Pr 标题为 "sss"\n2. 已分配任务给 @xx\n3. 已合并 pr\n', 8 | ): 9 | elements = [ 10 | FeishuMessageDiv( 11 | content=content, 12 | tag="lark_md", 13 | ), 14 | GitMayaCardNote("GitMaya Pr Action"), 15 | ] 16 | header = FeishuMessageCardHeader("🎉 操作成功!") 17 | config = FeishuMessageCardConfig() 18 | 19 | super().__init__(*elements, header=header, config=config) 20 | 21 | 22 | if __name__ == "__main__": 23 | import json 24 | import os 25 | 26 | import httpx 27 | from dotenv import find_dotenv, load_dotenv 28 | 29 | load_dotenv(find_dotenv()) 30 | message = PrTipSuccess() 31 | print("message", json.dumps(message)) 32 | result = httpx.post( 33 | os.environ.get("TEST_BOT_HOOK"), 34 | json={"card": message, "msg_type": "interactive"}, 35 | ).json() 36 | print("result", result) 37 | -------------------------------------------------------------------------------- /server/utils/lark/repo_info.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class RepoInfo(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | repo_url="https://github.com/ConnectAI-E/GitMaya", 8 | repo_name="GitMaya", 9 | repo_description="", 10 | repo_topic=[], 11 | homepage=None, 12 | visibility="私有仓库", 13 | archived=False, 14 | updated="2022年12月23日 16:32", 15 | open_issues_count=0, 16 | stargazers_count=1, 17 | forks_count=2, 18 | ): 19 | labels = ( 20 | "、".join(repo_topic) 21 | if len(repo_topic) > 0 22 | else "**待补充**" 23 | ) 24 | description = ( 25 | repo_description 26 | if repo_description is not None 27 | else "**待补充**" 28 | ) 29 | homepage = ( 30 | f"[{homepage}]({homepage})" 31 | if homepage is not None and homepage != "" 32 | else "**待补充**" 33 | ) 34 | elements = [ 35 | FeishuMessageColumnSet( 36 | FeishuMessageColumn( 37 | FeishuMessageColumnSet( 38 | FeishuMessageColumn( 39 | FeishuMessageMarkdown( 40 | f"**📦 仓库名:** \n{repo_name}", 41 | text_align="left", 42 | ), 43 | width="weighted", 44 | weight=1, 45 | vertical_align="top", 46 | ), 47 | FeishuMessageColumn( 48 | FeishuMessageMarkdown( 49 | f"**👀 可见性:**\n{visibility}", 50 | text_align="left", 51 | ), 52 | width="weighted", 53 | weight=1, 54 | vertical_align="top", 55 | ), 56 | FeishuMessageColumn( 57 | FeishuMessageMarkdown( 58 | f"**🌐 Homepage: **\n{homepage}", 59 | text_align="left", 60 | ), 61 | width="weighted", 62 | weight=1, 63 | vertical_align="top", 64 | ), 65 | flex_mode="stretch", 66 | background_style="grey", 67 | ), 68 | FeishuMessageMarkdown( 69 | f"**🗒️ 描述:**\n{description}", text_align="left" 70 | ), 71 | FeishuMessageMarkdown( 72 | f"🏷️ **标签:**\n{labels}", 73 | text_align="left,", 74 | ), 75 | width="weighted", 76 | weight=1, 77 | vertical_align="top", 78 | ), 79 | flex_mode="flow", 80 | background_style="grey", 81 | ), 82 | FeishuMessageColumnSet( 83 | FeishuMessageColumn( 84 | FeishuMessageColumnSet( 85 | FeishuMessageColumn( 86 | FeishuMessageMarkdown( 87 | f"**Issue 状态**\n累计 {open_issues_count} 条" 88 | ), 89 | width="weighted", 90 | weight=1, 91 | vertical_align="top", 92 | ), 93 | FeishuMessageColumn( 94 | FeishuMessageMarkdown( 95 | f"**Fork 热度**\n累计 {forks_count} 条", 96 | ), 97 | width="weighted", 98 | weight=1, 99 | vertical_align="top", 100 | ), 101 | FeishuMessageColumn( 102 | FeishuMessageMarkdown( 103 | f"**Star 热度**\n累计 {stargazers_count} 颗", 104 | ), 105 | width="auto", 106 | weight=1, 107 | vertical_align="top", 108 | ), 109 | flex_mode="stretch", 110 | background_style="grey", 111 | ), 112 | ), 113 | flex_mode="flow", 114 | background_style="grey", 115 | ), 116 | FeishuMessageAction( 117 | FeishuMessageButton( 118 | "新建 Issue", 119 | type="primary", 120 | multi_url={ 121 | "url": f"{repo_url}/issues/new", 122 | "android_url": f"{repo_url}/issues/new", 123 | "ios_url": f"{repo_url}/issues/new", 124 | "pc_url": f"{repo_url}/issues/new", 125 | }, 126 | ), 127 | FeishuMessageButton( 128 | "打开 Repo Insight", 129 | type="plain_text", 130 | multi_url={ 131 | "url": f"{repo_url}/pulse", 132 | "android_url": f"{repo_url}/pulse", 133 | "ios_url": f"{repo_url}/pulse", 134 | "pc_url": f"{repo_url}/pulse", 135 | }, 136 | ), 137 | FeishuMessageButton( 138 | "在 GitHub 中打开", 139 | type="plain_text", 140 | multi_url={ 141 | "url": repo_url, 142 | "android_url": repo_url, 143 | "ios_url": repo_url, 144 | "pc_url": repo_url, 145 | }, 146 | ), 147 | # FeishuMessageOverflow( 148 | # FeishuMessageOption( 149 | # value="appStore", 150 | # content="敬请期待", 151 | # ), 152 | # FeishuMessageOption( 153 | # value="appStore", 154 | # content="暂停使用项目群", 155 | # ), 156 | # FeishuMessageOption( 157 | # value="appStore", 158 | # content="更新仓库状态", 159 | # ), 160 | # ), 161 | ), 162 | GitMayaCardNote(f"最近更新 {updated.split('T')[0]}"), 163 | ] 164 | 165 | header = ( 166 | FeishuMessageCardHeader( 167 | f"{repo_name} 仓库信息 ** (已归档) **", 168 | tag="lark_md", 169 | template="blue", 170 | ) 171 | if archived 172 | else FeishuMessageCardHeader(f"{repo_name} 仓库信息 ", template="blue") 173 | ) 174 | 175 | config = FeishuMessageCardConfig() 176 | 177 | super().__init__(*elements, header=header, config=config) 178 | 179 | 180 | if __name__ == "__main__": 181 | import json 182 | import os 183 | 184 | import httpx 185 | from dotenv import find_dotenv, load_dotenv 186 | 187 | load_dotenv(find_dotenv()) 188 | message = RepoInfo( 189 | repo_url="https://github.com/ConnectAI-E/GitMaya", 190 | repo_name="GitMaya", 191 | ) 192 | print("message", json.dumps(message)) 193 | result = httpx.post( 194 | os.environ.get("TEST_BOT_HOOK"), 195 | json={"card": message, "msg_type": "interactive"}, 196 | ).json() 197 | print("result", result) 198 | -------------------------------------------------------------------------------- /server/utils/lark/repo_manual.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class RepoManual(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | repo_name="GitMaya", 8 | repo_url="https://github.com/ConnectAI-E/GitMaya", 9 | repo_description="待补充", 10 | visibility="public", 11 | statuses=["public", "private"], 12 | archived=False, 13 | ): 14 | elements = [ 15 | GitMayaTitle(), 16 | FeishuMessageHr(), 17 | FeishuMessageDiv( 18 | content="** 👀 修改 Repo 可见性**\n*话题下回复「/visit + public, private」*", 19 | tag="lark_md", 20 | extra=FeishuMessageSelect( 21 | *[ 22 | FeishuMessageOption(value=status, content=status) 23 | for status in statuses 24 | ], 25 | placeholder="", 26 | value={ 27 | "command": f"/visit ", 28 | "suffix": "$option", 29 | }, 30 | ), 31 | ), 32 | FeishuMessageDiv( 33 | content="**🥂 修改 Repo 访问权限**\n*话题下回复「/access + read, triger, wirte, maintain, admin + @成员」*", 34 | tag="lark_md", 35 | ), 36 | # repo 标题有问题,先不开放 37 | # FeishuMessageDiv( 38 | # content="** 📑 修改 Repo 标题**\n*话题下回复「/rename + 新 Repo 名称」 *", 39 | # tag="lark_md", 40 | # ), 41 | FeishuMessageDiv( 42 | content="**📝 修改 Repo 描述**\n*话题下回复「/edit + 新 Repo 描述」*", 43 | tag="lark_md", 44 | ), 45 | FeishuMessageDiv( 46 | content="**⌨️ 修改 Repo 网页**\n*话题下回复「/link + 新 Repo homepage url」*", 47 | tag="lark_md", 48 | ), 49 | FeishuMessageDiv( 50 | content="**🏷 添加 Repo 标签**\n*话题下回复「/label + 标签名」*", 51 | tag="lark_md", 52 | ), 53 | FeishuMessageDiv( 54 | content=f"**🕒 更新 Repo 状态**\n*话题下回复「/archive、/unarchive」*", 55 | tag="lark_md", 56 | extra=FeishuMessageButton( 57 | f"{'UnArchive' if archived else 'Archive'} Repo", 58 | tag="lark_md", 59 | type="primary" if archived else "danger", 60 | value={"command": "/unarchive" if archived else "/archive"}, 61 | ), 62 | ), 63 | FeishuMessageDiv( 64 | content=f"**⚡️ 前往 GitHub 查看 Repo 主页 **\n*话题下回复「/view」*", 65 | tag="lark_md", 66 | extra=FeishuMessageButton( 67 | "打开 GitHub 主页", 68 | tag="lark_md", 69 | type="default", 70 | multi_url={ 71 | "url": repo_url, 72 | "android_url": repo_url, 73 | "ios_url": repo_url, 74 | "pc_url": repo_url, 75 | }, 76 | ), 77 | ), 78 | FeishuMessageDiv( 79 | content=f"**📈 前往 GitHub 查看 Repo Insight **\n*话题下回复「/insight」*", 80 | tag="lark_md", 81 | extra=FeishuMessageButton( 82 | "打开 Insight 面板", 83 | tag="lark_md", 84 | type="default", 85 | multi_url={ 86 | "url": f"{repo_url}/pulse", 87 | "android_url": f"{repo_url}/pulse", 88 | "ios_url": f"{repo_url}/pulse", 89 | "pc_url": f"{repo_url}/pulse", 90 | }, 91 | ), 92 | ), 93 | GitMayaCardNote("GitMaya Repo Manual"), 94 | ] 95 | header = FeishuMessageCardHeader("GitMaya Repo Manual\n", template="blue") 96 | config = FeishuMessageCardConfig() 97 | 98 | super().__init__(*elements, header=header, config=config) 99 | 100 | 101 | class RepoView(FeishuMessageCard): 102 | def __init__( 103 | self, 104 | repo_url="https://github.com/ConnectAI-E/GitMaya", 105 | ): 106 | elements = [ 107 | FeishuMessageDiv( 108 | content=f"** ⚡️ 前往 GitHub 查看信息 **", 109 | tag="lark_md", 110 | extra=FeishuMessageButton( 111 | "在浏览器打开", 112 | tag="lark_md", 113 | type="default", 114 | multi_url={ 115 | "url": repo_url, 116 | "android_url": repo_url, 117 | "ios_url": repo_url, 118 | "pc_url": repo_url, 119 | }, 120 | ), 121 | ), 122 | GitMayaCardNote("GitMaya Repo Action"), 123 | ] 124 | header = FeishuMessageCardHeader("🎉 操作成功!") 125 | config = FeishuMessageCardConfig() 126 | 127 | super().__init__(*elements, header=header, config=config) 128 | 129 | 130 | if __name__ == "__main__": 131 | import json 132 | import os 133 | 134 | import httpx 135 | from dotenv import find_dotenv, load_dotenv 136 | 137 | load_dotenv(find_dotenv()) 138 | message = RepoManual() 139 | print("message", json.dumps(message)) 140 | result = httpx.post( 141 | os.environ.get("TEST_BOT_HOOK"), 142 | json={"card": message, "msg_type": "interactive"}, 143 | ).json() 144 | print("result", result) 145 | -------------------------------------------------------------------------------- /server/utils/lark/repo_tip_failed.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class RepoTipFailed(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content="", 8 | ): 9 | elements = [ 10 | FeishuMessageDiv( 11 | content=content, 12 | tag="lark_md", 13 | ), 14 | GitMayaCardNote("GitMaya Repo Action"), 15 | ] 16 | header = FeishuMessageCardHeader("😕 操作失败!") 17 | config = FeishuMessageCardConfig() 18 | 19 | super().__init__(*elements, header=header, config=config) 20 | 21 | 22 | if __name__ == "__main__": 23 | import json 24 | import os 25 | 26 | import httpx 27 | from dotenv import find_dotenv, load_dotenv 28 | 29 | load_dotenv(find_dotenv()) 30 | message = RepoTipFailed() 31 | print("message", json.dumps(message)) 32 | result = httpx.post( 33 | os.environ.get("TEST_BOT_HOOK"), 34 | json={"card": message, "msg_type": "interactive"}, 35 | ).json() 36 | print("result", result) 37 | -------------------------------------------------------------------------------- /server/utils/lark/repo_tip_success.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class RepoTipSuccess(FeishuMessageCard): 5 | def __init__( 6 | self, 7 | content="操作成功!", 8 | ): 9 | elements = [ 10 | FeishuMessageDiv( 11 | content=content, 12 | tag="lark_md", 13 | ), 14 | GitMayaCardNote("GitMaya Repo Action"), 15 | ] 16 | header = FeishuMessageCardHeader("🎉 操作成功!") 17 | config = FeishuMessageCardConfig() 18 | 19 | super().__init__(*elements, header=header, config=config) 20 | 21 | 22 | if __name__ == "__main__": 23 | import json 24 | import os 25 | 26 | import httpx 27 | from dotenv import find_dotenv, load_dotenv 28 | 29 | load_dotenv(find_dotenv()) 30 | message = RepoTipSuccess() 31 | print("message", json.dumps(message)) 32 | result = httpx.post( 33 | os.environ.get("TEST_BOT_HOOK"), 34 | json={"card": message, "msg_type": "interactive"}, 35 | ).json() 36 | print("result", result) 37 | -------------------------------------------------------------------------------- /server/utils/redis.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import logging 4 | import pickle 5 | import random 6 | from inspect import iscoroutinefunction 7 | 8 | import redis 9 | from app import app 10 | 11 | app.config.setdefault("REDIS_URL", "redis://redis:6379/0") 12 | 13 | client = redis.from_url(app.config["REDIS_URL"], decode_responses=True) 14 | 15 | 16 | class RedisStorage(object): 17 | def __init__(self, **kwargs): 18 | for k, v in kwargs.items(): 19 | if v: 20 | self.set(k, v) 21 | 22 | def get(self, name): 23 | return client.get(name) 24 | 25 | def set(self, name, value): 26 | client.set(name, value) 27 | 28 | 29 | def get_client(decode_responses=False): 30 | return redis.from_url(app.config["REDIS_URL"], decode_responses=decode_responses) 31 | 32 | 33 | def gen_prefix(obj, method): 34 | return ".".join([obj.__module__, obj.__class__.__name__, method.__name__]) 35 | 36 | 37 | def stalecache( 38 | key=None, 39 | expire=600, 40 | stale=3600, 41 | time_lock=1, 42 | time_delay=1, 43 | max_time_delay=10, 44 | ): 45 | def decorate(method): 46 | @functools.wraps(method) 47 | def wrapper(*args, **kwargs): 48 | if kwargs.get("skip_cache"): 49 | return method(*args, **kwargs) 50 | name = args[0] if args and not key else None 51 | 52 | res = get_client(False).pipeline().ttl(name).get(name).execute() 53 | v = pickle.loads(res[1]) if res[0] > 0 and res[1] else None 54 | if res[0] <= 0 or res[0] < stale: 55 | 56 | def func(): 57 | value = method(*args, **kwargs) 58 | logging.debug("update cache: %s", name) 59 | get_client(False).pipeline().set(name, pickle.dumps(value)).expire( 60 | name, expire + stale 61 | ).execute() 62 | return value 63 | 64 | # create new cache in blocking modal, if cache not exists. 65 | if res[0] <= 0: 66 | return func() 67 | 68 | # create new cache in non blocking modal, and return stale data. 69 | # set expire to get a "lock", and delay to run the task 70 | real_time_delay = random.randrange(time_delay, max_time_delay) 71 | get_client(False).expire(name, stale + real_time_delay + time_lock) 72 | # 创建一个 asyncio 任务来执行 func 73 | loop = asyncio.get_event_loop() 74 | loop.run_until_complete(asyncio.sleep(real_time_delay, func())) 75 | 76 | return v 77 | 78 | @functools.wraps(method) 79 | async def async_wrapper(*args, **kwargs): 80 | if kwargs.get("skip_cache"): 81 | return await method(*args, **kwargs) 82 | 83 | name = args[0] if args and not key else None 84 | 85 | res = get_client(False).pipeline().ttl(name).get(name).execute() 86 | v = pickle.loads(res[1]) if res[0] > 0 and res[1] else None 87 | if res[0] <= 0 or res[0] < stale: 88 | 89 | async def func(): 90 | value = await method(*args, **kwargs) 91 | logging.debug("update cache: %s", name) 92 | get_client(False).pipeline().set(name, pickle.dumps(value)).expire( 93 | name, expire + stale 94 | ).execute() 95 | return value 96 | 97 | # create new cache in blocking modal, if cache not exists. 98 | if res[0] <= 0: 99 | return await func() 100 | 101 | # create new cache in non blocking modal, and return stale data. 102 | # set expire to get a "lock", and delay to run the task 103 | real_time_delay = random.randrange(time_delay, max_time_delay) 104 | get_client(False).expire(name, stale + real_time_delay + time_lock) 105 | loop = asyncio.get_event_loop() 106 | loop.run_until_complete(asyncio.sleep(real_time_delay, func())) 107 | 108 | return v 109 | 110 | return async_wrapper if iscoroutinefunction(method) else wrapper 111 | 112 | return decorate 113 | -------------------------------------------------------------------------------- /server/utils/user.py: -------------------------------------------------------------------------------- 1 | from app import app, db 2 | from flask import abort 3 | from model.schema import BindUser, ObjID, User 4 | from model.team import add_team_member 5 | from utils.github.account import get_email, get_user_info 6 | from utils.github.application import oauth_by_code 7 | 8 | 9 | def register(code: str) -> str | None: 10 | """GitHub OAuth register. 11 | 12 | If `code`, register by code. 13 | 14 | Args: 15 | code (str): The code of the GitHub OAuth. 16 | 17 | Returns: 18 | str | None: The id of the user. 19 | """ 20 | 21 | oauth_info = oauth_by_code(code) # 获取 access token 22 | if oauth_info is None: 23 | abort(500) 24 | 25 | access_token = oauth_info.get("access_token", None)[0] # 这里要考虑取哪个,为什么会有多个? 26 | # TODO: 预备好对 user_access_token 的刷新处理 27 | 28 | # 使用 oauth_info 中的 access_token 获取用户信息 29 | user_info = get_user_info(access_token) 30 | 31 | # 查询 github_id 是否已经存在,若存在,则刷新 access_token,返回 user_id 32 | github_id = str(user_info.get("id", None)) 33 | 34 | email = get_email(access_token) 35 | 36 | new_user_id, _ = create_github_user( 37 | github_id=github_id, 38 | name=user_info.get("login", None), 39 | email=email, 40 | avatar=user_info.get("avatar_url", None), 41 | access_token=access_token, 42 | extra={"user_info": user_info, "oauth_info": oauth_info}, 43 | ) 44 | 45 | return new_user_id 46 | 47 | 48 | def create_github_user( 49 | github_id: str, 50 | name: str, 51 | email: str, 52 | avatar: str, 53 | access_token: str = None, 54 | application_id: str = None, 55 | extra: dict = {}, 56 | ) -> (str, str): 57 | """Create a GitHub user. 58 | 59 | Args: 60 | name (str): The name of the user. 61 | email (str): The email of the user. 62 | avatar (str): The avatar of the user. 63 | extra (dict): The extra of the user. 64 | 65 | Returns: 66 | str: The id of the user. 67 | """ 68 | 69 | if github_id is None: 70 | raise Exception("Failed to get github_id.") 71 | 72 | user = User.query.filter_by(unionid=github_id).first() 73 | if user is not None: 74 | bind_user = BindUser.query.filter_by(user_id=user.id, platform="github").first() 75 | 76 | if bind_user is None: 77 | raise Exception("Failed to get bind user.") 78 | 79 | # 刷新 access_token 80 | if access_token is not None: 81 | bind_user.access_token = access_token 82 | 83 | # 刷新 email 84 | if email is not None: 85 | bind_user.email = email 86 | 87 | # 通过 GitHub 创建的 BindUser 不再写入更新 application_id 88 | # if application_id is not None: 89 | # bind_user.application_id = application_id 90 | 91 | db.session.commit() 92 | return user.id, bind_user.id 93 | 94 | new_user = User( 95 | id=ObjID.new_id(), 96 | unionid=github_id, 97 | email=email, 98 | name=name, 99 | avatar=avatar, 100 | extra=extra.get("user_info", None), 101 | ) 102 | 103 | db.session.add(new_user) 104 | db.session.flush() 105 | 106 | new_bind_user = BindUser( 107 | id=ObjID.new_id(), 108 | user_id=new_user.id, 109 | platform="github", 110 | email=email, 111 | name=name, 112 | avatar=avatar, 113 | access_token=access_token, 114 | # application_id=application_id, 115 | extra=extra.get("oauth_info", None), 116 | ) 117 | 118 | db.session.add(new_bind_user) 119 | 120 | db.session.commit() 121 | 122 | return new_user.id, new_bind_user.id 123 | 124 | 125 | def create_github_member(members: list, application_id: str, team_id: str) -> list: 126 | """Create GitHub members. 127 | Args: 128 | members (list): The members of the GitHub. 129 | application_id (str): The id of the application. 130 | team_id (str): The id of the team. 131 | Returns: 132 | list: The members. 133 | """ 134 | 135 | for member in members: 136 | # 已存在的用户不会重复创建 137 | _, new_bind_user_id = create_github_user( 138 | github_id=str(member["id"]), 139 | name=member["login"], 140 | email=member.get("email", None), 141 | avatar=member["avatar_url"], 142 | access_token=None, 143 | application_id=application_id, 144 | extra={}, 145 | ) 146 | 147 | add_team_member(team_id, new_bind_user_id) 148 | 149 | return members 150 | -------------------------------------------------------------------------------- /server/utils/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import httpx 5 | from utils.redis import stalecache 6 | 7 | 8 | def process_image(url, bot): 9 | if not url or not url.startswith("http"): 10 | return "" 11 | 12 | if url.startswith(f"{os.environ.get('DOMAIN')}/api"): 13 | return url.split("/")[-1] 14 | return upload_image(url, bot) 15 | 16 | 17 | # 使用 stalecache 装饰器,以 url 作为缓存键 18 | @stalecache(expire=3600, stale=600) 19 | def upload_image(url, bot): 20 | logging.info("upload image: %s", url) 21 | response = httpx.get(url, follow_redirects=True) 22 | if response.status_code == 200: 23 | # 函数返回值: iamg_key 存到缓存中 24 | img_key = upload_image_binary(response.content, bot) 25 | return img_key 26 | else: 27 | return "" 28 | 29 | 30 | def upload_image_binary(img_bin, bot): 31 | url = f"{bot.host}/open-apis/im/v1/images" 32 | 33 | data = {"image_type": "message"} 34 | files = { 35 | "image": img_bin, 36 | } 37 | response = bot.post(url, data=data, files=files).json() 38 | return response["data"]["image_key"] 39 | 40 | 41 | @stalecache(expire=3600, stale=600) 42 | def download_file(file_key, message_id, bot, file_type="image"): 43 | """ 44 | 获取消息中的资源文件,包括音频,视频,图片和文件,暂不支持表情包资源下载。当前仅支持 100M 以内的资源文件的下载 45 | """ 46 | # open-apis/im/v1/images/{img_key} 接口只能下载机器人自己上传的图片 47 | url = f"{bot.host}/open-apis/im/v1/messages/{message_id}/resources/{file_key}?type={file_type}" 48 | 49 | response = bot.get(url) 50 | return response.content 51 | 52 | 53 | def query_one_page(query, page, size): 54 | offset = (page - 1) * int(size) 55 | return ( 56 | query.offset(offset if offset > 0 else 0).limit(size if size > 0 else 0).all() 57 | ) 58 | --------------------------------------------------------------------------------