├── .dockerignore ├── .github └── workflows │ └── megalinter.yml ├── .gitignore ├── .mega-linter.yml ├── .pre-commit-config.yaml ├── .yamllint.yaml ├── CODE_OF_CONDUCT.md ├── LICENSE.MD ├── MAINTAINERS.md ├── Procfile ├── README.md ├── base_bot_manifest.yml ├── bot_manifest.yml ├── build.sh ├── data ├── .DS_Store └── VA-Documents │ ├── 2020-CFR-Title38-Vol-1.pdf │ └── 2020-CFR-Title38-Vol-2.pdf ├── docker ├── Dockerfile ├── docker-compose.local.yml ├── docker-compose.weaviate.yml └── docker-compose.yml ├── main.py ├── modules ├── __init__.py ├── airtable │ ├── __init__.py │ ├── daily_programmer_table.py │ ├── mentorship_tables.py │ ├── message_text_table.py │ ├── scheduled_message_table.py │ └── shared_table.py ├── databases │ ├── __init__.py │ └── vector_database_config.py ├── handlers │ ├── __init__.py │ ├── channel_join_handler.py │ ├── daily_programmer.py │ ├── greeting_handler.py │ ├── mentorship_handler.py │ └── report_handler.py ├── models │ ├── __init__.py │ ├── daily_programmer_models.py │ ├── greeting_models.py │ ├── mentorship_models.py │ ├── message_text_models.py │ ├── report_models.py │ ├── scheduled_message_models.py │ ├── shared_models.py │ └── slack_models │ │ ├── __init__.py │ │ ├── action_models.py │ │ ├── command_models.py │ │ ├── event_models.py │ │ ├── message_models.py │ │ ├── shared_models.py │ │ ├── slack_models.py │ │ └── view_models.py ├── slack │ ├── __init__.py │ └── blocks │ │ ├── __init__.py │ │ ├── announcement_blocks.py │ │ ├── block_kit_examples │ │ ├── channel_join_request_blocks.json │ │ ├── general_announcement.json │ │ ├── greeting_block.json │ │ ├── mentorship │ │ │ ├── mentorship_claim_blocks.json │ │ │ ├── mentorship_request_block.json │ │ │ └── mentorship_request_modal.json │ │ ├── new_join_delayed.json │ │ ├── new_join_immediate.json │ │ └── reports │ │ │ ├── report_claim.json │ │ │ ├── report_form.json │ │ │ ├── response_to_user_on_failed_report.json │ │ │ └── response_to_user_on_successful_report.json │ │ ├── greeting_blocks.py │ │ ├── mentorship_blocks.py │ │ ├── new_join_blocks.py │ │ ├── report_blocks.py │ │ └── shared_blocks.py └── utils │ ├── __init__.py │ ├── daily_programmer_scheduler.py │ ├── example_requests │ ├── mentorship_request_claim_action.json │ ├── pride_request_command.json │ └── view_submission_request.json │ ├── example_responses │ └── view_open_response.json │ ├── message_scheduler.py │ ├── one_off_scripts.py │ ├── vector_delete_data.py │ ├── vector_ingestion.py │ └── vector_search.py ├── poetry.lock ├── pyproject.toml ├── setup.sh └── tests ├── __init__.py ├── conftest.py └── unit ├── __init__.py ├── cassettes ├── TestDailyProgrammerTableBasic.test_mentorship_affiliation_table_has_all_desired_fields.yaml ├── TestDailyProgrammerTableBasic.test_mentorship_affiliation_table_has_correct_number_of_fields.yaml ├── TestMentorTableBasic.test_mentor_table_has_all_desired_fields.yaml ├── TestMentorTableBasic.test_mentor_table_has_correct_number_of_fields.yaml ├── TestMentorshipAffiliationTableBasic.test_mentorship_affiliation_table_has_all_desired_fields.yaml ├── TestMentorshipAffiliationTableBasic.test_mentorship_affiliation_table_has_correct_number_of_fields.yaml ├── TestMentorshipRequestsTableBasic.test_mentorship_affiliation_table_has_all_desired_fields.yaml ├── TestMentorshipRequestsTableBasic.test_mentorship_affiliation_table_has_correct_number_of_fields.yaml ├── TestMentorshipServicesTableBasic.test_mentorship_services_table_has_all_desired_fields.yaml ├── TestMentorshipServicesTableBasic.test_mentorship_services_table_has_correct_number_of_fields.yaml ├── TestMentorshipSkillsetsTableBasic.test_mentorship_skillsets_table_has_all_desired_fields.yaml ├── TestMentorshipSkillsetsTableBasic.test_mentorship_skillsets_table_has_correct_number_of_fields.yaml ├── TestMessageTextTableBasic.test_mentorship_affiliation_table_has_all_desired_fields.yaml ├── TestMessageTextTableBasic.test_mentorship_affiliation_table_has_correct_number_of_fields.yaml ├── TestScheduledMessagesTableBasic.test_mentorship_affiliation_table_has_all_desired_fields.yaml └── TestScheduledMessagesTableBasic.test_mentorship_affiliation_table_has_correct_number_of_fields.yaml └── test_airtable.py /.dockerignore: -------------------------------------------------------------------------------- 1 | fly.toml 2 | .env -------------------------------------------------------------------------------- /.github/workflows/megalinter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: MegaLinter 3 | "on": [push] 4 | 5 | concurrency: 6 | group: ${{ github.ref }}-${{ github.workflow }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | mega-lint: 11 | name: Mega Linter 12 | runs-on: ubuntu-20.04 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Mega Linter 16 | uses: oxsecurity/megalinter/flavors/python@v7 17 | env: 18 | VALIDATE_ALL_CODEBASE: true 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | 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 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | .idea/* 108 | env* 109 | fly.toml 110 | 111 | .ruff_cache -------------------------------------------------------------------------------- /.mega-linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | DISABLE: 3 | - CLOUDFORMATION 4 | - CSS 5 | - EDITORCONFIG 6 | - JAVASCRIPT 7 | - TEKTON 8 | DISABLE_LINTERS: 9 | - JSON_PRETTIER 10 | - PYTHON_BANDIT 11 | - PYTHON_FLAKE8 12 | # We use MyPy 13 | - PYTHON_PYLINT 14 | - PYTHON_PYRIGHT 15 | - REPOSITORY_GITLEAKS 16 | - SPELL_CSPELL 17 | - SPELL_LYCHEE 18 | - SPELL_PROSELINT 19 | DISABLE_ERRORS_LINTERS: 20 | - REPOSITORY_DEVSKIM 21 | - REPOSITORY_SEMGREP 22 | DOCKERFILE_HADOLINT_ARGUMENTS: "--ignore DL3008 --ignore DL3018 --ignore DL3013 --ignore DL3059 --ignore DL3005" 23 | COPYPASTE_JSCPD_ARGUMENTS: "--ignore '**/handlers/**,**/vector*'" 24 | COPYPASTE_JSCPD_DISABLE_ERRORS_IF_LESS_THAN: 28 25 | MARKDOWN_MARKDOWN_LINK_CHECK_CONFIG_FILE: ".markdown-link-check-config.json" 26 | MARKDOWN_MARKDOWN_LINK_CHECK_DISABLE_ERRORS: true 27 | REPOSITORY_CHECKOV_DISABLE_ERRORS: true 28 | REPOSITORY_DEVSKIM_ARGUMENTS: ["-g", ".mypy_cache/*"] 29 | REPOSITORY_TRIVY_DISABLE_ERRORS: true 30 | PRINT_ALL_FILES: false 31 | PYTHON_ISORT_CONFIG_FILE: "pyproject.toml" 32 | PYTHON_MYPY_PRE_COMMANDS: 33 | - command: "yes | pip install types-redis types-urllib3 types-requests && mkdir .mypy_cache" 34 | continue_on_failure: true 35 | cwd: "workspace" 36 | PYTHON_MYPY_ARGUMENTS: 37 | [ 38 | "--ignore-missing-imports", 39 | "--follow-imports=skip", 40 | "--strict-optional", 41 | "--disallow-any-generics", 42 | ] 43 | PYTHON_MYPY_CONFIG_FILE: "pyproject.toml" 44 | PYTHON_MYPY_DISABLE_ERRORS_IF_LESS_THAN: 28 45 | PYTHON_RUFF_CONFIG_FILE: "pyproject.toml" 46 | SHOW_ELAPSED_TIME: true 47 | SPELL_MISSPELL_FILTER_REGEX_EXCLUDE: '(\.automation/generated|docs/descriptors)' 48 | YAML_YAMLLINT_FILTER_REGEX_EXCLUDE: '(templates/|\.mega-linter\.yml|/tests)' 49 | YAML_PRETTIER_FILTER_REGEX_EXCLUDE: '(templates/|\.mega-linter\.yml|mkdocs\.yml)' 50 | YAML_V8R_FILTER_REGEX_EXCLUDE: '(descriptors|templates/\.mega-linter\.yml|\.codecov\.yml)' 51 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/ambv/black 4 | rev: 23.3.0 5 | hooks: 6 | - id: black 7 | language_version: python3.10 8 | args: [--line-length=119] 9 | 10 | - repo: https://github.com/charliermarsh/ruff-pre-commit 11 | rev: "v0.0.277" 12 | hooks: 13 | - id: ruff-autofix 14 | args: [--fix] 15 | 16 | - repo: local 17 | # We do not use pre-commit/mirrors-mypy, 18 | # as it comes with opinionated defaults 19 | # (like --ignore-missing-imports) 20 | # and is difficult to configure to run 21 | # with the dependencies correctly installed. 22 | hooks: 23 | - id: mypy 24 | name: mypy 25 | entry: mypy 26 | language: python 27 | language_version: python3.10 28 | additional_dependencies: ["mypy==1.4.1"] 29 | types: [python] 30 | # use require_serial so that script 31 | # is only called once per commit 32 | require_serial: true 33 | # Print the number of files as a sanity-check 34 | verbose: true 35 | args: 36 | - --ignore-missing-imports 37 | - --follow-imports=skip 38 | - --install-types 39 | - --non-interactive 40 | - --strict-optional 41 | - --disallow-any-generics 42 | -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | ignore: | 5 | tests/* 6 | 7 | rules: 8 | line-length: disable 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Operation Code: Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /LICENSE.MD: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OperationCode/operationcode-pybot/01e8d414f4f3f02aa2af967a07f0d915ec329c0d/LICENSE.MD -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Maintainers 2 | 3 | This file lists how the Operation Code PyBot project is maintained. When making changes to the system, this file tells you who needs to review your contribution - you need a simple majority of maintainers for the relevant subsystems to provide a 👍 on your pull request. Additionally, you need to not receive a veto from a Lieutenant or the Project Lead. 4 | 5 | Check out [how Operation Code Open Source projects are maintained](https://github.com/OperationCode/START_HERE/blob/61cebc02875ef448679e1130d3a68ef2f855d6c4/open_source_maintenance_policy.md) for details on the process, how to become a maintainer, lieutenant, or the project lead. 6 | 7 | # Project Lead 8 | 9 | * [Allen Anthes](http://www.github.com/allenanthes) 10 | 11 | # Lieutenant 12 | 13 | * [William Montgomery](http://www.github.com/wimo7083) 14 | 15 | # Maintainers 16 | 17 | * [YOU](http://www.github.com/YOU) -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | # Modify this Procfile to fit your needs 2 | web: gunicorn server:app 3 | -------------------------------------------------------------------------------- /base_bot_manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _metadata: 3 | major_version: 1 4 | minor_version: 1 5 | display_information: 6 | name: OC-Community-Pybot 7 | features: 8 | bot_user: 9 | display_name: OC-Community-Pybot 10 | always_online: true 11 | slash_commands: 12 | - command: /mentor_request 13 | url: https://oc-pybot-production.herokuapp.com/slack/events 14 | description: Request a Mentor 15 | should_escape: false 16 | - command: /new_join 17 | url: https://oc-pybot-production.herokuapp.com/slack/events 18 | description: New join testing 19 | should_escape: false 20 | - command: /report 21 | url: https://oc-pybot-production.herokuapp.com/slack/events 22 | description: Sends a report to the moderation team 23 | should_escape: false 24 | - command: /join-blacks-in-tech 25 | url: https://oc-pybot-production.herokuapp.com/slack/events 26 | description: Sends a request to join the blacks-in-tech channel 27 | should_escape: false 28 | - command: /join-pride 29 | url: https://oc-pybot-production.herokuapp.com/slack/events 30 | description: Sends a request to join the operation-pride channel. 31 | should_escape: false 32 | oauth_config: 33 | scopes: 34 | bot: 35 | - app_mentions:read 36 | - channels:history 37 | - channels:join 38 | - channels:read 39 | - chat:write 40 | - chat:write.public 41 | - commands 42 | - emoji:read 43 | - files:read 44 | - groups:read 45 | - groups:write 46 | - im:write 47 | - im:history 48 | - links:read 49 | - mpim:write 50 | - mpim:history 51 | - pins:read 52 | - remote_files:read 53 | - team.preferences:read 54 | - team:read 55 | - usergroups:read 56 | - users.profile:read 57 | - users:read 58 | - users:read.email 59 | settings: 60 | event_subscriptions: 61 | request_url: https://oc-pybot-production.herokuapp.com/slack/events 62 | bot_events: 63 | - app_mention 64 | - member_joined_channel 65 | - message.channels 66 | - message.im 67 | - message.mpim 68 | - team_join 69 | interactivity: 70 | is_enabled: true 71 | request_url: https://oc-pybot-production.herokuapp.com/slack/events 72 | org_deploy_enabled: false 73 | socket_mode_enabled: false 74 | token_rotation_enabled: false 75 | -------------------------------------------------------------------------------- /bot_manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _metadata: 3 | major_version: 1 4 | minor_version: 1 5 | display_information: 6 | name: ${BOT_NAME} 7 | features: 8 | bot_user: 9 | display_name: ${BOT_NAME} 10 | always_online: false 11 | slash_commands: 12 | - command: /mentor_request-${BOT_USERNAME} 13 | url: ${NGROK_URL}/slack/events 14 | description: Request a Mentor 15 | should_escape: false 16 | - command: /new_join-${BOT_USERNAME} 17 | url: ${NGROK_URL}/slack/events 18 | description: New join testing 19 | should_escape: false 20 | - command: /report-${BOT_USERNAME} 21 | url: ${NGROK_URL}/slack/events 22 | description: Sends a report to the moderation team 23 | should_escape: false 24 | - command: /join-blacks-in-tech-${BOT_USERNAME} 25 | url: ${NGROK_URL}/slack/events 26 | description: Sends a request to join the blacks-in-tech channel 27 | should_escape: false 28 | - command: /join-pride-${BOT_USERNAME} 29 | url: ${NGROK_URL}/slack/events 30 | description: Sends a request to join the operation-pride channel. 31 | should_escape: false 32 | oauth_config: 33 | scopes: 34 | bot: 35 | - app_mentions:read 36 | - channels:history 37 | - channels:join 38 | - channels:read 39 | - chat:write 40 | - chat:write.public 41 | - commands 42 | - emoji:read 43 | - files:read 44 | - groups:read 45 | - groups:write 46 | - im:write 47 | - im:history 48 | - links:read 49 | - mpim:write 50 | - mpim:history 51 | - pins:read 52 | - remote_files:read 53 | - team.preferences:read 54 | - team:read 55 | - usergroups:read 56 | - users.profile:read 57 | - users:read 58 | - users:read.email 59 | settings: 60 | event_subscriptions: 61 | request_url: ${NGROK_URL}/slack/events 62 | bot_events: 63 | - app_mention 64 | - member_joined_channel 65 | - message.channels 66 | - message.im 67 | - message.mpim 68 | - team_join 69 | interactivity: 70 | is_enabled: true 71 | request_url: ${NGROK_URL}/slack/events 72 | org_deploy_enabled: false 73 | socket_mode_enabled: false 74 | token_rotation_enabled: false 75 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o errexit 3 | 4 | pip install --upgrade pip 5 | 6 | pip install 'poetry==1.3.1' 7 | 8 | poetry config virtualenvs.create false 9 | 10 | poetry install -------------------------------------------------------------------------------- /data/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OperationCode/operationcode-pybot/01e8d414f4f3f02aa2af967a07f0d915ec329c0d/data/.DS_Store -------------------------------------------------------------------------------- /data/VA-Documents/2020-CFR-Title38-Vol-1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OperationCode/operationcode-pybot/01e8d414f4f3f02aa2af967a07f0d915ec329c0d/data/VA-Documents/2020-CFR-Title38-Vol-1.pdf -------------------------------------------------------------------------------- /data/VA-Documents/2020-CFR-Title38-Vol-2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OperationCode/operationcode-pybot/01e8d414f4f3f02aa2af967a07f0d915ec329c0d/data/VA-Documents/2020-CFR-Title38-Vol-2.pdf -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine AS base 2 | 3 | FROM base as builder 4 | 5 | ENV PIP_DISABLE_PIP_VERSION_CHECK on 6 | ENV PYTHONDONTWRITEBYTECODE 1 7 | ENV PYTHONUNBUFFERED 1 8 | 9 | RUN apk update && \ 10 | apk add --no-cache build-base musl-dev python3-dev libffi-dev openssl-dev 11 | 12 | WORKDIR /app 13 | 14 | COPY pyproject.toml poetry.lock ./ 15 | 16 | RUN pip install --no-cache-dir --upgrade pip 17 | 18 | RUN pip install --no-cache-dir 'poetry==1.5.1' 19 | 20 | # The `dev` stage creates an image and runs the application with development settings 21 | FROM builder as dev 22 | 23 | ENV PIP_DISABLE_PIP_VERSION_CHECK on 24 | ENV PYTHONDONTWRITEBYTECODE 1 25 | ENV PYTHONUNBUFFERED 1 26 | 27 | WORKDIR /app 28 | 29 | COPY .. ./ 30 | 31 | RUN poetry install 32 | 33 | ENTRYPOINT ["poetry", "run", "python3", "main.py"] 34 | 35 | # The `prod` stage creates an image that will run the application with production 36 | # settings 37 | FROM builder As prod 38 | 39 | WORKDIR /app 40 | 41 | COPY .. ./ 42 | 43 | ENV PYTHONDONTWRITEBYTECODE 1 44 | 45 | RUN poetry config virtualenvs.create false 46 | 47 | RUN poetry install 48 | 49 | ENTRYPOINT ["python3", "main.py"] 50 | -------------------------------------------------------------------------------- /docker/docker-compose.local.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3.9' 3 | services: 4 | pybot: 5 | image: pybot:latest 6 | container_name: pybot01 7 | ports: 8 | - "8010:8010" 9 | 10 | ngrok: 11 | image: wernight/ngrok:latest 12 | environment: 13 | - NGROK_PORT=pybot:8010 14 | ports: 15 | - "4040:4040" 16 | -------------------------------------------------------------------------------- /docker/docker-compose.weaviate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3.4' 3 | services: 4 | weaviate: 5 | image: semitechnologies/weaviate:1.19.6 6 | ports: 7 | - "8055:8080" 8 | environment: 9 | QUERY_DEFAULTS_LIMIT: 20 10 | AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true' 11 | PERSISTENCE_DATA_PATH: "./data" 12 | DEFAULT_VECTORIZER_MODULE: text2vec-transformers 13 | ENABLE_MODULES: text2vec-transformers 14 | TRANSFORMERS_INFERENCE_API: http://t2v-transformers:8055 15 | CLUSTER_HOSTNAME: 'node1' 16 | t2v-transformers: 17 | image: semitechnologies/transformers-inference:sentence-transformers-all-mpnet-base-v2 18 | environment: 19 | ENABLE_CUDA: 1 20 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3.9' 3 | 4 | services: 5 | pybot: 6 | container_name: oc-pybot 7 | restart: on-failure 8 | build: 9 | context: .. 10 | dockerfile: docker/Dockerfile 11 | command: uvicorn main:api -host 0.0.0.0 --port 5001 --reload --log-level 'debug' 12 | ports: 13 | - "5001:5001" 14 | -------------------------------------------------------------------------------- /modules/__init__.py: -------------------------------------------------------------------------------- 1 | # noqa: D104 2 | -------------------------------------------------------------------------------- /modules/airtable/__init__.py: -------------------------------------------------------------------------------- 1 | from modules.airtable.daily_programmer_table import DailyProgrammerTable # noqa: D104 2 | from modules.airtable.mentorship_tables import ( 3 | MentorshipAffiliationsTable, 4 | MentorshipMentorsTable, 5 | MentorshipRequestsTable, 6 | MentorshipServicesTable, 7 | MentorshipSkillsetsTable, 8 | ) 9 | from modules.airtable.message_text_table import MessageTextTable 10 | from modules.airtable.scheduled_message_table import ScheduledMessagesTable 11 | 12 | # General message related tables 13 | message_text_table = MessageTextTable() 14 | 15 | # Scheduled message related tables 16 | scheduled_message_table = ScheduledMessagesTable() 17 | 18 | # Daily Programmer related table 19 | daily_programmer_table = DailyProgrammerTable() 20 | 21 | # Mentorship related tables 22 | mentor_table = MentorshipMentorsTable() 23 | mentorship_services_table = MentorshipServicesTable() 24 | mentorship_skillsets_table = MentorshipSkillsetsTable() 25 | mentorship_requests_table = MentorshipRequestsTable() 26 | mentorship_affiliations_table = MentorshipAffiliationsTable() 27 | -------------------------------------------------------------------------------- /modules/airtable/daily_programmer_table.py: -------------------------------------------------------------------------------- 1 | import logging # noqa: D100 2 | from typing import Any 3 | 4 | from pydantic import ValidationError 5 | 6 | from modules.airtable.shared_table import BaseAirtableTable 7 | from modules.models.daily_programmer_models import DailyProgrammerInfo 8 | from modules.utils import snake_case 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class DailyProgrammerTable(BaseAirtableTable): 14 | """Airtable table for the daily programmer channel.""" 15 | 16 | def __init__(self: "DailyProgrammerTable") -> None: 17 | """Initialize the daily programmer table.""" 18 | super().__init__("Daily Programmer") 19 | 20 | @staticmethod 21 | def parse_daily_programmer_row(row: dict[str, Any]) -> DailyProgrammerInfo: 22 | """Parse a daily programmer row. 23 | 24 | :param row: The row to parse. 25 | :return: The parsed row. 26 | """ 27 | fields = {snake_case(k): v for k, v in row["fields"].items()} 28 | try: 29 | return DailyProgrammerInfo( 30 | **fields, 31 | airtable_id=row["id"], 32 | created_at=row["createdTime"], 33 | ) 34 | except ValidationError: 35 | logger.exception("Unable to parse daily programmer row.", extra={"row": row}) 36 | raise 37 | 38 | def retrieve_valid_daily_programmer_row_by_slug( 39 | self: "DailyProgrammerTable", 40 | slug: str, 41 | ) -> DailyProgrammerInfo: 42 | """Retrieve a valid daily programmer row by slug. 43 | 44 | :param slug: The slug to match. 45 | :return: The parsed row. 46 | """ 47 | return self.parse_daily_programmer_row( 48 | self.first( 49 | formula=f"{{Slug}} = '{slug}'", 50 | view="Valid", 51 | ), 52 | ) 53 | 54 | def retrieve_valid_daily_programmer_by_view( 55 | self: "DailyProgrammerTable", 56 | view_name: str, 57 | ) -> dict[str, DailyProgrammerInfo]: 58 | """Retrieve all valid daily programmer rows by view. 59 | 60 | :param view_name: The view name to retrieve messages from. 61 | :return: The dictionary of messages. 62 | """ 63 | logger.info("STAGE: Retrieving daily programmer rows by view") 64 | logger.info("With view_name", extra={"view_name": view_name}) 65 | messages = {} 66 | for row in self.all(view=view_name): 67 | parsed_row = self.parse_daily_programmer_row(row) 68 | messages[parsed_row.slug] = parsed_row 69 | return messages 70 | -------------------------------------------------------------------------------- /modules/airtable/mentorship_tables.py: -------------------------------------------------------------------------------- 1 | """Airtable tables for the mentorship program.""" 2 | import logging 3 | from functools import cached_property 4 | from itertools import chain 5 | from typing import Any 6 | 7 | from pydantic import ValidationError 8 | 9 | from modules.airtable.shared_table import BaseAirtableTable 10 | from modules.models.mentorship_models import ( 11 | Mentor, 12 | MentorshipAffiliation, 13 | MentorshipRequest, 14 | MentorshipService, 15 | MentorshipSkillset, 16 | ) 17 | from modules.utils import snake_case 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class MentorshipAffiliationsTable(BaseAirtableTable): 23 | """Airtable table for the mentorship affiliations table.""" 24 | 25 | def __init__(self: "MentorshipAffiliationsTable") -> None: 26 | """Initialize the mentorship affiliations table.""" 27 | super().__init__("Affiliations") 28 | 29 | @cached_property 30 | def valid_affiliations(self: "MentorshipAffiliationsTable") -> list[MentorshipAffiliation]: 31 | """Return the valid affiliations from the table. 32 | 33 | :return: A list of valid affiliations. 34 | """ 35 | return [self.parse_affiliation_row(row) for row in self.all(view="Valid")] 36 | 37 | @staticmethod 38 | def parse_affiliation_row(row: dict[str, Any]) -> MentorshipAffiliation: 39 | """Parse an affiliation row. 40 | 41 | :param row: The row to parse. 42 | :return: The parsed affiliation row. 43 | """ 44 | fields = {snake_case(k): v for k, v in row["fields"].items()} 45 | try: 46 | return MentorshipAffiliation( 47 | **fields, 48 | airtable_id=row["id"], 49 | created_at=row["createdTime"], 50 | ) 51 | except ValidationError: 52 | logger.exception("Error parsing affiliation row", extra={"row": row}) 53 | raise 54 | 55 | 56 | class MentorshipMentorsTable(BaseAirtableTable): 57 | """Table containing the mentors who have signed up to mentor others.""" 58 | 59 | def __init__(self: "MentorshipMentorsTable") -> None: 60 | """Initialize the mentorship mentors table.""" 61 | super().__init__("Mentors") 62 | 63 | @cached_property 64 | def valid_mentors(self: "MentorshipMentorsTable") -> list[Mentor]: 65 | """Returns the mentors from the table sorted by row ID. 66 | 67 | :return: list of mentors 68 | """ 69 | try: 70 | return [self.parse_mentor_row(row) for row in self.all(view="Valid")] 71 | except ValidationError: 72 | logger.exception("Unable to retrieve the list of mentors") 73 | raise 74 | 75 | @staticmethod 76 | def parse_mentor_row(row: dict[str, Any]) -> Mentor: 77 | """Parse a mentor row. 78 | 79 | :param row: The row to parse. 80 | :return: The parsed row. 81 | """ 82 | fields = {snake_case(k): v for k, v in row["fields"].items()} 83 | try: 84 | return Mentor( 85 | **fields, 86 | airtable_id=row["id"], 87 | created_at=row["createdTime"], 88 | ) 89 | except ValidationError: 90 | logger.exception("Unable to parse mentor row.", extra={"row": row}) 91 | raise 92 | 93 | 94 | class MentorshipSkillsetsTable(BaseAirtableTable): 95 | """Airtable table for the mentorship skillsets table.""" 96 | 97 | def __init__(self: "MentorshipSkillsetsTable") -> None: 98 | """Initialize the mentorship skillsets table.""" 99 | super().__init__("Skillsets") 100 | 101 | @cached_property 102 | def valid_skillsets(self: "MentorshipSkillsetsTable") -> list[MentorshipSkillset]: 103 | """Returns the skillsets from the table. 104 | 105 | :return: The list of skillsets. 106 | """ 107 | try: 108 | return [self.parse_skillset_row(row) for row in self.all(view="Valid")] 109 | except ValidationError: 110 | logger.exception("Unable to retrieve the list of skillsets") 111 | raise 112 | 113 | @cached_property 114 | def mentors_by_skillsets(self: "MentorshipSkillsetsTable") -> dict[str, str]: 115 | """Returns the mentors by skillset. 116 | 117 | :return: The mentors by skillset. 118 | """ 119 | try: 120 | mentors_by_skillset = {} 121 | for row in self.all(fields=["Name", "Mentors"], view="Valid"): 122 | mentors_by_skillset[row["Name"]] = row["Mentors"] 123 | return mentors_by_skillset # noqa: TRY300 124 | except Exception: 125 | logger.exception("Issue retrieving mentor IDs by skillset") 126 | raise 127 | 128 | @staticmethod 129 | def parse_skillset_row(row: dict[str, Any]) -> MentorshipSkillset: 130 | """Parse a skillset row. 131 | 132 | :param row: The row to parse. 133 | :return: The parsed row. 134 | """ 135 | fields = {snake_case(k): v for k, v in row["fields"].items()} 136 | try: 137 | return MentorshipSkillset( 138 | **fields, 139 | airtable_id=row["id"], 140 | created_at=row["createdTime"], 141 | ) 142 | except ValidationError: 143 | logger.exception("Unable to parse skillset row.", extra={"row": row}) 144 | raise 145 | 146 | def mentors_by_skillset(self: "MentorshipSkillsetsTable", skillsets_to_search: list[str]) -> set[str]: 147 | """Retrieve mentor IDs by skillset. 148 | 149 | :param skillsets_to_search: The skillsets to search for. 150 | :return: The mentor IDs that have the skillsets. 151 | """ 152 | logger.info("STAGE: Returning mentors by skillset...") 153 | try: 154 | mentors = [] 155 | formula = [f"{{Name}} = '{skillset}'," for skillset in skillsets_to_search] 156 | for row in self.all( 157 | fields=["Name", "Mentors"], 158 | view="Valid", 159 | formula=("OR(" + "".join(formula)[:-1] + ")"), 160 | ): 161 | try: 162 | mentors.append( 163 | row["fields"]["Mentors"] if row["fields"]["Mentors"] else [], 164 | ) 165 | except KeyError: 166 | logger.exception("Key error intercepted retrieving mentors by skillset", extra={"row": row}) 167 | 168 | # Flatten the array and get unique values 169 | return set(chain(*mentors)) 170 | except Exception: 171 | logger.exception( 172 | "Issue retrieving mentor IDs with particular skillsets", 173 | extra={"skillsets": skillsets_to_search}, 174 | ) 175 | raise 176 | 177 | 178 | class MentorshipServicesTable(BaseAirtableTable): 179 | """Airtable table for the mentorship services table.""" 180 | 181 | def __init__(self: "MentorshipServicesTable") -> None: 182 | """Initialize the mentorship services table.""" 183 | super().__init__("Services") 184 | 185 | @cached_property 186 | def valid_services(self: "MentorshipServicesTable") -> list[MentorshipService]: 187 | """Returns the services from the table. 188 | 189 | :return: The list of services from the table. 190 | """ 191 | try: 192 | return [self.parse_service_row(row) for row in self.all(view="Valid")] 193 | except ValidationError: 194 | logger.exception("Unable to retrieve the list of services") 195 | raise 196 | 197 | @staticmethod 198 | def parse_service_row(row: dict[str, Any]) -> MentorshipService: 199 | """Parse a service row. 200 | 201 | :param row: The row to parse. 202 | :return: The parsed row. 203 | """ 204 | fields = {snake_case(k): v for k, v in row["fields"].items()} 205 | try: 206 | return MentorshipService( 207 | **fields, 208 | airtable_id=row["id"], 209 | created_at=row["createdTime"], 210 | ) 211 | except ValidationError: 212 | logger.exception("Unable to parse service row.", extra={"row": row}) 213 | raise 214 | 215 | 216 | class MentorshipRequestsTable(BaseAirtableTable): 217 | """Airtable table for the mentorship requests table.""" 218 | 219 | def __init__(self: "MentorshipRequestsTable") -> None: 220 | """Initialize the mentorship requests table.""" 221 | super().__init__("Mentor Requests") 222 | 223 | @cached_property 224 | def valid_services(self: "MentorshipRequestsTable") -> list[MentorshipRequest]: 225 | """Returns the services from the table. 226 | 227 | :return: list of services from the table 228 | """ 229 | try: 230 | return [self.parse_request_row(row) for row in self.all(view="Valid")] 231 | except ValidationError: 232 | logger.exception("Unable to retrieve the list of requests") 233 | raise 234 | 235 | @staticmethod 236 | def parse_request_row(row: dict[str, Any]) -> MentorshipRequest: 237 | """Parse a request row. 238 | 239 | :param row: The row to parse. 240 | :return: The parsed row. 241 | """ 242 | fields = {snake_case(k): v for k, v in row["fields"].items()} 243 | try: 244 | return MentorshipRequest( 245 | **fields, 246 | airtable_id=row["id"], 247 | created_at=row["createdTime"], 248 | ) 249 | except ValidationError: 250 | logger.exception("Unable to parse request row.", extra={"row": row}) 251 | raise 252 | 253 | def return_record_by_slack_message_ts(self: "MentorshipRequestsTable", timestamp: str) -> MentorshipRequest: 254 | """Return a specific record by the recorded timestamp. 255 | 256 | :param timestamp: The timestamp to use to find the record. 257 | :return: The mentorship request found with the timestamp. 258 | """ 259 | logger.info("Returning record using timestamp", extra={"timestamp": timestamp}) 260 | row = self.first(formula=f"{{Slack Message TS}} = '{timestamp}'") 261 | if not row: 262 | logger.error("Unable to find record", extra={"timestamp": timestamp}) 263 | error_message = f"Unable to find record with timestamp {timestamp}" 264 | raise ValueError(error_message) 265 | logger.info("Found record", extra={"row": row}) 266 | return self.parse_request_row(row) 267 | -------------------------------------------------------------------------------- /modules/airtable/message_text_table.py: -------------------------------------------------------------------------------- 1 | """Defines the message text table in Airtable.""" 2 | import logging 3 | from typing import Any 4 | 5 | from pydantic import ValidationError 6 | 7 | from modules.airtable.shared_table import BaseAirtableTable 8 | from modules.models.message_text_models import MessageTextInfo 9 | from modules.utils import snake_case 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class MessageTextTable(BaseAirtableTable): 15 | """The message text table contains the various messages we send out periodically in the OC Slack workspace.""" 16 | 17 | def __init__(self: "MessageTextTable") -> None: 18 | """Initialize the message text table.""" 19 | super().__init__("Message Text") 20 | 21 | @staticmethod 22 | def parse_message_text_row(row: dict[str, Any]) -> MessageTextInfo: 23 | """Parse a message text row. 24 | 25 | :param row: The row to parse. 26 | :return: The parsed message text row. 27 | """ 28 | fields = {snake_case(k): v for k, v in row["fields"].items()} 29 | try: 30 | return MessageTextInfo( 31 | **fields, 32 | airtable_id=row["id"], 33 | created_at=row["createdTime"], 34 | ) 35 | except ValidationError: 36 | logger.exception("Unable to parse message text row.", extra={"row": row}) 37 | raise 38 | 39 | def retrieve_valid_message_row(self: "MessageTextTable", message_slug: str) -> MessageTextInfo: 40 | """Retrieve all valid messages that match the given slug. 41 | 42 | :param message_slug: The message slug to match. 43 | :return: The parsed message text row. 44 | """ 45 | return self.parse_message_text_row( 46 | self.first( 47 | formula=f"{{Slug}} = '{message_slug}'", 48 | view="Valid", 49 | ), 50 | ) 51 | 52 | def retrieve_valid_messages_by_view( 53 | self: "MessageTextTable", 54 | view_name: str, 55 | ) -> dict[str, MessageTextInfo]: 56 | """Retrieve a dictionary of all valid messages by view. 57 | 58 | :param view_name: The view name to retrieve messages from. 59 | :return: The dictionary of messages. 60 | """ 61 | logger.info("STAGE: Retrieving valid messages by view") 62 | logger.info("With view_name", extra={"view_name": view_name}) 63 | messages = {} 64 | for row in self.all(view=view_name): 65 | parsed_row = self.parse_message_text_row(row) 66 | messages[parsed_row.slug] = parsed_row 67 | return messages 68 | -------------------------------------------------------------------------------- /modules/airtable/scheduled_message_table.py: -------------------------------------------------------------------------------- 1 | """Airtable table for scheduled messages.""" 2 | import logging 3 | from typing import Any 4 | 5 | from pydantic import ValidationError 6 | 7 | from modules.airtable.shared_table import BaseAirtableTable 8 | from modules.models.scheduled_message_models import ScheduledMessageInfo 9 | from modules.utils import snake_case 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class ScheduledMessagesTable(BaseAirtableTable): 15 | """Airtable table for scheduled messages.""" 16 | 17 | def __init__(self: "ScheduledMessagesTable") -> None: 18 | """Initialize the scheduled messages table.""" 19 | super().__init__("Scheduled Messages") 20 | 21 | @property 22 | def all_valid_scheduled_messages(self: "ScheduledMessagesTable") -> list[ScheduledMessageInfo]: 23 | """Return all valid scheduled messages.""" 24 | return [self.parse_scheduled_message_row(row) for row in self.all(view="Valid")] 25 | 26 | @staticmethod 27 | def parse_scheduled_message_row(row: dict[str, Any]) -> ScheduledMessageInfo: 28 | """Return a parsed scheduled message row. 29 | 30 | :param row: The row to parse. 31 | :return: A parsed scheduled message row. 32 | """ 33 | fields = {snake_case(k): v for k, v in row["fields"].items()} 34 | try: 35 | return ScheduledMessageInfo( 36 | **fields, 37 | airtable_id=row["id"], 38 | created_at=row["createdTime"], 39 | ) 40 | 41 | except ValidationError: 42 | logger.exception("Unable to parse scheduled message row.", extra={"row": row}) 43 | raise 44 | -------------------------------------------------------------------------------- /modules/airtable/shared_table.py: -------------------------------------------------------------------------------- 1 | import os # noqa: D100 2 | from typing import Any 3 | 4 | from pyairtable import Table 5 | 6 | from modules.utils import table_fields 7 | 8 | 9 | class BaseAirtableTable(Table): # noqa: D101 10 | def __init__(self, table_name: str): # noqa: ANN101, ANN204, D107 11 | super().__init__( 12 | api_key=os.getenv("AIRTABLE_API_KEY"), 13 | base_id=os.getenv("AIRTABLE_BASE_ID"), 14 | table_name=f"{table_name}", 15 | ) 16 | 17 | @property 18 | def table_fields(self) -> list[str]: # noqa: ANN101 19 | """Returns snake cased columns (fields in Airtable parlance) on the table. 20 | 21 | :return: list of fields 22 | :rtype: list[str] 23 | """ 24 | return table_fields(self) 25 | 26 | def update_record( # noqa: D102 27 | self, # noqa: ANN101 28 | airtable_id: str, 29 | fields_to_update: dict[str, Any], 30 | ) -> dict[str, Any]: 31 | return self.update(airtable_id, fields=fields_to_update, typecast=True) 32 | 33 | def create_record(self, record_to_create: dict[str, Any]) -> dict[str, Any]: # noqa: ANN101, D102 34 | return self.create(fields=record_to_create, typecast=True) 35 | -------------------------------------------------------------------------------- /modules/databases/__init__.py: -------------------------------------------------------------------------------- 1 | """Configuration module for databases.""" 2 | -------------------------------------------------------------------------------- /modules/databases/vector_database_config.py: -------------------------------------------------------------------------------- 1 | """Configuration for a vector database connection.""" 2 | import os 3 | 4 | import weaviate 5 | from dotenv import load_dotenv 6 | 7 | load_dotenv() 8 | 9 | # Weaviate configuration 10 | auth_config = weaviate.AuthApiKey(api_key=os.getenv("WEAVIATE_API_KEY", "")) 11 | 12 | # Create a weaviate client 13 | weaviate_client = weaviate.Client(os.getenv("WEAVIATE_URL", "http://localhost:8015"), auth_client_secret=auth_config) 14 | 15 | # Create the schema 16 | class_obj = { 17 | "class": "TextChunk", 18 | "description": "A chunk of text from the Federal Title Code.", 19 | "properties": [ 20 | {"name": "text", "dataType": ["text"]}, 21 | {"name": "cfr_title_number", "dataType": ["int"]}, 22 | {"name": "ingestion_date", "dataType": ["date"]}, 23 | {"name": "index_number", "dataType": ["int"]}, 24 | {"name": "unique_id", "dataType": ["uuid"]}, 25 | {"name": "surrounding_context", "dataType": ["text"]}, 26 | ], 27 | "vectorizer": "text2vec-transformers", 28 | "vectorIndexConfig": { 29 | "ef": 450, 30 | }, 31 | } 32 | 33 | try: 34 | weaviate_client.schema.create_class(class_obj) 35 | except weaviate.exceptions.UnexpectedStatusCodeException: 36 | print("Schema already exists.") 37 | 38 | try: 39 | weaviate_client.schema.update_config("TextChunk", class_obj) 40 | except weaviate.exceptions.UnexpectedStatusCodeException as e: 41 | print(e) 42 | print("Schema already updated.") 43 | -------------------------------------------------------------------------------- /modules/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | # noqa: D104 2 | -------------------------------------------------------------------------------- /modules/handlers/channel_join_handler.py: -------------------------------------------------------------------------------- 1 | """Channel join handler module.""" 2 | import logging 3 | import os 4 | 5 | from slack_bolt.context.async_context import AsyncBoltContext 6 | 7 | from modules.models.slack_models.action_models import SlackActionRequestBody 8 | from modules.models.slack_models.command_models import SlackCommandRequestBody 9 | from modules.slack.blocks.shared_blocks import ( 10 | channel_join_request_action, 11 | channel_join_request_blocks, 12 | channel_join_request_reset_action, 13 | channel_join_request_successful_block, 14 | ) 15 | from modules.utils import log_to_thread, slack_team 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | async def handle_channel_join_request( 21 | parsed_body: SlackCommandRequestBody, 22 | context: AsyncBoltContext, 23 | ) -> None: 24 | """Handle the channel join request. 25 | 26 | :param parsed_body: The parsed body of the request. 27 | :param context: The Slack Bolt context. 28 | """ 29 | logger.info("STAGE: Handling channel join command...") 30 | await context.ack() 31 | channel_id = "" 32 | channel_name = "" 33 | try: 34 | if parsed_body.command == "/join-pride": 35 | channel_id = slack_team.pride_channel.id 36 | channel_name = os.getenv("PRIDE_CHANNEL_NAME", "") 37 | if parsed_body.command == "/join-blacks-in-tech": 38 | channel_id = slack_team.blacks_in_tech.id 39 | channel_name = os.getenv("BLACKS_IN_TECH_CHANNEL_NAME", "") 40 | await context.client.chat_postMessage( 41 | channel=channel_id, 42 | blocks=channel_join_request_blocks(parsed_body.user_name), 43 | text="New channel join request...", 44 | ) 45 | await context.client.chat_postEphemeral( 46 | channel=parsed_body.user_id, 47 | user=parsed_body.user_id, 48 | blocks=[channel_join_request_successful_block(channel_name)], 49 | text=f"Your request to join {channel_name} was successful...", 50 | ) 51 | 52 | except Exception as general_exception: 53 | logger.exception("Unable to handle the channel join request") 54 | raise general_exception from general_exception 55 | 56 | 57 | async def handle_channel_join_request_claim( 58 | parsed_body: SlackActionRequestBody, 59 | context: AsyncBoltContext, 60 | ) -> None: 61 | """Handle the claim for a channel join request. 62 | 63 | :param parsed_body: The parsed body of the request. 64 | :param context: The Slack Bolt context. 65 | """ 66 | logger.info("STAGE: Handling channel join request claim...") 67 | await context.ack() 68 | try: 69 | blocks = parsed_body.message.blocks 70 | blocks[-1] = channel_join_request_reset_action(parsed_body.user.username) 71 | await log_to_thread( 72 | client=context.client, 73 | channel_id=parsed_body.channel.id, 74 | message_ts=parsed_body.message.ts, 75 | username=parsed_body.user.username, 76 | action_ts=parsed_body.actions[0].action_ts, 77 | claim=True, 78 | ) 79 | await context.respond( 80 | text="Someone has claimed the invite request...", 81 | blocks=blocks, 82 | replace_original=True, 83 | ) 84 | 85 | except Exception as general_exception: 86 | logger.exception("Unable to handle the channel join request claim") 87 | raise general_exception from general_exception 88 | 89 | 90 | async def handle_channel_join_request_claim_reset( 91 | parsed_body: SlackActionRequestBody, 92 | context: AsyncBoltContext, 93 | ) -> None: 94 | """Handle the reset of claim for a channel join request. 95 | 96 | :param parsed_body: The parsed body of the request. 97 | :param context: The Slack Bolt context. 98 | """ 99 | logger.info("STAGE: Handling channel join request claim reset...") 100 | await context.ack() 101 | try: 102 | blocks = parsed_body.message.blocks 103 | blocks[-1] = channel_join_request_action() 104 | await log_to_thread( 105 | client=context.client, 106 | channel_id=parsed_body.channel.id, 107 | message_ts=parsed_body.message.ts, 108 | username=parsed_body.user.username, 109 | action_ts=parsed_body.actions[0].action_ts, 110 | claim=False, 111 | ) 112 | await context.respond( 113 | text="Someone has reset the invite request...", 114 | blocks=blocks, 115 | replace_original=True, 116 | ) 117 | 118 | except Exception as general_exception: 119 | logger.exception("Unable to handle the channel join request claim reset") 120 | raise general_exception from general_exception 121 | -------------------------------------------------------------------------------- /modules/handlers/daily_programmer.py: -------------------------------------------------------------------------------- 1 | """Handles the daily programmer channel.""" 2 | import logging 3 | import re 4 | from datetime import datetime, timezone 5 | from difflib import SequenceMatcher 6 | 7 | from slack_bolt.context.async_context import AsyncBoltContext 8 | 9 | from modules.airtable import daily_programmer_table 10 | from modules.models.slack_models.event_models import MessageReceivedChannelEvent 11 | from modules.models.slack_models.shared_models import SlackMessageInfo 12 | 13 | LOGGER = logging.getLogger(__name__) 14 | MATCHING_TEXT_RATIO = 0.85 15 | 16 | 17 | async def handle_daily_programmer_post( 18 | parsed_body: MessageReceivedChannelEvent, 19 | context: AsyncBoltContext, 20 | ) -> None: 21 | """Process a message that was posted to the daily programmer channel. 22 | 23 | :param parsed_body: The parsed body of the Slack message event. 24 | :param context: The Slack Bolt context. 25 | """ 26 | await context.ack() 27 | LOGGER.info("STAGE: Handling a daily programmer post...") 28 | post_id, post_count = check_for_existing_post(parsed_body.event.text) 29 | if post_id and post_count: 30 | daily_programmer_table.update( 31 | post_id, 32 | {"Post Count": post_count, "Last Posted On": datetime.now(timezone.utc)}, 33 | ) 34 | return None # noqa: RET501 35 | process_daily_programmer_post_text(parsed_body.event) 36 | 37 | 38 | def check_for_existing_post(text: str) -> tuple[str, int] | tuple[None, None]: 39 | """Check for an existing daily programmer post. 40 | 41 | :param text: The text of the post. 42 | :return: The existing post ID and the number of times it has been posted, if it exists. 43 | """ 44 | existing_posts = daily_programmer_table.all( 45 | view="Valid", 46 | fields=["Text", "Posted Count"], 47 | ) 48 | for post in existing_posts: 49 | if SequenceMatcher(None, post["fields"]["Text"], text).ratio() > MATCHING_TEXT_RATIO: 50 | LOGGER.info("Found matching post", extra={"post": post}) 51 | return post["id"], int(post["fields"]["Posted Count"]) 52 | return None, None 53 | 54 | 55 | def process_daily_programmer_post_text(body: SlackMessageInfo) -> None: 56 | """Process a post to the daily programming channel. 57 | 58 | :param body: The body of the Slack message. 59 | """ 60 | LOGGER.info("STAGE: Processing a daily programmer post text...") 61 | # Posts to the daily programmer channel should be in the format: 62 | # ==[Name]== 63 | title = re.search(r"(={2,3}.*={2,3})", body.text) 64 | if title: 65 | LOGGER.info("Found a daily programmer post title...") 66 | name = re.search(r"(\[.*?])", body.text) 67 | if name: 68 | try: 69 | daily_programmer_table.create_record( 70 | { 71 | "Name": name[0].replace("[", "").replace("]", "").replace("*", ""), 72 | "Text": body.text[name.span()[1] + 1 :], 73 | "Initially Posted On": str( 74 | datetime.fromtimestamp(float(body.ts), timezone.utc), 75 | ), 76 | "Last Posted On": str( 77 | datetime.fromtimestamp(float(body.ts), timezone.utc), 78 | ), 79 | "Posted Count": 1, 80 | "Initial Slack TS": body.ts, 81 | "Blocks": body.blocks, 82 | }, 83 | ) 84 | except Exception as general_error: 85 | LOGGER.exception( 86 | "Unable to create new daily programmer entry", 87 | ) 88 | raise general_error from general_error 89 | return 90 | LOGGER.warning( 91 | "Unable to create new daily programmer entry due to not finding the name...", 92 | ) 93 | return 94 | LOGGER.warning( 95 | "Unable to create new daily programmer entry due to not finding the title...", 96 | ) 97 | -------------------------------------------------------------------------------- /modules/handlers/greeting_handler.py: -------------------------------------------------------------------------------- 1 | import re # noqa: D100 2 | from datetime import datetime, timedelta, timezone 3 | from typing import Union 4 | 5 | from slack_bolt.context.async_context import AsyncBoltContext 6 | 7 | from modules.models.slack_models.action_models import SlackActionRequestBody 8 | from modules.models.slack_models.command_models import SlackCommandRequestBody 9 | from modules.models.slack_models.event_models import MemberJoinedChannelEvent 10 | from modules.slack.blocks.greeting_blocks import ( 11 | greeting_block_button, 12 | greeting_block_claimed_button, 13 | initial_greet_user_blocks, 14 | ) 15 | from modules.slack.blocks.new_join_blocks import ( 16 | new_join_delayed_welcome_blocks, 17 | new_join_immediate_welcome_blocks, 18 | ) 19 | from modules.utils import get_slack_user_by_id, log_to_thread, slack_team 20 | 21 | 22 | async def handle_new_member_join( # noqa: D103 23 | parsed_body: Union[MemberJoinedChannelEvent, SlackCommandRequestBody], # noqa: UP007 24 | context: AsyncBoltContext, 25 | ) -> None: 26 | await context.ack() 27 | user = None 28 | if isinstance(parsed_body, MemberJoinedChannelEvent): 29 | user = await get_slack_user_by_id(context.client, parsed_body.user) 30 | elif isinstance(parsed_body, SlackCommandRequestBody): 31 | user = await get_slack_user_by_id(context.client, parsed_body.user_id) 32 | await context.client.chat_postMessage( 33 | channel=slack_team.greetings_channel.id, 34 | blocks=initial_greet_user_blocks(user), 35 | text="A new member has joined!", 36 | ) 37 | # Add one minute to the current timestamp 38 | immediate_message_timestamp = datetime.now(timezone.utc).timestamp() + 60 39 | await context.client.chat_scheduleMessage( 40 | channel=user.id, 41 | user=user.id, 42 | post_at=int(immediate_message_timestamp), 43 | text="Welcome to Operation Code Slack!", 44 | blocks=new_join_immediate_welcome_blocks(user.name), 45 | unfurl_links=False, 46 | unfurl_media=False, 47 | ) 48 | # Schedule the delayed message for the next day at 1600 UTC (10 AM CST/CDT) 49 | # This could be in two days, by popular measure, if UTC has already rolled over midnight 50 | delayed_message_timestamp = ( 51 | (datetime.now(timezone.utc) + timedelta(days=1)).replace(hour=16, minute=00).timestamp() 52 | ) 53 | await context.client.chat_scheduleMessage( 54 | channel=user.id, 55 | user=user.id, 56 | post_at=int(delayed_message_timestamp), 57 | text="We're happy to have you at Operation Code!", 58 | blocks=new_join_delayed_welcome_blocks(), 59 | unfurl_media=False, 60 | unfurl_links=False, 61 | ) 62 | 63 | 64 | async def handle_greeting_new_user_claim( # noqa: D103 65 | parsed_body: SlackActionRequestBody, 66 | context: AsyncBoltContext, 67 | ) -> None: 68 | await context.ack() 69 | original_blocks = parsed_body.message.blocks 70 | original_blocks[-1] = greeting_block_claimed_button(parsed_body.user.username) 71 | modified_blocks = original_blocks 72 | await log_to_thread( 73 | client=context.client, 74 | channel_id=parsed_body.channel.id, 75 | message_ts=parsed_body.message.ts, 76 | username=parsed_body.user.username, 77 | action_ts=parsed_body.actions[0].action_ts, 78 | claim=True, 79 | ) 80 | await context.respond( 81 | text="Modified the claim to greet the new user...", 82 | blocks=modified_blocks, 83 | replace_original=True, 84 | ) 85 | 86 | 87 | async def handle_resetting_greeting_new_user_claim( # noqa: D103 88 | parsed_body: SlackActionRequestBody, 89 | context: AsyncBoltContext, 90 | ) -> None: 91 | await context.ack() 92 | original_blocks = parsed_body.message.blocks 93 | # Extract out the username of the new user (the user we are greeting) 94 | original_blocks[-1] = greeting_block_button( 95 | str(re.match(r"\((@.*)\)", parsed_body.message.blocks[0]["text"]["text"])), 96 | ) 97 | modified_blocks = original_blocks 98 | await log_to_thread( 99 | client=context.client, 100 | channel_id=parsed_body.channel.id, 101 | message_ts=parsed_body.message.ts, 102 | username=parsed_body.user.username, 103 | action_ts=parsed_body.actions[0].action_ts, 104 | claim=False, 105 | ) 106 | await context.respond( 107 | text="Modified the claim to greet the new user...", 108 | blocks=modified_blocks, 109 | replace_original=True, 110 | ) 111 | -------------------------------------------------------------------------------- /modules/handlers/mentorship_handler.py: -------------------------------------------------------------------------------- 1 | import logging # noqa: D100 2 | from datetime import datetime, timezone 3 | from typing import Any 4 | 5 | from slack_bolt.context.async_context import AsyncBoltContext 6 | 7 | from modules.airtable import ( 8 | mentor_table, 9 | mentorship_affiliations_table, 10 | mentorship_requests_table, 11 | mentorship_services_table, 12 | mentorship_skillsets_table, 13 | ) 14 | from modules.models.greeting_models import UserInfo 15 | from modules.models.mentorship_models import MentorshipRequestCreate 16 | from modules.models.slack_models.action_models import SlackActionRequestBody 17 | from modules.models.slack_models.command_models import SlackCommandRequestBody 18 | from modules.models.slack_models.view_models import SlackViewRequestBody 19 | from modules.slack.blocks.mentorship_blocks import ( 20 | mentorship_request_view, 21 | request_claim_blocks, 22 | request_claim_button, 23 | request_claim_details_block, 24 | request_claim_reset_button, 25 | request_claim_tagged_users_block, 26 | request_successful_block, 27 | request_unsuccessful_block, 28 | ) 29 | from modules.utils import get_slack_user_by_id, log_to_thread, slack_team 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | 34 | async def handle_mentor_request( # noqa: D103 35 | parsed_body: SlackCommandRequestBody, 36 | context: AsyncBoltContext, 37 | ) -> None: 38 | logging.info("STAGE: Handling the mentor request...") 39 | await context.ack() 40 | response = await context.client.views_open( 41 | trigger_id=parsed_body.trigger_id, 42 | view=mentorship_request_view( 43 | services=mentorship_services_table.valid_services, 44 | skillsets=mentorship_skillsets_table.valid_skillsets, 45 | affiliations=mentorship_affiliations_table.valid_affiliations, 46 | ), 47 | ) 48 | if response["ok"]: 49 | logger.debug("View opened successfully") 50 | 51 | else: 52 | logger.warning(f"Unable to open the view, given response: {response}") # noqa: G004 53 | 54 | 55 | async def handle_mentorship_request_form_submit( # noqa: D103 56 | parsed_body: SlackViewRequestBody, 57 | context: AsyncBoltContext, 58 | ) -> None: 59 | logger.info("STAGE: Handling the mentorship request form submission...") 60 | await context.ack() 61 | try: 62 | slack_user_info = await get_slack_user_by_id( 63 | context.client, 64 | parsed_body.user.id, 65 | ) 66 | mentorship_request, airtable_record = create_mentor_request_record( 67 | parsed_body, 68 | slack_user_info, 69 | ) 70 | mentors_channel_response = await context.client.chat_postMessage( 71 | channel=slack_team.mentors_internal_channel.id, 72 | blocks=request_claim_blocks( 73 | mentorship_request.service, 74 | mentorship_request.skillsets_requested, 75 | mentorship_request.affiliation 76 | if isinstance(mentorship_request.affiliation, str) 77 | else mentorship_request.affiliation[0], 78 | mentorship_request.slack_name, 79 | ), 80 | text="New mentorship request received...", 81 | ) 82 | mentorship_requests_table.update_record( 83 | airtable_id=airtable_record["id"], 84 | fields_to_update={"Slack Message TS": mentors_channel_response["ts"]}, 85 | ) 86 | await context.client.chat_postMessage( 87 | channel=slack_team.mentors_internal_channel.id, 88 | thread_ts=mentors_channel_response["ts"], 89 | text="Additional details added to mentorship request...", 90 | blocks=[request_claim_details_block(mentorship_request.additional_details)], 91 | ) 92 | matching_mentors = mentorship_skillsets_table.mentors_by_skillset( 93 | mentorship_request.skillsets_requested, 94 | ) 95 | retrieve_mentor_slack_names = [ 96 | mentor.slack_name for mentor in mentor_table.valid_mentors if mentor.airtable_id in matching_mentors 97 | ] 98 | await context.client.chat_postMessage( 99 | channel=slack_team.mentors_internal_channel.id, 100 | thread_ts=mentors_channel_response["ts"], 101 | text="Tagged users for mentorship request...", 102 | blocks=[request_claim_tagged_users_block(retrieve_mentor_slack_names)], 103 | link_names=True, 104 | ) 105 | await context.client.chat_postEphemeral( 106 | channel=parsed_body.user.id, 107 | user=parsed_body.user.id, 108 | text="Successfully sent mentorship request...", 109 | blocks=[request_successful_block()], 110 | ) 111 | except Exception as general_exception: 112 | logger.exception( 113 | f"Unable to create the mentorship request record due to error: {general_exception}", # noqa: TRY401, G004 114 | ) 115 | await context.client.chat_postEphemeral( 116 | channel=parsed_body.user.id, 117 | user=parsed_body.user.id, 118 | text="Mentorship request was unsuccessful...", 119 | blocks=[request_unsuccessful_block()], 120 | ) 121 | 122 | 123 | async def handle_mentorship_request_claim( 124 | parsed_body: SlackActionRequestBody, 125 | context: AsyncBoltContext, 126 | ) -> None: 127 | """Handle a mentorship request claim submission from Slack. 128 | 129 | :param parsed_body: The parsed body of the Slack request. 130 | :param context: The context object for the Slack request. 131 | """ 132 | logger.info("STAGE: Handling mentorship request claim...") 133 | await context.ack() 134 | blocks = parsed_body.message.blocks 135 | blocks[-1] = request_claim_reset_button(parsed_body.user.username) 136 | request_record = mentorship_requests_table.return_record_by_slack_message_ts( 137 | timestamp=str(parsed_body.message.ts), 138 | ) 139 | mentorship_requests_table.update_record( 140 | airtable_id=request_record.airtable_id, 141 | fields_to_update={ 142 | "Claimed": "true", 143 | "Claimed By": parsed_body.user.username, 144 | "Claimed On": str(datetime.now(timezone.utc)), 145 | "Reset By": "", 146 | }, 147 | ) 148 | await log_to_thread( 149 | client=context.client, 150 | channel_id=parsed_body.channel.id, 151 | message_ts=parsed_body.message.ts, 152 | username=parsed_body.user.username, 153 | action_ts=parsed_body.actions[0].action_ts, 154 | claim=True, 155 | ) 156 | await context.respond( 157 | text="Someone claimed the mentorship request...", 158 | blocks=blocks, 159 | replace_original=True, 160 | ) 161 | 162 | 163 | async def handle_mentorship_request_claim_reset( # noqa: D103 164 | parsed_body: SlackActionRequestBody, 165 | context: AsyncBoltContext, 166 | ) -> None: 167 | logger.info("STAGE: Handling mentorship request claim reset...") 168 | await context.ack() 169 | blocks = parsed_body.message.blocks 170 | blocks[-1] = request_claim_button() 171 | request_record = mentorship_requests_table.return_record_by_slack_message_ts( 172 | timestamp=parsed_body.message.ts, 173 | ) 174 | mentorship_requests_table.update_record( 175 | airtable_id=request_record.airtable_id, 176 | fields_to_update={ 177 | "Claimed": "false", 178 | "Claimed By": "", 179 | "Reset By": parsed_body.user.username, 180 | "Reset On": str(datetime.now(timezone.utc)), 181 | "Reset Count": int(request_record.reset_count) + 1, 182 | }, 183 | ) 184 | await log_to_thread( 185 | client=context.client, 186 | channel_id=parsed_body.channel.id, 187 | message_ts=parsed_body.message.ts, 188 | username=parsed_body.user.username, 189 | action_ts=parsed_body.actions[0].action_ts, 190 | claim=False, 191 | ) 192 | await context.respond( 193 | text="Someone reset the claimed mentorship request...", 194 | blocks=blocks, 195 | replace_original=True, 196 | ) 197 | 198 | 199 | def create_mentor_request_record( # noqa: D103 200 | parsed_body: SlackViewRequestBody, 201 | slack_user_info: UserInfo, 202 | ) -> tuple[MentorshipRequestCreate, dict[str, Any]]: 203 | logger.info("STAGE: Creating the mentorship request record...") 204 | try: 205 | mentorship_request = MentorshipRequestCreate( 206 | slack_name=slack_user_info.name, 207 | email=slack_user_info.email, 208 | service=parsed_body.view.state["values"]["mentorship_service_input"]["mentorship_service_selection"][ 209 | "selected_option" 210 | ]["value"], 211 | additional_details=parsed_body.view.state["values"]["details_input_block"]["details_text_input"]["value"], 212 | skillsets_requested=[ 213 | skill["value"] 214 | for skill in parsed_body.view.state["values"]["mentor_skillset_input"][ 215 | "mentorship_skillset_multi_selection" 216 | ]["selected_options"] 217 | ], 218 | affiliation=parsed_body.view.state["values"]["mentorship_affiliation_input"][ 219 | "mentorship_affiliation_selection" 220 | ]["selected_option"]["text"]["text"], 221 | ) 222 | modified_request = {k.title().replace("_", " "): v for k, v in mentorship_request.__dict__.items()} 223 | created_record = mentorship_requests_table.create_record(modified_request) 224 | return mentorship_request, created_record # noqa: TRY300 225 | except Exception as exc: 226 | logger.exception( 227 | f"Unable to create the Airtable record for user: {slack_user_info.name} due to an exception", # noqa: G004 228 | exc, # noqa: TRY401 229 | ) 230 | raise exc # noqa: TRY201 231 | -------------------------------------------------------------------------------- /modules/handlers/report_handler.py: -------------------------------------------------------------------------------- 1 | import logging # noqa: D100 2 | from typing import Any 3 | 4 | from slack_bolt.context.async_context import AsyncBoltContext 5 | 6 | from modules.models.slack_models.shared_models import SlackUserInfo 7 | from modules.models.slack_models.slack_models import SlackResponseBody 8 | from modules.slack.blocks.report_blocks import ( 9 | report_claim_blocks, 10 | report_claim_button, 11 | report_claim_claimed_button, 12 | report_failed_ephemeral_message, 13 | report_form_view_elements, 14 | report_received_ephemeral_message, 15 | ) 16 | from modules.utils import get_team_info, log_to_thread 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | async def handle_report(body: dict[str, Any], context: AsyncBoltContext) -> None: # noqa: D103 22 | await context.ack() 23 | await context.client.views_open( 24 | trigger_id=body["trigger_id"], 25 | view=report_form_view_elements(), 26 | ) 27 | 28 | 29 | async def handle_report_submit(body: dict[str, Any], context: AsyncBoltContext) -> None: # noqa: D103 30 | await context.ack() 31 | slack_team = get_team_info() 32 | logger.debug(f"Parsing received body: {body}") # noqa: G004 33 | parsed_body = SlackResponseBody( 34 | **body, 35 | originating_user=SlackUserInfo(**body["user"]), 36 | ) 37 | response = await context.client.chat_postMessage( 38 | channel=slack_team.moderators_channel.id, 39 | blocks=report_claim_blocks( 40 | parsed_body.originating_user.username, 41 | parsed_body.view.state["values"]["report_input"]["report_input_field"]["value"], 42 | ), 43 | text="New report submitted...", 44 | ) 45 | if response.data["ok"]: 46 | await context.client.chat_postEphemeral( 47 | channel=parsed_body.originating_user.id, 48 | text="Successfully sent report to moderators...", 49 | blocks=[report_received_ephemeral_message()], 50 | user=parsed_body.originating_user.id, 51 | ) 52 | else: 53 | await context.client.chat_postEphemeral( 54 | channel=parsed_body.originating_user.id, 55 | text="There was an issue sending your report...", 56 | blocks=[report_failed_ephemeral_message()], 57 | user=parsed_body.originating_user.id, 58 | ) 59 | 60 | 61 | async def handle_report_claim( # noqa: D103 62 | body: SlackResponseBody, 63 | context: AsyncBoltContext, 64 | ) -> None: 65 | await context.ack() 66 | blocks = body.message.blocks 67 | blocks[-1] = report_claim_claimed_button(body.originating_user.username) 68 | await log_to_thread( 69 | client=context.client, 70 | channel_id=body.channel.id, 71 | message_ts=body.message.ts, 72 | username=body.originating_user.username, 73 | action_ts=body.actions[0].action_ts, 74 | claim=True, 75 | ) 76 | await context.respond( 77 | text="Modified the claim to reach out about the report...", 78 | blocks=blocks, 79 | replace_original=True, 80 | ) 81 | 82 | 83 | async def handle_reset_report_claim( # noqa: D103 84 | body: SlackResponseBody, 85 | context: AsyncBoltContext, 86 | ) -> None: 87 | await context.ack() 88 | blocks = body.message.blocks 89 | blocks[-1] = report_claim_button() 90 | await log_to_thread( 91 | client=context.client, 92 | channel_id=body.channel.id, 93 | message_ts=body.message.ts, 94 | username=body.originating_user.username, 95 | action_ts=body.actions[0].action_ts, 96 | claim=False, 97 | ) 98 | await context.respond( 99 | text="Modified the claim to reach out about the report...", 100 | blocks=blocks, 101 | replace_original=True, 102 | ) 103 | -------------------------------------------------------------------------------- /modules/models/__init__.py: -------------------------------------------------------------------------------- 1 | # noqa: D104 2 | -------------------------------------------------------------------------------- /modules/models/daily_programmer_models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime # noqa: D100 2 | 3 | from pydantic import Field 4 | 5 | from modules.models.shared_models import AirtableRowBaseModel 6 | 7 | 8 | class DailyProgrammerInfo(AirtableRowBaseModel): # noqa: D101 9 | name: str = Field( 10 | ..., 11 | example="Minimum Absolute Difference", 12 | description="The display name of the daily programmer entry - will be wrapped in [] in the text from Slack", 13 | ) 14 | slug: str = Field( 15 | ..., 16 | example="minimum_absolute_difference", 17 | description="A more parseable representation of the name of the message - should be snake cased; this is set by formula in Airtable based on the Message Name field", # noqa: E501 18 | ) 19 | text: str = Field( 20 | ..., 21 | example="Your report has been received :check_mark:", 22 | description="The text of the message - utilizes Slack's mrkdwn format", 23 | ) 24 | category: str = Field( 25 | ..., 26 | example="mentorship_request", 27 | description="Snake cased category of the message", 28 | ) 29 | initially_posted_on: datetime = Field( 30 | None, 31 | example="2021-04-23T10:20:30.400+00:00", 32 | description="ISO formatted datetime in UTC for when the message was first posted to the channel", 33 | ) 34 | last_posted_on: datetime = Field( 35 | None, 36 | example="2021-04-23T10:20:30.400+00:00", 37 | description="ISO formatted datetime in UTC for when the message was last posted to the channel", 38 | ) 39 | posted_count: int = Field( 40 | ..., 41 | description="The number of time this message has been posted to the channel", 42 | ) 43 | -------------------------------------------------------------------------------- /modules/models/greeting_models.py: -------------------------------------------------------------------------------- 1 | """Models for the greeting module.""" 2 | from pydantic import BaseModel, Field 3 | 4 | 5 | class UserInfo(BaseModel): 6 | """User info schema.""" 7 | 8 | id: str = Field( 9 | ..., 10 | example="U02RK2AL5LZ", 11 | description="The Slack ID of the new user", 12 | ) 13 | name: str = Field( 14 | ..., 15 | example="julio123", 16 | description="The Slack name of the new user", 17 | ) 18 | first_name: str = Field( 19 | None, 20 | example="Julio", 21 | description="The first name of the new user", 22 | ) 23 | last_name: str = Field( 24 | None, 25 | example="Mendez", 26 | description="The last name of the new user", 27 | ) 28 | display_name: str = Field( 29 | None, 30 | example="julio123", 31 | description="The display name chosen by the user", 32 | ) 33 | real_name: str = Field( 34 | None, 35 | example="Julio Mendez", 36 | description="The display name of the new user as entered by the user", 37 | ) 38 | email: str = Field(..., example="test@example.com", description="Email of the user") 39 | zip_code: str = Field(None, example="12345", description="The zip code of the user") 40 | joined_date: str = Field( 41 | None, 42 | example="2013-01-30", 43 | description="The date the user joined the OC Slack", 44 | ) 45 | -------------------------------------------------------------------------------- /modules/models/mentorship_models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime # noqa: D100 2 | from typing import Union 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | from modules.models.shared_models import AirtableRowBaseModel 7 | 8 | 9 | class MentorshipService(AirtableRowBaseModel): # noqa: D101 10 | name: str = Field( 11 | ..., 12 | example="Pair Programming", 13 | description="Name of the service", 14 | ) 15 | slug: str = Field( 16 | ..., 17 | example="pair_programming", 18 | description="Snake cased value for the service, used for identification and other purposes", 19 | ) 20 | description: str = Field( 21 | ..., 22 | example="Work on a programming problem with a mentor while on a call", 23 | description="Description of the service", 24 | ) 25 | 26 | 27 | class MentorshipSkillset(AirtableRowBaseModel): # noqa: D101 28 | name: str = Field( 29 | ..., 30 | example="Pair Programming", 31 | description="Name of the service", 32 | ) 33 | slug: str = Field( 34 | ..., 35 | example="pair_programming", 36 | description="Snake cased value for the service, used for identification and other purposes", 37 | ) 38 | mentors: list[str] = Field( 39 | None, 40 | example="['recoakW045JkGgQB7', 'rec9Un0YIvPsFjPZh', 'recnfnbHDZdie8jcD']", 41 | description="List of Airtable record IDs for mentors that have this skillset", 42 | ) 43 | 44 | 45 | class MentorshipAffiliation(AirtableRowBaseModel): # noqa: D101 46 | name: str = Field( 47 | ..., 48 | example="US Veteran", 49 | description="The name of the affiliation", 50 | ) 51 | slug: str = Field( 52 | ..., 53 | example="us_veteran", 54 | description="A more parseable slug for the affiliation, set by a formula in Airtable", 55 | ) 56 | description: str = Field( 57 | ..., 58 | example="Veterans are former members of the United States military.", 59 | description="A short description of the affiliation", 60 | ) 61 | 62 | 63 | class Mentor(AirtableRowBaseModel): # noqa: D101 64 | slack_name: str = Field( 65 | ..., 66 | example="john123", 67 | description="The Slack username for the mentor", 68 | ) 69 | full_name: str = Field( 70 | ..., 71 | example="John Smith", 72 | description="The full name of the mentor", 73 | ) 74 | email: str = Field(..., example="test@example.com", description="Email of the user") 75 | active: bool = Field(..., description="Whether or not the mentor is current active") 76 | skills: list[str] = Field( 77 | ..., 78 | example="['recoakW045JkGgQB7', 'rec9Un0YIvPsFjPZh', 'recnfnbHDZdie8jcD']", 79 | description="The Airtable provided IDs of the skillsets the mentor has added", 80 | ) 81 | desired_mentorship_hours_per_week: int = Field( 82 | ..., 83 | description="The number of hours the mentor has specified they would like to mentor for", 84 | ) 85 | time_zone: str = Field( 86 | ..., 87 | example="Indian/Maldives", 88 | description="The mentor's time zone", 89 | ) 90 | max_mentees: int = Field( 91 | ..., 92 | description="The maximum number of mentees this mentor wants to work with at one time", 93 | ) 94 | bio: str = Field(None, description="The self provided bio for the mentor") 95 | notes: str = Field(None, description="Any additional notes on the mentor") 96 | mentees_worked_with: list[str] = Field( 97 | None, 98 | example="['recCMMhN5j51NoagK']", 99 | description="The Airtable provided IDs of the mentees that the mentor has worked with, found on the Mentor Request table", # noqa: E501 100 | ) 101 | code_of_conduct_accepted: bool = Field( 102 | ..., 103 | description="Whether or not the mentor has accepted the code of conduct", 104 | ) 105 | guidebook_read: bool = Field( 106 | ..., 107 | description="Whether or not the mentor has read the guidebook", 108 | ) 109 | row_id: int = Field(..., description="Row ID from the Airtable table") 110 | 111 | 112 | class MentorshipRequestBase(BaseModel): # noqa: D101 113 | slack_name: str = Field( 114 | ..., 115 | example="john123", 116 | description="The Slack username for the user making the mentorship request", 117 | ) 118 | email: str = Field( 119 | ..., 120 | example="test@example.com", 121 | description="Email of the requesting user", 122 | ) 123 | service: str = Field( 124 | ..., 125 | example="Career Guidance", 126 | description="Service requested for the mentorship session", 127 | ) 128 | additional_details: str = Field( 129 | ..., 130 | example="I need help with choosing a career path.", 131 | description="Details provided by the user making the request", 132 | ) 133 | skillsets_requested: list[str] = Field( 134 | ..., 135 | example="['Go', 'React', 'Code Review']", 136 | description="List of all skillsets selected by the user making the request - this is used to match a mentor", 137 | ) 138 | affiliation: str | list[str] = Field( 139 | ..., 140 | example="recCMMhN5j51NoagK", 141 | description="The Airtable created ID of a record on the Affiliations table", 142 | ) 143 | claimed: bool = Field( 144 | False, # noqa: FBT003 145 | description="Whether or not the mentor request has been claimed", 146 | ) 147 | claimed_by: Union[str, list[str]] = Field( # noqa: UP007 148 | None, 149 | description="The Airtable ID of the user who has claimed the request - this is pulled from the Mentor table", 150 | ) 151 | claimed_on: datetime = Field( 152 | None, 153 | example="2021-04-23T10:20:30.400+00:00", 154 | description="ISO formatted UTC time when the request was claimed", 155 | ) 156 | reset_by: str = Field( 157 | None, 158 | example="john123", 159 | description="Slack username of the user who reset the claim", 160 | ) 161 | reset_on: datetime = Field( 162 | None, 163 | example="2021-04-23T10:20:30.400+00:00", 164 | description="ISO formatted UTC time when the request claim was reset", 165 | ) 166 | reset_count: int = Field( 167 | 0, 168 | description="The number of times the request claim was reset", 169 | ) 170 | 171 | 172 | class MentorshipRequest(MentorshipRequestBase, AirtableRowBaseModel): # noqa: D101 173 | row_id: int = Field( 174 | None, 175 | description="The Airtable created row ID of the row, primarily used for sorting", 176 | ) 177 | slack_message_ts: float = Field( 178 | ..., 179 | example=1640727458.000000, 180 | description="The message timestamp - this along with the channel ID allow the message to be found", 181 | ) 182 | 183 | 184 | class MentorshipRequestCreate(MentorshipRequestBase): 185 | """Create a new mentorship request.""" 186 | -------------------------------------------------------------------------------- /modules/models/message_text_models.py: -------------------------------------------------------------------------------- 1 | """Models related to message text.""" 2 | from pydantic import Field 3 | 4 | from modules.models.shared_models import AirtableRowBaseModel 5 | 6 | 7 | class MessageTextInfo(AirtableRowBaseModel): 8 | """The message text info model. 9 | 10 | This model represents messages that are sent to different channels in Slack. 11 | """ 12 | 13 | name: str = Field( 14 | ..., 15 | example="Report Received", 16 | description="The display name of the message text", 17 | ) 18 | slug: str = Field( 19 | ..., 20 | example="report_received", 21 | description="A more parseable representation of the name of the message - should be snake cased; " 22 | "this is set by formula in Airtable based on the Message Name field", 23 | ) 24 | text: str = Field( 25 | ..., 26 | example="Your report has been received :check_mark:", 27 | description="The text of the message - utilizes Slack's mrkdwn format", 28 | ) 29 | category: str = Field( 30 | ..., 31 | example="mentorship_request", 32 | description="Snake cased category of the message", 33 | ) 34 | -------------------------------------------------------------------------------- /modules/models/report_models.py: -------------------------------------------------------------------------------- 1 | """Models related to reports.""" 2 | -------------------------------------------------------------------------------- /modules/models/scheduled_message_models.py: -------------------------------------------------------------------------------- 1 | """Models related to scheduled messages.""" 2 | from datetime import datetime 3 | from enum import Enum 4 | 5 | from pydantic import Field, field_validator 6 | from pydantic_core.core_schema import FieldValidationInfo 7 | 8 | from modules.models.shared_models import AirtableRowBaseModel 9 | 10 | 11 | class FrequencyEnum(str, Enum): 12 | """Enum for message frequency.""" 13 | 14 | daily = "daily" 15 | weekly = "weekly" 16 | monthly = "monthly" 17 | 18 | 19 | class ScheduledMessageInfo(AirtableRowBaseModel): 20 | """The scheduled message info model.""" 21 | 22 | name: str = Field( 23 | ..., 24 | example="Mentorship Reminder", 25 | description="The display name of the message to be scheduled", 26 | ) 27 | slug: str = Field( 28 | ..., 29 | example="mentorship_reminder", 30 | description="A more parseable representation of the name of the scheduled message - should be snake cased", 31 | ) 32 | channel: str = Field( 33 | ..., 34 | example="general", 35 | description="Channel to send the message to", 36 | ) 37 | message_text: str = Field( 38 | ..., 39 | example="Don't forget you can use the `/mentor` command to request a 1 on 1 session with a mentor!", 40 | description="A text string that can contain markdown syntax to be posted to Slack", 41 | ) 42 | initial_date_time_to_send: datetime = Field( 43 | ..., 44 | example="2021-04-23T10:20:30.400+00:00", 45 | description="ISO formatted datetime in UTC to send the first message - " 46 | "this is used to set the schedule for this message", 47 | ) 48 | frequency: str = Field( 49 | ..., 50 | example="daily", 51 | description="Frequency to send the message - one of daily, weekly, monthly", 52 | ) 53 | scheduled_next: datetime = Field( 54 | None, 55 | example="2021-04-23T10:20:30.400+00:00", 56 | description="When the message was last scheduled to send", 57 | ) 58 | when_to_send: datetime = Field( 59 | ..., 60 | example="2021-04-23T10:20:30.400+00:00", 61 | description="When to send the message - this is calculated using a formula on the Airtable table", 62 | ) 63 | 64 | @field_validator("frequency") 65 | def frequency_must_be_valid( 66 | cls: "ScheduledMessageInfo", # noqa: N805 67 | frequency: str, 68 | info: FieldValidationInfo, # noqa: ARG002 69 | ) -> str: 70 | """Validate that the passed in frequency is a valid option. 71 | 72 | :param frequency: The frequency to validate. 73 | :param info: The field validation info. 74 | :return: The frequency if it is valid. 75 | """ 76 | if frequency.lower() not in FrequencyEnum.__members__: 77 | exception_message = f"Frequency must be one of {FrequencyEnum.__members__.keys()}" 78 | raise ValueError(exception_message) 79 | return frequency.lower() 80 | -------------------------------------------------------------------------------- /modules/models/shared_models.py: -------------------------------------------------------------------------------- 1 | """Shared models for Airtable tables.""" 2 | from datetime import datetime 3 | from enum import Enum 4 | 5 | from pydantic import BaseModel, Field 6 | 7 | 8 | class ValidEnum(str, Enum): 9 | """Enum for valid and invalid.""" 10 | 11 | valid = "valid" 12 | invalid = "invalid" 13 | 14 | 15 | class AirtableUser(BaseModel): 16 | """Model for Airtable user.""" 17 | 18 | id: str = Field( 19 | ..., 20 | example="usrAuExK7DEWFNiI6", 21 | description="Airtable provided unique ID of the user", 22 | ) 23 | email: str = Field(..., example="test@example.com", description="Email of the user") 24 | name: str = Field(..., example="John Smith", description="Name of the user") 25 | 26 | 27 | class AirtableRowBaseModel(BaseModel): 28 | """Base model for Airtable rows.""" 29 | 30 | airtable_id: str = Field( 31 | ..., 32 | example="rec8CRVRJOKYBIDIL", 33 | description="Airtable provided unique ID for the row", 34 | ) 35 | created_at: datetime = Field( 36 | ..., 37 | example="2021-04-23T10:20:30.400+00:00", 38 | description="When the Airtable record was created", 39 | ) 40 | last_modified: datetime = Field( 41 | None, 42 | example="2021-04-23T10:20:30.400+00:00", 43 | description="When the Airtable record was last updated", 44 | ) 45 | last_modified_by: AirtableUser = Field( 46 | None, 47 | example="JulioMendez", 48 | description="Name of the user who last modified the Airtable record", 49 | ) 50 | valid: ValidEnum = Field( 51 | None, 52 | example="invalid", 53 | description="Whether or not the record is valid - this is calculated on the Airtable table and has a value of valid if all fields are filled out", # noqa: E501 54 | ) 55 | -------------------------------------------------------------------------------- /modules/models/slack_models/__init__.py: -------------------------------------------------------------------------------- 1 | # noqa: D104 2 | -------------------------------------------------------------------------------- /modules/models/slack_models/action_models.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field # noqa: D100 2 | 3 | from modules.models.slack_models.shared_models import ( 4 | BaseSlackTeamInfo, 5 | BasicSlackRequest, 6 | SlackActionContainerInfo, 7 | SlackActionInfo, 8 | SlackChannelInfo, 9 | SlackMessageInfo, 10 | SlackUserInfo, 11 | ) 12 | 13 | 14 | class SlackActionRequestBody(BasicSlackRequest): 15 | """The body of a Slack action request.""" 16 | 17 | type: str = Field(..., example="block_actions", description="The type of action") 18 | user: SlackUserInfo = Field( 19 | ..., 20 | description="The user who triggered the action request", 21 | ) 22 | container: SlackActionContainerInfo = Field( 23 | ..., 24 | description="The container where the action was triggered", 25 | ) 26 | team: BaseSlackTeamInfo = Field(..., description="Basic team information") 27 | channel: SlackChannelInfo = Field( 28 | ..., 29 | description="The channel the action was triggered in", 30 | ) 31 | message: SlackMessageInfo = Field( 32 | ..., 33 | description="The original message where the action was triggered", 34 | ) 35 | response_url: str = Field( 36 | ..., 37 | example="https://hooks.slack.com/actions/T01SBLCQ57A/2899731511204/xb8gxI4ldtCaVwbdsddM0nb", 38 | description="The response URL where a response can be sent if needed", 39 | ) 40 | actions: list[SlackActionInfo] = Field( 41 | ..., 42 | description="The action information about the action that was triggered", 43 | ) 44 | -------------------------------------------------------------------------------- /modules/models/slack_models/command_models.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field # noqa: D100 2 | 3 | from modules.models.slack_models.shared_models import BasicSlackRequest 4 | 5 | 6 | class SlackCommandRequestBody(BasicSlackRequest): 7 | """The body of a Slack command request. 8 | 9 | These are typically received from the Slack application after a slash command is used. 10 | """ 11 | 12 | command: str = Field( 13 | ..., 14 | example="/mentor_request", 15 | description="The command that triggered the request", 16 | ) 17 | user_id: str = Field( 18 | ..., 19 | example="U01RN31JSTT", 20 | description="The Slack user ID for the user who triggered the request", 21 | ) 22 | user_name: str = Field( 23 | ..., 24 | example="john123", 25 | description="The Slack user name for the user who triggered the request", 26 | ) 27 | channel_id: str = Field( 28 | ..., 29 | example="D02R6CR6DMG", 30 | description="The Slack channel ID where the command was triggered", 31 | ) 32 | channel_name: str = Field( 33 | ..., 34 | example="directmessage", 35 | description="The name of the channel where the command was triggered", 36 | ) 37 | response_url: str | None = Field( 38 | None, 39 | example="https://hooks.slack.com/actions/T01SBLfdsaQ57A/2902419552385/BiWpNhRSURKF9CvqujZ3x1MQ", 40 | description="The URL to send the response to that will automatically put the response in the right place", 41 | ) 42 | team_id: str = Field( 43 | ..., 44 | example="T01SBLCQ57A", 45 | description="The Slack ID of the team that the command came from", 46 | ) 47 | -------------------------------------------------------------------------------- /modules/models/slack_models/event_models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field # noqa: D100 2 | 3 | from modules.models.slack_models.shared_models import SlackMessageInfo 4 | 5 | 6 | class MemberJoinedChannelEvent(BaseModel): 7 | """The body of a Slack member_joined_channel event.""" 8 | 9 | type: str = Field( 10 | ..., 11 | example="member_joined_channel", 12 | description="The type of event, should always be member_joined_channel", 13 | ) 14 | user: str = Field( 15 | ..., 16 | example="U123456789", 17 | description="The Slack ID of the user who joined the channel", 18 | ) 19 | channel: str = Field( 20 | ..., 21 | example="C0698JE0H", 22 | description="The Slack ID of the channel the user joined", 23 | ) 24 | channel_type: str = Field( 25 | ..., 26 | example="C", 27 | description="The channel type - C is typically a public channel and G is for a private channel or group", 28 | ) 29 | team: str = Field(..., example="T024BE7LD", description="The Slack ID of the team") 30 | inviter: str = Field( 31 | None, 32 | example="U123456789", 33 | description="The Slack user ID of the user who invited the joining user - is optional and won't show up for default channels, for example", # noqa: E501 34 | ) 35 | 36 | 37 | class MessageReceivedChannelEvent(BaseModel): 38 | """The body of a Slack message event.""" 39 | 40 | team_id: str = Field( 41 | ..., 42 | example="T024BE7LD", 43 | description="The Slack ID of the team", 44 | ) 45 | api_app_id: str = Field( 46 | ..., 47 | example="A02R6C6S9JN", 48 | description="The Slack application ID", 49 | ) 50 | event: SlackMessageInfo = Field( 51 | ..., 52 | description="The information about the message that was received", 53 | ) 54 | type: str = Field( 55 | ..., 56 | example="event_callback", 57 | description="The type of event, should always be event_callback", 58 | ) 59 | event_id: str = Field( 60 | ..., 61 | example="Ev02UJP6HDBR", 62 | description="The Slack provided ID of the event", 63 | ) 64 | event_time: int = Field( 65 | ..., 66 | example=1642732981, 67 | description="The Unix timestamp of the event", 68 | ) 69 | event_context: str = Field( 70 | ..., 71 | example="4-eyJldCI6Im1lc3NhZ2UiLCJ0aWQiOiJUMDFTQkxDUTU3QSIsImFpZCI6IkEwMlI2QzZTOUpOIiwiY2lkIjoiQzAxUlUxTUhNRkUifQ", 72 | ) 73 | -------------------------------------------------------------------------------- /modules/models/slack_models/message_models.py: -------------------------------------------------------------------------------- 1 | """Models for messages. Unused for now.""" 2 | -------------------------------------------------------------------------------- /modules/models/slack_models/slack_models.py: -------------------------------------------------------------------------------- 1 | from typing import Any # noqa: D100 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from modules.models.slack_models.shared_models import ( 6 | BasicSlackRequest, 7 | SlackActionContainerInfo, 8 | SlackActionInfo, 9 | SlackChannelInfo, 10 | SlackMessageInfo, 11 | SlackUserInfo, 12 | SlackViewInfo, 13 | ) 14 | 15 | 16 | class SlackResponseBody(BasicSlackRequest): 17 | """The body of a Slack response.""" 18 | 19 | type: str = Field( 20 | ..., 21 | example="view_submission", 22 | description="The type of request the response is responding to", 23 | ) 24 | originating_user: SlackUserInfo = Field( 25 | ..., 26 | description="The info of the user who triggered the request", 27 | ) 28 | view: SlackViewInfo | None = Field( 29 | None, 30 | description="View object of the original message if it exists", 31 | ) 32 | container: SlackActionContainerInfo | None = Field( 33 | None, 34 | description="The container that the action originated from if it exists", 35 | ) 36 | channel: SlackChannelInfo | None = Field( 37 | None, 38 | description="The channel information for where the original request was from", 39 | ) 40 | message: SlackMessageInfo | None = Field( 41 | None, 42 | description="The original message from the request, if it exists", 43 | ) 44 | response_urls: list[str] | None = Field( 45 | None, 46 | description="List of response URLs, typically included with a view response", 47 | ) 48 | actions: list[SlackActionInfo] | None = Field( 49 | None, 50 | description="The list of actions in this message", 51 | ) 52 | 53 | 54 | class BotInfo(BaseModel): 55 | """Information about the bot that sent the request.""" 56 | 57 | slack_id: str = Field( 58 | ..., 59 | example="B02QRQ4KU5V", 60 | description="Slack ID for the bot that sent the request", 61 | ) 62 | app_id: str = Field( 63 | ..., 64 | example="A02R6C6S9JN", 65 | description="Slack ID for the parent application", 66 | ) 67 | name: str = Field( 68 | ..., 69 | example="retrieval-bot", 70 | description="Name of the bot that sent the request", 71 | ) 72 | team_id: str = Field( 73 | ..., 74 | example="T01SBLCQ57A", 75 | description="Slack team ID of the bot that sent the request", 76 | ) 77 | 78 | 79 | class BasicSlackBotResponse(BaseModel): 80 | """Basic information about a Slack bot response.""" 81 | 82 | date_time_received: str = Field( 83 | ..., 84 | example="Tue, 28 Dec 2021 05:36:22 GMT", 85 | description="Timestamp for when the response was received", 86 | ) 87 | oauth_scopes: str = Field( 88 | ..., 89 | example="app_mentions:read,channels:history,channels:read,channels:join,emoji:read", 90 | description="List of oauth scopes the bot is authorized to use", 91 | ) 92 | status_ok: bool = Field( 93 | ..., 94 | description="Status of the request that triggered the response, true means the request was successful while " 95 | "false means it was in error", 96 | ) 97 | received_timestamp: str = Field( 98 | ..., 99 | example="1640669783.000100", 100 | description="Unix epoch timestamp for when the request was received", 101 | ) 102 | 103 | 104 | class SlackBotResponseContent(BasicSlackBotResponse): 105 | """The content of a Slack bot response.""" 106 | 107 | channel: str = Field( 108 | ..., 109 | example="D02R6CR6DMG", 110 | description="Channel the request was sent to", 111 | ) 112 | bot_info: BotInfo = Field( 113 | ..., 114 | description="Information about the bot that sent the request", 115 | ) 116 | request_blocks: list[dict[str, Any]] | None = Field( 117 | None, 118 | description="List of blocks in the original request", 119 | ) 120 | -------------------------------------------------------------------------------- /modules/models/slack_models/view_models.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field # noqa: D100 2 | 3 | from modules.models.slack_models.shared_models import ( 4 | BasicSlackRequest, 5 | SlackUserInfo, 6 | SlackViewInfo, 7 | ) 8 | 9 | 10 | class SlackViewRequestBody(BasicSlackRequest): # noqa: D101 11 | user: SlackUserInfo = Field( 12 | ..., 13 | description="The Slack user object of the user who triggered the submission of the view", 14 | ) 15 | view: SlackViewInfo = Field( 16 | ..., 17 | description="The information of the view that was submitted", 18 | ) 19 | response_urls: list[str] = Field( 20 | [], 21 | example="['https://hooks.slack.com/actions/T01SBLfdsaQ57A/2902419552385/BiWpNhRSURKF9CvqujZ3x1MQ']", 22 | description="List of URLs to be used for responses depending on if the view has elements that are configured to generate a response URL", # noqa: E501 23 | ) 24 | -------------------------------------------------------------------------------- /modules/slack/__init__.py: -------------------------------------------------------------------------------- 1 | # noqa: D104 2 | -------------------------------------------------------------------------------- /modules/slack/blocks/__init__.py: -------------------------------------------------------------------------------- 1 | # noqa: D104 2 | -------------------------------------------------------------------------------- /modules/slack/blocks/announcement_blocks.py: -------------------------------------------------------------------------------- 1 | """Slack blocks for the announcements in various channels.""" 2 | from slack_sdk.models.blocks.basic_components import MarkdownTextObject, PlainTextObject 3 | from slack_sdk.models.blocks.blocks import HeaderBlock, SectionBlock 4 | 5 | 6 | def general_announcement_blocks( 7 | header_text: str, 8 | text: str, 9 | ) -> list[HeaderBlock | SectionBlock]: 10 | """The blocks used for a general announcement. 11 | 12 | :param header_text: The text for the header. 13 | :param text: The text for the body. 14 | :return: A list of Header and Section blocks. 15 | """ # noqa: D401 16 | return [general_announcement_header(header_text), general_announcement_body(text)] 17 | 18 | 19 | def general_announcement_header(header_text: str) -> HeaderBlock: 20 | """The header block for a general announcement. 21 | 22 | :param header_text: The text for the header. 23 | :return: The header block. 24 | """ # noqa: D401 25 | text = PlainTextObject(text="[" + header_text + "]", emoji=True) 26 | return HeaderBlock(block_id="general_announcement_header", text=text) 27 | 28 | 29 | def general_announcement_body(text: str) -> SectionBlock: 30 | """The body block for a general announcement. 31 | 32 | :param text: The text for the body. 33 | :return: The body block. 34 | """ # noqa: D401 35 | text = MarkdownTextObject(text=text) 36 | return SectionBlock(text=text, block_id="general_announcement_body") 37 | -------------------------------------------------------------------------------- /modules/slack/blocks/block_kit_examples/channel_join_request_blocks.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "request_main", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": ":tada: has requested to join the channel." 9 | } 10 | }, 11 | { 12 | "type": "actions", 13 | "block_id": "invite_to_channel_claim", 14 | "elements": [ 15 | { 16 | "type": "button", 17 | "text": { 18 | "type": "plain_text", 19 | "text": "I'll Invite Them!", 20 | "emoji": true 21 | }, 22 | "style": "primary", 23 | "value": "juilio.mendez", 24 | "action_id": "invite_to_channel_click" 25 | } 26 | ] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /modules/slack/blocks/block_kit_examples/general_announcement.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "header", 5 | "block_id": "general_announcement_header", 6 | "text": { 7 | "type": "plain_text", 8 | "text": "[Scholarship Opportunity]", 9 | "emoji": true 10 | } 11 | }, 12 | { 13 | "type": "section", 14 | "block_id": "general_announcement_body", 15 | "text": { 16 | "type": "mrkdwn", 17 | "text": "*Coursera:* Prepare for in-demand jobs in Data Analytics, IT Support, Project Management, and UX design. ends on December 31st 2022." 18 | } 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /modules/slack/blocks/block_kit_examples/greeting_block.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "text": { 6 | "type": "mrkdwn", 7 | "text": ":tada: has joined our community! :tada:" 8 | } 9 | }, 10 | { 11 | "type": "section", 12 | "fields": [ 13 | { 14 | "type": "mrkdwn", 15 | "text": "*First Name:*" 16 | }, 17 | { 18 | "type": "mrkdwn", 19 | "text": "Julio" 20 | }, 21 | { 22 | "type": "mrkdwn", 23 | "text": "*Last Name:*" 24 | }, 25 | { 26 | "type": "mrkdwn", 27 | "text": "Mendez" 28 | }, 29 | { 30 | "type": "mrkdwn", 31 | "text": "*When:*" 32 | }, 33 | { 34 | "type": "mrkdwn", 35 | "text": "August 10th, 2021" 36 | } 37 | ] 38 | }, 39 | { 40 | "type": "actions", 41 | "elements": [ 42 | { 43 | "type": "button", 44 | "text": { 45 | "type": "plain_text", 46 | "emoji": true, 47 | "text": "I will greet them!" 48 | }, 49 | "style": "primary", 50 | "value": "greet_user" 51 | } 52 | ] 53 | } 54 | ] 55 | } -------------------------------------------------------------------------------- /modules/slack/blocks/block_kit_examples/mentorship/mentorship_claim_blocks.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "mentorship_request_service_text", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": "User has requested a mentor for Code Review." 9 | } 10 | }, 11 | { 12 | "type": "section", 13 | "block_id": "mentorship_request_skillset_text", 14 | "text": { 15 | "type": "mrkdwn", 16 | "text": "*Requested Skillset(s):* SQL, C / C++" 17 | } 18 | }, 19 | { 20 | "type": "section", 21 | "block_id": "mentorship_request_affiliation_text", 22 | "text": { 23 | "type": "mrkdwn", 24 | "text": "*Requestor Affiliation:* US Military Veteran" 25 | } 26 | }, 27 | { 28 | "type": "actions", 29 | "block_id": "claim_button_action_block", 30 | "elements": [ 31 | { 32 | "type": "button", 33 | "text": { 34 | "type": "plain_text", 35 | "text": "Claim Mentorship Request", 36 | "emoji": true 37 | }, 38 | "style": "primary", 39 | "value": "JulioMendez", 40 | "action_id": "claim_mentorship_request" 41 | } 42 | ] 43 | } 44 | ] 45 | } -------------------------------------------------------------------------------- /modules/slack/blocks/block_kit_examples/new_join_delayed.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "delayed_welcome_first_text", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": "Again, welcome to Operation Code's Slack Community, we're very glad you are here! Please share with us in #general what brings you to Operation Code if you haven't already. Also please let us know how we can assist you on your journey. Consider adding links to your LinkedIn and Github profiles on your Operation Code profile. Lastly, consider connecting with us on our , , , and . If you'd like to contribute to our Open Source software, you can do so on ." 9 | } 10 | }, 11 | { 12 | "type": "section", 13 | "block_id": "delayed_welcome_second_text", 14 | "text": { 15 | "type": "mrkdwn", 16 | "text": "We're excited to have you! If you have any immediate needs, please tag @outreach-team in any public channel." 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /modules/slack/blocks/block_kit_examples/new_join_immediate.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "immediate_welcome_main_text", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": "Hello ! Welcome to Operation Code! I'm a bot designed to help you navigate this Slack workspace. Our goal here at Operation Code is to get veterans and their families started on the path to a career in tech. We do that through providing you with scholarships, mentoring, career development opportunities, conference tickets, and more! You can check out more information about us ." 9 | } 10 | }, 11 | { 12 | "type": "section", 13 | "text": { 14 | "type": "mrkdwn", 15 | "text": "Much of the provided aid requires veteran or military spouse status. Please verify your status on your profile at https://operationcode.org if you haven't already." 16 | } 17 | }, 18 | { 19 | "type": "section", 20 | "text": { 21 | "type": "mrkdwn", 22 | "text": "You are currently in Slack, a chat application that serves as the hub of Operation Code. If you are visiting us via your browser, Slack provides a to make staying in touch even more convenient." 23 | } 24 | }, 25 | { 26 | "type": "section", 27 | "text": { 28 | "type": "mrkdwn", 29 | "text": "All active Operation Code open source projects are located on our . Lastly, please take a moment to review our ." 30 | } 31 | }, 32 | { 33 | "type": "section", 34 | "block_id": "oc_homepage_button", 35 | "text": { 36 | "type": "mrkdwn", 37 | "text": "Operation Code Homepage" 38 | }, 39 | "accessory": { 40 | "type": "button", 41 | "text": { 42 | "type": "plain_text", 43 | "text": "OC Homepage", 44 | "emoji": true 45 | }, 46 | "value": "oc_home_page", 47 | "url": "https://operationcode.org", 48 | "action_id": "oc_greeting_homepage_click", 49 | "style": "primary" 50 | } 51 | }, 52 | { 53 | "type": "section", 54 | "block_id": "slack_download_button", 55 | "text": { 56 | "type": "mrkdwn", 57 | "text": "Slack Download" 58 | }, 59 | "accessory": { 60 | "type": "button", 61 | "text": { 62 | "type": "plain_text", 63 | "text": "Slack Download", 64 | "emoji": true 65 | }, 66 | "value": "slack_download", 67 | "url": "https://slack.com/downloads/", 68 | "style": "primary", 69 | "action_id": "oc_greeting_slack_download_click" 70 | } 71 | }, 72 | { 73 | "type": "section", 74 | "block_id": "oc_coc_button", 75 | "text": { 76 | "type": "mrkdwn", 77 | "text": "Operation Code CoC" 78 | }, 79 | "accessory": { 80 | "type": "button", 81 | "text": { 82 | "type": "plain_text", 83 | "text": "Operation Code CoC", 84 | "emoji": true 85 | }, 86 | "value": "operation_code_coc", 87 | "url": "https://github.com/OperationCode/community/blob/master/code_of_conduct.md", 88 | "style": "primary", 89 | "action_id": "oc_greeting_coc_click" 90 | } 91 | } 92 | ] 93 | } -------------------------------------------------------------------------------- /modules/slack/blocks/block_kit_examples/reports/report_claim.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "report_claim_title", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": ":warning: has submitted a report. :warning:" 9 | } 10 | }, 11 | { 12 | "type": "header", 13 | "block_id": "report_claim_header", 14 | "text": { 15 | "type": "plain_text", 16 | "text": "Report details:", 17 | "emoji": true 18 | } 19 | }, 20 | { 21 | "type": "section", 22 | "block_id": "report_claim_details", 23 | "text": { 24 | "type": "mrkdwn", 25 | "text": "I have an issue with the post made by x: blah blah blah" 26 | } 27 | }, 28 | { 29 | "type": "actions", 30 | "block_id": "report_claim_button", 31 | "elements": [ 32 | { 33 | "type": "button", 34 | "action_id": "report_claim_button_click", 35 | "text": { 36 | "type": "plain_text", 37 | "emoji": true, 38 | "text": "I Will Reach Out to Them" 39 | }, 40 | "style": "primary", 41 | "value": "claim_report" 42 | } 43 | ] 44 | } 45 | ] 46 | } -------------------------------------------------------------------------------- /modules/slack/blocks/block_kit_examples/reports/report_form.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": { 3 | "type": "plain_text", 4 | "text": "OC Slack - Report", 5 | "emoji": true 6 | }, 7 | "type": "modal", 8 | "callback_id": "report_modal", 9 | "blocks": [ 10 | { 11 | "type": "section", 12 | "block_id": "report_title_block", 13 | "text": { 14 | "type": "mrkdwn", 15 | "text": ":warning: Thank you for taking the time to report an issue to the moderation team. Please fill out the below input field with the text of the message you'd like to report. If you'd like, you can include a short description of why you are reporting it. The report will only be shown to the moderators of the OC Slack workspace.:warning:" 16 | } 17 | }, 18 | { 19 | "type": "input", 20 | "block_id": "report_input", 21 | "element": { 22 | "type": "plain_text_input", 23 | "action_id": "report_input_field", 24 | "multiline": true, 25 | "focus_on_load": true, 26 | "min_length": 2, 27 | "placeholder": { 28 | "type": "plain_text", 29 | "text": "You can copy and paste the text of the message you'd like to report or tell us a bit about what you are reporting..." 30 | } 31 | }, 32 | "label": { 33 | "type": "plain_text", 34 | "text": "Text of message you are reporting or reason for your report*", 35 | "emoji": true 36 | } 37 | } 38 | ], 39 | "close": { 40 | "type": "plain_text", 41 | "text": "Cancel", 42 | "emoji": true 43 | }, 44 | "submit": { 45 | "type": "plain_text", 46 | "text": "Submit Report", 47 | "emoji": true 48 | } 49 | } -------------------------------------------------------------------------------- /modules/slack/blocks/block_kit_examples/reports/response_to_user_on_failed_report.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "text": { 6 | "type": "mrkdwn", 7 | "text": ":warning: Your report was not sent to the moderators due to an unspecified error. Please contact @moderators and let them know so we can investigate the issue and take care of your report. :warning:" 8 | } 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /modules/slack/blocks/block_kit_examples/reports/response_to_user_on_successful_report.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "text": { 6 | "type": "mrkdwn", 7 | "text": ":white_check_mark: Your report has been received by the moderator team and someone will be reaching out shortly! Please don't hesitate to contact @moderators if you have any other questions. :white_check_mark:" 8 | } 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /modules/slack/blocks/greeting_blocks.py: -------------------------------------------------------------------------------- 1 | from slack_sdk.models.blocks.basic_components import MarkdownTextObject, PlainTextObject # noqa: D100 2 | from slack_sdk.models.blocks.block_elements import ButtonElement 3 | from slack_sdk.models.blocks.blocks import ActionsBlock, SectionBlock 4 | 5 | from modules.models.greeting_models import UserInfo 6 | 7 | 8 | def initial_greet_user_blocks(user_info: UserInfo) -> list[SectionBlock | ActionsBlock]: 9 | """The blocks used for the initial greeting of a new user. 10 | 11 | :param user_info: The user info of the new user. 12 | :return: The blocks used for the initial greeting of a new user. 13 | """ # noqa: D401 14 | return [ 15 | greeting_blocks_title(user_info.name), 16 | greeting_blocks_user_info(user_info), 17 | greeting_block_button(user_info.id), 18 | ] 19 | 20 | 21 | def greeting_blocks_title(slack_name: str) -> SectionBlock: # noqa: D103 22 | greeting_text = MarkdownTextObject( 23 | text=f"🎉 <@{slack_name}> has joined our community! 🎉", 24 | ) 25 | return SectionBlock(block_id="title_text", text=greeting_text) 26 | 27 | 28 | def greeting_blocks_user_info(user_info: UserInfo) -> SectionBlock: # noqa: D103 29 | greeting_fields = [] 30 | for key, value in user_info.__dict__.items(): 31 | if key in ("zip_code", "email", "id"): # noqa: SIM114 32 | pass 33 | elif value is None: 34 | pass 35 | else: 36 | greeting_fields.append( 37 | MarkdownTextObject(text=f"*{key.replace('_', ' ').title()}:*"), 38 | ) 39 | greeting_fields.append(MarkdownTextObject(text=f"{value}")) 40 | return SectionBlock(block_id="user_info", fields=greeting_fields) 41 | 42 | 43 | def greeting_block_button(new_user_id: str) -> ActionsBlock: # noqa: D103 44 | button_text = PlainTextObject(text="I will greet them!", emoji=True) 45 | greet_button = ButtonElement( 46 | text=button_text, 47 | action_id="greet_new_user_claim", 48 | style="primary", 49 | value=f"{new_user_id}", 50 | ) 51 | return ActionsBlock( 52 | block_id="claim_action", 53 | elements=[greet_button], 54 | ) 55 | 56 | 57 | def greeting_block_claimed_button(claiming_user_name: str) -> ActionsBlock: 58 | """Creates an ActionsBlock that contains a button showing who claimed the greeting - this button allows anyone to reset the claim. 59 | 60 | :param claiming_user_name: username of the user claiming the greeting 61 | :type claiming_user_name: str 62 | :return: an ActionsBlock with the claimed button that allows a reset 63 | :rtype: ActionsBlock 64 | """ # noqa: E501, D401 65 | button_text = PlainTextObject(text=f"Greeted by {claiming_user_name}!") 66 | claimed_greet_button = ButtonElement( 67 | text=button_text, 68 | action_id="reset_greet_new_user_claim", 69 | style="danger", 70 | value=f"{claiming_user_name}", 71 | ) 72 | return ActionsBlock(block_id="reset_claim_action", elements=[claimed_greet_button]) 73 | -------------------------------------------------------------------------------- /modules/slack/blocks/mentorship_blocks.py: -------------------------------------------------------------------------------- 1 | import logging # noqa: D100 2 | 3 | from slack_sdk.models.blocks.basic_components import ( 4 | MarkdownTextObject, 5 | Option, 6 | PlainTextObject, 7 | ) 8 | from slack_sdk.models.blocks.block_elements import ( 9 | ButtonElement, 10 | PlainTextInputElement, 11 | StaticMultiSelectElement, 12 | StaticSelectElement, 13 | ) 14 | from slack_sdk.models.blocks.blocks import ActionsBlock, Block, DividerBlock, InputBlock, SectionBlock 15 | from slack_sdk.models.views import View 16 | 17 | from modules.airtable import message_text_table 18 | from modules.models.mentorship_models import ( 19 | MentorshipAffiliation, 20 | MentorshipService, 21 | MentorshipSkillset, 22 | ) 23 | from modules.slack.blocks import shared_blocks 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | def mentorship_request_view( # noqa: D103 29 | services: list[MentorshipService], 30 | skillsets: list[MentorshipSkillset], 31 | affiliations: list[MentorshipAffiliation], 32 | ) -> View: 33 | logger.info("STAGE: Building mentorship request form view...") 34 | return View( 35 | type="modal", 36 | callback_id="mentorship_request_form_submit", 37 | title=PlainTextObject(text="OC Mentor Request", emoji=True), 38 | submit=PlainTextObject(text="Submit Request", emoji=True), 39 | cancel=PlainTextObject(text="Cancel", emoji=True), 40 | external_id="mentorship_request_form_modal", 41 | blocks=mentorship_request_blocks(services, skillsets, affiliations), 42 | ) 43 | 44 | 45 | def mentorship_request_blocks( 46 | services: list[MentorshipService], 47 | skillsets: list[MentorshipSkillset], 48 | affiliations: list[MentorshipAffiliation], 49 | ) -> list[SectionBlock | DividerBlock | InputBlock]: 50 | """The blocks used for the mentorship request form. 51 | 52 | :param services: The list of mentorship services. 53 | :param skillsets: The list of mentorship skillsets. 54 | :param affiliations: The affiliations of the requestor. 55 | :return: The blocks used for the mentorship request form. 56 | """ # noqa: D401 57 | logger.info("STAGE: Building the mentorship request blocks...") 58 | messages = message_text_table.retrieve_valid_messages_by_view( 59 | "Valid Mentorship Requests", 60 | ) 61 | return [ 62 | request_view_main_text(messages["mentorship_request_main"].text), 63 | shared_blocks.generic_divider_block(block_id="mentorship_request_divider_1"), 64 | request_view_services_input( 65 | services, 66 | messages["mentorship_request_service_label"].text, 67 | messages["mentorship_request_service_placeholder"].text, 68 | ), 69 | request_view_skillsets_input( 70 | skillsets, 71 | messages["mentorship_request_skillset_label"].text, 72 | messages["mentorship_request_skillset_placeholder"].text, 73 | ), 74 | request_view_details_input( 75 | messages["mentorship_request_details_label"].text, 76 | messages["mentorship_request_details_placeholder"].text, 77 | ), 78 | shared_blocks.generic_divider_block(block_id="mentorship_request_divider_2"), 79 | request_view_affiliations_input( 80 | affiliations, 81 | messages["mentorship_request_affiliation_label"].text, 82 | messages["mentorship_request_affiliation_placeholder"].text, 83 | ), 84 | ] 85 | 86 | 87 | def request_view_main_text(main_text: str) -> SectionBlock: # noqa: D103 88 | logger.info("STAGE: Building mentorship request form main section block...") 89 | return SectionBlock( 90 | block_id="mentorship_request_main_text", 91 | text=MarkdownTextObject(text=main_text), 92 | ) 93 | 94 | 95 | def request_view_services_input( # noqa: D103 96 | services: list[MentorshipService], 97 | services_label: str, 98 | services_placeholder: str, 99 | ) -> InputBlock: 100 | logger.info("STAGE: Building mentorship request form services input block...") 101 | service_options = [Option(label=service.name, value=service.name) for service in services] 102 | input_element = StaticSelectElement( 103 | placeholder=PlainTextObject(text=services_placeholder, emoji=True), 104 | action_id="mentorship_service_selection", 105 | options=service_options, 106 | ) 107 | return InputBlock( 108 | block_id="mentorship_service_input", 109 | label=PlainTextObject(text=services_label, emoji=True), 110 | element=input_element, 111 | ) 112 | 113 | 114 | def request_view_skillsets_input( # noqa: D103 115 | skillsets: list[MentorshipSkillset], 116 | skillsets_label: str, 117 | skillsets_placeholder: str, 118 | ) -> InputBlock: 119 | logger.info("STAGE: Building mentorship request form skillsets input block...") 120 | service_options = [Option(label=skillset.name, value=skillset.name) for skillset in skillsets] 121 | input_element = StaticMultiSelectElement( 122 | placeholder=PlainTextObject(text=skillsets_placeholder, emoji=True), 123 | action_id="mentorship_skillset_multi_selection", 124 | options=service_options, 125 | ) 126 | return InputBlock( 127 | block_id="mentor_skillset_input", 128 | label=PlainTextObject(text=skillsets_label, emoji=True), 129 | element=input_element, 130 | ) 131 | 132 | 133 | def request_view_details_input( # noqa: D103 134 | details_label: str, 135 | details_placeholder: str, 136 | ) -> InputBlock: 137 | logger.info("STAGE: Building mentorship request form details input block...") 138 | input_element = PlainTextInputElement( 139 | action_id="details_text_input", 140 | multiline=True, 141 | min_length=10, 142 | placeholder=PlainTextObject(text=details_placeholder, emoji=True), 143 | ) 144 | return InputBlock( 145 | block_id="details_input_block", 146 | label=PlainTextObject(text=details_label, emoji=True), 147 | element=input_element, 148 | ) 149 | 150 | 151 | def request_view_affiliations_input( # noqa: D103 152 | affiliations: list[MentorshipAffiliation], 153 | affiliations_label: str, 154 | affiliations_placeholder: str, 155 | ) -> InputBlock: 156 | logger.info("STAGE: Building mentorship request form affiliations input block...") 157 | affiliation_options = [Option(label=affiliation.name, value=affiliation.name) for affiliation in affiliations] 158 | input_element = StaticSelectElement( 159 | placeholder=PlainTextObject(text=affiliations_placeholder, emoji=True), 160 | action_id="mentorship_affiliation_selection", 161 | options=affiliation_options, 162 | ) 163 | return InputBlock( 164 | block_id="mentorship_affiliation_input", 165 | label=PlainTextObject(text=affiliations_label, emoji=True), 166 | element=input_element, 167 | ) 168 | 169 | 170 | def request_successful_block() -> SectionBlock: # noqa: D103 171 | message_row = message_text_table.retrieve_valid_message_row( 172 | message_slug="mentorship_request_received_successfully", 173 | ) 174 | return SectionBlock( 175 | block_id="mentorship_request_received_successfully", 176 | text=MarkdownTextObject(text=message_row.text), 177 | ) 178 | 179 | 180 | def request_unsuccessful_block() -> SectionBlock: # noqa: D103 181 | message_row = message_text_table.retrieve_valid_message_row( 182 | message_slug="mentorship_request_unsuccessful", 183 | ) 184 | return SectionBlock( 185 | block_id="mentorship_request_unsuccessful", 186 | text=MarkdownTextObject(text=message_row.text), 187 | ) 188 | 189 | 190 | def request_claim_blocks( # noqa: D103 191 | requested_service: str, 192 | skillsets: list[str], 193 | affiliation: str, 194 | requesting_username: str, 195 | ) -> list[Block]: 196 | return [ 197 | request_claim_service_block(requesting_username, requested_service), 198 | request_claim_skillset_block(skillsets), 199 | request_claim_affiliation_block(affiliation), 200 | request_claim_button(), 201 | ] 202 | 203 | 204 | def request_claim_service_block( # noqa: D103 205 | requesting_username: str, 206 | requested_service: str, 207 | ) -> SectionBlock: 208 | message_row = message_text_table.retrieve_valid_message_row( 209 | message_slug="mentorship_request_claim_service_text", 210 | ) 211 | return SectionBlock( 212 | block_id="mentorship_request_service_text", 213 | text=MarkdownTextObject( 214 | text=message_row.text.format(requesting_username, requested_service), 215 | ), 216 | ) 217 | 218 | 219 | def request_claim_skillset_block(skillsets: list[str]) -> SectionBlock: # noqa: D103 220 | message_row = message_text_table.retrieve_valid_message_row( 221 | message_slug="mentorship_request_claim_skillset_text", 222 | ) 223 | return SectionBlock( 224 | block_id="mentorship_request_skillset_text", 225 | text=MarkdownTextObject(text=message_row.text.format(", ".join(skillsets))), 226 | ) 227 | 228 | 229 | def request_claim_affiliation_block(affiliation: str) -> SectionBlock: # noqa: D103 230 | message_row = message_text_table.retrieve_valid_message_row( 231 | message_slug="mentorship_request_claim_affiliation_text", 232 | ) 233 | return SectionBlock( 234 | block_id="mentorship_request_affiliation_text", 235 | text=MarkdownTextObject(text=message_row.text.format(affiliation)), 236 | ) 237 | 238 | 239 | def request_claim_button() -> ActionsBlock: # noqa: D103 240 | button_element = ButtonElement( 241 | text=PlainTextObject(text="Claim Mentorship Request", emoji=True), 242 | style="primary", 243 | action_id="claim_mentorship_request", 244 | ) 245 | return ActionsBlock(block_id="claim_button_action_block", elements=[button_element]) 246 | 247 | 248 | def request_claim_reset_button(claiming_username: str) -> ActionsBlock: # noqa: D103 249 | button_element = ButtonElement( 250 | text=PlainTextObject( 251 | text=f"Request Claimed By {claiming_username}", 252 | emoji=True, 253 | ), 254 | style="danger", 255 | action_id="reset_mentorship_request_claim", 256 | ) 257 | return ActionsBlock(block_id="claim_button_action_block", elements=[button_element]) 258 | 259 | 260 | def request_claim_details_block(details: str) -> SectionBlock: # noqa: D103 261 | message_row = message_text_table.retrieve_valid_message_row( 262 | message_slug="mentorship_request_claim_details_text", 263 | ) 264 | return SectionBlock( 265 | block_id="mentorship_request_details_text", 266 | text=MarkdownTextObject(text=message_row.text.format(details)), 267 | ) 268 | 269 | 270 | def request_claim_tagged_users_block(usernames: list[str]) -> SectionBlock: # noqa: D103 271 | message_row = message_text_table.retrieve_valid_message_row( 272 | message_slug="mentorship_request_claim_tagged_users", 273 | ) 274 | return SectionBlock( 275 | block_id="mentorship_request_tagged_users", 276 | text=MarkdownTextObject( 277 | text=message_row.text.format( 278 | " ".join([f"<@{username}>" for username in usernames]), 279 | ), 280 | ), 281 | ) 282 | -------------------------------------------------------------------------------- /modules/slack/blocks/new_join_blocks.py: -------------------------------------------------------------------------------- 1 | from slack_sdk.models.blocks import ( # noqa: D100 2 | Block, 3 | ButtonElement, 4 | MarkdownTextObject, 5 | PlainTextObject, 6 | SectionBlock, 7 | ) 8 | 9 | from modules.airtable import message_text_table 10 | 11 | 12 | def new_join_immediate_welcome_blocks(joining_username: str) -> list[Block]: # noqa: D103 13 | return [ 14 | new_join_immediate_welcome_first_text(joining_username), 15 | new_join_immediate_welcome_second_text(), 16 | new_join_immediate_welcome_third_text(), 17 | new_join_immediate_welcome_fourth_text(), 18 | new_join_immediate_welcome_oc_homepage_button(), 19 | new_join_immediate_welcome_slack_download_button(), 20 | new_join_immediate_welcome_oc_coc_button(), 21 | ] 22 | 23 | 24 | def new_join_delayed_welcome_blocks() -> list[Block]: # noqa: D103 25 | return [ 26 | new_join_delayed_welcome_first_text(), 27 | new_join_immediate_welcome_second_text(), 28 | ] 29 | 30 | 31 | def new_join_immediate_welcome_first_text(joining_username: str) -> SectionBlock: # noqa: D103 32 | message_row = message_text_table.retrieve_valid_message_row( 33 | message_slug="new_member_join_immediate_welcome_first_text", 34 | ) 35 | return SectionBlock( 36 | block_id="immediate_welcome_first_text", 37 | text=MarkdownTextObject(text=message_row.text.format(joining_username)), 38 | ) 39 | 40 | 41 | def new_join_immediate_welcome_second_text() -> SectionBlock: # noqa: D103 42 | message_row = message_text_table.retrieve_valid_message_row( 43 | message_slug="new_member_join_immediate_welcome_second_text", 44 | ) 45 | return SectionBlock( 46 | block_id="immediate_welcome_second_text", 47 | text=MarkdownTextObject(text=message_row.text), 48 | ) 49 | 50 | 51 | def new_join_immediate_welcome_third_text() -> SectionBlock: # noqa: D103 52 | message_row = message_text_table.retrieve_valid_message_row( 53 | message_slug="new_member_join_immediate_welcome_third_text", 54 | ) 55 | return SectionBlock( 56 | block_id="immediate_welcome_third_text", 57 | text=MarkdownTextObject(text=message_row.text), 58 | ) 59 | 60 | 61 | def new_join_immediate_welcome_fourth_text() -> SectionBlock: # noqa: D103 62 | message_row = message_text_table.retrieve_valid_message_row( 63 | message_slug="new_member_join_immediate_welcome_fourth_text", 64 | ) 65 | return SectionBlock( 66 | block_id="immediate_welcome_fourth_text", 67 | text=MarkdownTextObject(text=message_row.text), 68 | ) 69 | 70 | 71 | def new_join_immediate_welcome_oc_homepage_button() -> SectionBlock: # noqa: D103 72 | accessory = ButtonElement( 73 | text=PlainTextObject(text="OC Homepage", emoji=True), 74 | value="oc_home_page", 75 | url="https://operationcode.org/", 76 | action_id="oc_greeting_homepage_click", 77 | style="primary", 78 | ) 79 | return SectionBlock( 80 | block_id="oc_homepage_button", 81 | text=MarkdownTextObject(text="Operation Code Homepage"), 82 | accessory=accessory, 83 | ) 84 | 85 | 86 | def new_join_immediate_welcome_slack_download_button() -> SectionBlock: # noqa: D103 87 | accessory = ButtonElement( 88 | text=PlainTextObject(text="Slack Download", emoji=True), 89 | value="slack_download", 90 | url="https://slack.com/downloads/", 91 | action_id="oc_greeting_slack_download_click", 92 | style="primary", 93 | ) 94 | return SectionBlock( 95 | block_id="slack_download_button", 96 | text=MarkdownTextObject(text="Slack Download"), 97 | accessory=accessory, 98 | ) 99 | 100 | 101 | def new_join_immediate_welcome_oc_coc_button() -> SectionBlock: # noqa: D103 102 | accessory = ButtonElement( 103 | text=PlainTextObject(text="Operation Code CoC", emoji=True), 104 | value="operation_code_coc", 105 | url="https://github.com/OperationCode/community/blob/master/code_of_conduct.md", 106 | action_id="oc_greeting_coc_click", 107 | style="primary", 108 | ) 109 | return SectionBlock( 110 | block_id="oc_coc_button", 111 | text=MarkdownTextObject(text="Operation Code CoC"), 112 | accessory=accessory, 113 | ) 114 | 115 | 116 | def new_join_delayed_welcome_first_text() -> SectionBlock: # noqa: D103 117 | message_row = message_text_table.retrieve_valid_message_row( 118 | message_slug="new_member_join_delayed_welcome_first_text", 119 | ) 120 | return SectionBlock( 121 | block_id="delayed_welcome_first_text", 122 | text=MarkdownTextObject(text=message_row.text), 123 | ) 124 | 125 | 126 | def new_join_delayed_welcome_second_text() -> SectionBlock: # noqa: D103 127 | message_row = message_text_table.retrieve_valid_message_row( 128 | message_slug="new_member_join_delayed_welcome_second_text", 129 | ) 130 | return SectionBlock( 131 | block_id="delayed_welcome_second_text", 132 | text=MarkdownTextObject(text=message_row.text), 133 | ) 134 | -------------------------------------------------------------------------------- /modules/slack/blocks/report_blocks.py: -------------------------------------------------------------------------------- 1 | from slack_sdk.models.blocks.basic_components import MarkdownTextObject, PlainTextObject # noqa: D100 2 | from slack_sdk.models.blocks.block_elements import ButtonElement, PlainTextInputElement 3 | from slack_sdk.models.blocks.blocks import ( 4 | ActionsBlock, 5 | HeaderBlock, 6 | InputBlock, 7 | SectionBlock, 8 | ) 9 | from slack_sdk.models.views import View 10 | 11 | from modules.airtable import message_text_table 12 | 13 | 14 | def report_form_view_elements() -> View: # noqa: D103 15 | title_text = PlainTextObject(text="OC Slack - Report", emoji=True) 16 | close_button_text = PlainTextObject(text="Cancel") 17 | submit_button_text = PlainTextObject(text="Submit Report") 18 | return View( 19 | type="modal", 20 | callback_id="report_form_submit", 21 | title=title_text, 22 | close=close_button_text, 23 | submit=submit_button_text, 24 | blocks=report_form_modal_blocks(), 25 | external_id="report_form_modal", 26 | ) 27 | 28 | 29 | def report_form_modal_blocks() -> list[SectionBlock | InputBlock]: 30 | """Return the blocks for the report form modal. 31 | 32 | :return: The blocks for the report form modal. 33 | """ 34 | return [report_form_title_block(), report_form_input_block()] 35 | 36 | 37 | def report_form_title_block() -> SectionBlock: # noqa: D103 38 | text = MarkdownTextObject( 39 | text=":warning: Thank you for taking the time to report an issue to the moderation team. Please fill out the below input field with the text of the message you'd like to report. If you'd like, you can include a short description of why you are reporting it. The report will only be shown to the moderators of the OC Slack workspace.:warning:", # noqa: E501 40 | ) 41 | return SectionBlock(block_id="report_title_block", text=text) 42 | 43 | 44 | def report_form_input_block() -> InputBlock: # noqa: D103 45 | input_placeholder = PlainTextObject( 46 | text="You can copy and paste the text of the message you'd like to report or tell us a bit about what you are reporting...", # noqa: E501 47 | emoji=True, 48 | ) 49 | input_label = PlainTextObject( 50 | text="Text of message you are reporting or reason for your report*", 51 | emoji=True, 52 | ) 53 | text_input = PlainTextInputElement( 54 | action_id="report_input_field", 55 | placeholder=input_placeholder, 56 | focus_on_load=True, 57 | multiline=True, 58 | min_length=2, 59 | ) 60 | return InputBlock(block_id="report_input", element=text_input, label=input_label) 61 | 62 | 63 | def report_claim_blocks( 64 | reporting_user_name: str, 65 | report_details: str, 66 | ) -> list[SectionBlock | HeaderBlock | ButtonElement]: 67 | """The blocks used for the report claim form. 68 | 69 | :param reporting_user_name: The username of the user who submitted the report. 70 | :param report_details: The details of the report. 71 | :return: The blocks for the report claim form. 72 | """ # noqa: D401 73 | return [ 74 | report_claim_title_section(reporting_user_name), 75 | report_claim_details_header(), 76 | report_claim_details(report_details), 77 | report_claim_button(), 78 | ] 79 | 80 | 81 | def report_claim_title_section(username: str) -> SectionBlock: # noqa: D103 82 | text = MarkdownTextObject( 83 | text=f":warning: <@{username}> has submitted a report. :warning:", 84 | ) 85 | return SectionBlock(text=text, block_id="report_claim_title") 86 | 87 | 88 | def report_claim_details_header() -> HeaderBlock: # noqa: D103 89 | text = PlainTextObject(text="Report details:", emoji=True) 90 | return HeaderBlock(block_id="report_claim_header", text=text) 91 | 92 | 93 | def report_claim_details(report_details: str) -> SectionBlock: # noqa: D103 94 | text = MarkdownTextObject(text=f"{report_details}") 95 | return SectionBlock(text=text, block_id="report_claim_details") 96 | 97 | 98 | def report_claim_button() -> ActionsBlock: # noqa: D103 99 | button_text = PlainTextObject(text="I Will Reach Out to Them") 100 | button_element = ButtonElement( 101 | text=button_text, 102 | style="primary", 103 | action_id="report_claim", 104 | ) 105 | return ActionsBlock(block_id="report_claim_button", elements=[button_element]) 106 | 107 | 108 | def report_claim_claimed_button(claiming_username: str) -> ActionsBlock: # noqa: D103 109 | button_text = PlainTextObject(text=f"Claimed by {claiming_username}!") 110 | button_element = ButtonElement( 111 | text=button_text, 112 | style="danger", 113 | action_id="reset_report_claim", 114 | ) 115 | return ActionsBlock(block_id="report_claim_button", elements=[button_element]) 116 | 117 | 118 | def report_received_ephemeral_message() -> SectionBlock: # noqa: D103 119 | message_row = message_text_table.retrieve_valid_message_row( 120 | message_slug="report_received", 121 | ) 122 | text = MarkdownTextObject(text=message_row.text) 123 | return SectionBlock(block_id="report_received", text=text) 124 | 125 | 126 | def report_failed_ephemeral_message() -> SectionBlock: # noqa: D103 127 | message_row = message_text_table.retrieve_valid_message_row( 128 | message_slug="report_not_received", 129 | ) 130 | text = MarkdownTextObject(text=message_row.text) 131 | return SectionBlock(block_id="report_not_received", text=text) 132 | -------------------------------------------------------------------------------- /modules/slack/blocks/shared_blocks.py: -------------------------------------------------------------------------------- 1 | from slack_sdk.models.blocks import ( # noqa: D100 2 | ActionsBlock, 3 | Block, 4 | ButtonElement, 5 | DividerBlock, 6 | MarkdownTextObject, 7 | PlainTextObject, 8 | SectionBlock, 9 | ) 10 | 11 | from modules.airtable import message_text_table 12 | 13 | 14 | def generic_divider_block(block_id: str) -> DividerBlock: # noqa: D103 15 | return DividerBlock(block_id=block_id) 16 | 17 | 18 | def channel_join_request_successful_block(channel_name: str) -> SectionBlock: # noqa: D103 19 | message_row = message_text_table.retrieve_valid_message_row( 20 | message_slug="channel_join_request_successful", 21 | ) 22 | return SectionBlock( 23 | block_id="channel_join_request_successful_block", 24 | text=MarkdownTextObject(text=message_row.text.format(channel_name)), 25 | ) 26 | 27 | 28 | def channel_join_request_unsuccessful_block() -> SectionBlock: # noqa: D103 29 | message_row = message_text_table.retrieve_valid_message_row( 30 | message_slug="channel_join_request_unsuccessful", 31 | ) 32 | return SectionBlock( 33 | block_id="channel_join_request_unsuccessful_block", 34 | text=MarkdownTextObject(text=message_row.text), 35 | ) 36 | 37 | 38 | def channel_join_request_blocks(requesting_username: str) -> list[Block]: # noqa: D103 39 | return [ 40 | channel_join_request_main(requesting_username), 41 | channel_join_request_action(), 42 | ] 43 | 44 | 45 | def channel_join_request_main(requesting_username: str) -> SectionBlock: # noqa: D103 46 | message_row = message_text_table.retrieve_valid_message_row( 47 | message_slug="channel_join_request_main_text", 48 | ) 49 | return SectionBlock( 50 | block_id="request_main", 51 | text=MarkdownTextObject(text=message_row.text.format(requesting_username)), 52 | ) 53 | 54 | 55 | def channel_join_request_action() -> ActionsBlock: # noqa: D103 56 | button_element = ButtonElement( 57 | text=PlainTextObject(text="I'll Invite Them!", emoji=True), 58 | style="primary", 59 | action_id="invite_to_channel_click", 60 | ) 61 | return ActionsBlock(block_id="channel_invite_action", elements=[button_element]) 62 | 63 | 64 | def channel_join_request_reset_action(claiming_username: str) -> ActionsBlock: # noqa: D103 65 | button_text = PlainTextObject(text=f"Invited by {claiming_username}!") 66 | button_element = ButtonElement( 67 | text=button_text, 68 | style="danger", 69 | action_id="reset_channel_invite", 70 | ) 71 | return ActionsBlock( 72 | block_id="reset_channel_invite_action", 73 | elements=[button_element], 74 | ) 75 | -------------------------------------------------------------------------------- /modules/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utility functions for the Slack app.""" 2 | import logging 3 | import os 4 | from datetime import datetime, timezone 5 | from pathlib import Path 6 | from re import sub 7 | 8 | from dotenv import load_dotenv 9 | from pyairtable import Table 10 | from slack_bolt.app import App 11 | from slack_sdk.models.blocks import MarkdownTextObject, SectionBlock 12 | from slack_sdk.web.async_client import AsyncWebClient 13 | 14 | from modules.models.greeting_models import UserInfo 15 | from modules.models.slack_models.shared_models import ( 16 | SlackConversationInfo, 17 | SlackTeam, 18 | SlackTeamInfo, 19 | ) 20 | 21 | logger = logging.getLogger(__name__) 22 | load_dotenv(dotenv_path=str(Path(__file__).parent.parent.parent) + "/.env") 23 | 24 | 25 | def snake_case(string_to_snakecase: str) -> str: 26 | """Snake case a string using regex. 27 | 28 | from https://www.w3resource.com/python-exercises/string/python-data-type-string-exercise-97.php. 29 | 30 | :param string_to_snakecase: The string to be snake-cased. 31 | :return: The snake-cased string. 32 | """ 33 | return "_".join( 34 | sub( 35 | "([A-Z][a-z]+)", 36 | r" \1", 37 | sub("([A-Z]+)", r" \1", string_to_snakecase.replace("-", " ")), 38 | ).split(), 39 | ).lower() 40 | 41 | 42 | def get_team_info() -> SlackTeam: 43 | """Get the team information from Slack. 44 | 45 | Uses a new synchronous Slack app to retrieve the details, so this can happen before the async app is initialized. 46 | 47 | :return: The SlackTeam object. 48 | """ 49 | logger.info("STAGE: Retrieving team information...") 50 | try: 51 | synchronous_app = App( 52 | token=os.environ.get("SLACK_BOT_TOKEN"), 53 | signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), 54 | ) 55 | team_info = synchronous_app.client.team_info() 56 | conversations = synchronous_app.client.conversations_list( 57 | exclude_archived=True, 58 | types=["public_channel", "private_channel"], 59 | limit=1000, 60 | ) 61 | return SlackTeam( 62 | SlackTeamInfo( 63 | id=team_info["team"]["id"], 64 | name=team_info["team"]["name"], 65 | conversations=[ 66 | SlackConversationInfo(**conversation) for conversation in conversations.data["channels"] 67 | ], 68 | ), 69 | ) 70 | except Exception: 71 | logger.exception("Failed to retrieve team information.") 72 | raise 73 | finally: 74 | del synchronous_app 75 | 76 | 77 | async def get_slack_user_from_email(client: AsyncWebClient, email: str) -> UserInfo: 78 | """Retrieve a Slack user from Slack using email. 79 | 80 | :param client: The Slack client. 81 | :param email: The email address of the user. 82 | :return: The UserInfo object. 83 | """ 84 | slack_user = await client.users_lookupByEmail(email=email) 85 | return UserInfo( 86 | **slack_user.data["user"], 87 | email=slack_user.data["user"]["profile"]["email"], 88 | ) 89 | 90 | 91 | async def get_slack_user_by_id(client: AsyncWebClient, user_id: str) -> UserInfo: 92 | """Retrieve a Slack user from Slack using the Slack user ID. 93 | 94 | :param client: The Slack client. 95 | :param user_id: The Slack user ID. 96 | :return: The UserInfo object. 97 | """ 98 | slack_user = await client.users_info(user=user_id) 99 | return UserInfo( 100 | **slack_user.data["user"], 101 | email=slack_user.data["user"]["profile"]["email"], 102 | ) 103 | 104 | 105 | async def log_to_thread( # noqa: PLR0913 - too many arguments 106 | client: AsyncWebClient, 107 | channel_id: str, 108 | message_ts: str, 109 | username: str, 110 | action_ts: str, 111 | claim: bool, # noqa: FBT001 112 | ) -> None: 113 | """Log a claim or reset to a thread. 114 | 115 | :param client: The Slack client. 116 | :param channel_id: The channel ID of the message. 117 | :param message_ts: The timestamp of the message. 118 | :param username: The username of the user performing the action. 119 | :param action_ts: The timestamp of the action. 120 | :param claim: Whether it's a claim or reset action. 121 | """ 122 | await client.chat_postMessage( 123 | channel=channel_id, 124 | thread_ts=message_ts, 125 | text="Logging to greeting thread...", 126 | blocks=[threaded_action_logging(username, action_ts, claim)], 127 | ) 128 | 129 | 130 | def threaded_action_logging(username: str, timestamp: str, claim: bool) -> SectionBlock: # noqa: FBT001 131 | """Return a block that is used to log a claim or reset to a thread. 132 | 133 | :param username: username of the user performing the action 134 | :type username: str 135 | :param timestamp: string timestamp of the action in Unix Epoch Time 136 | :type timestamp: str 137 | :param claim: whether it's a claim action or not 138 | :type claim: bool 139 | :return: a section block to be threaded on the original message 140 | :rtype: SectionBlock 141 | """ 142 | if claim: 143 | text = MarkdownTextObject( 144 | text=f"Claimed by {username} at " 145 | f"{datetime.fromtimestamp(float(timestamp), tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC!", 146 | ) 147 | else: 148 | text = MarkdownTextObject( 149 | text=f"Reset by {username} at " 150 | f"{datetime.fromtimestamp(float(timestamp), tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC!", 151 | ) 152 | return SectionBlock(block_id="greeting_log_reply", text=text) 153 | 154 | 155 | def table_fields(table: Table) -> list[str]: 156 | """Return snake cased columns (fields in Airtable parlance) on the table. 157 | 158 | Because we don't have access to the Airtable metadata API, we must set up a view on every table with every column 159 | filled in since as the Airtable API says - "Returned records do not include any fields with "empty" 160 | values, e.g. "", [], or false.". 161 | 162 | :return: List of fields on the table. 163 | """ 164 | try: 165 | first_record = table.first(view="Fields") 166 | return [snake_case(field) for field in first_record["fields"]] 167 | except Exception: 168 | logger.exception("Unable to retrieve fields from Airtable.") 169 | raise 170 | 171 | 172 | slack_team = get_team_info() 173 | -------------------------------------------------------------------------------- /modules/utils/daily_programmer_scheduler.py: -------------------------------------------------------------------------------- 1 | """Module containing the daily programmer scheduler task.""" 2 | import logging 3 | 4 | from slack_bolt.async_app import AsyncApp 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | async def post_daily_programmer_message(async_app: AsyncApp) -> None: # noqa: ARG001 - temporary 10 | """Post the daily programmer message to the #daily-programmer channel. 11 | 12 | :param async_app: The Slack Bolt async application. 13 | """ 14 | logger.info("STAGE: Beginning task post_daily_programmer_message...") 15 | -------------------------------------------------------------------------------- /modules/utils/example_requests/mentorship_request_claim_action.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "block_actions", 3 | "user": { 4 | "id": "U01RN31JSTD", 5 | "username": "judson.stevens", 6 | "name": "judson.stevens", 7 | "team_id": "T01SBLCQ57A" 8 | }, 9 | "api_app_id": "A02R6C6S9JN", 10 | "token": "ZdW4MAeWALbwTKtzdfhyvrGW", 11 | "container": { 12 | "type": "message", 13 | "message_ts": "1640986475.006500", 14 | "channel_id": "C01R77KM8H5", 15 | "is_ephemeral": false, 16 | "thread_ts": "1640986475.006500" 17 | }, 18 | "trigger_id": "2921169898672.1895692821248.8f353dfbf0be6d23ed09354b247e0b89", 19 | "team": { 20 | "id": "T01SBLCQ57A", 21 | "domain": "bot-testing-field" 22 | }, 23 | "enterprise": "None", 24 | "is_enterprise_install": false, 25 | "channel": { 26 | "id": "C01R77KM8H5", 27 | "name": "mentors-internal" 28 | }, 29 | "message": { 30 | "bot_id": "B02QRQ4KU5V", 31 | "type": "message", 32 | "text": "New mentorship request received...", 33 | "user": "U02RK2AL5LZ", 34 | "ts": "1640986475.006500", 35 | "team": "T01SBLCQ57A", 36 | "blocks": [ 37 | { 38 | "type": "section", 39 | "block_id": "mentorship_request_service_text", 40 | "text": { 41 | "type": "mrkdwn", 42 | "text": "User <@U01RN31JSTD> has requested a mentor for General Guidance.", 43 | "verbatim": false 44 | } 45 | }, 46 | { 47 | "type": "section", 48 | "block_id": "mentorship_request_skillset_text", 49 | "text": { 50 | "type": "mrkdwn", 51 | "text": "*Requested Skillset(s):* Architecture, Career Advice, Cloud / AWS / GCP / Azure", 52 | "verbatim": false 53 | } 54 | }, 55 | { 56 | "type": "section", 57 | "block_id": "mentorship_request_affiliation_text", 58 | "text": { 59 | "type": "mrkdwn", 60 | "text": "*Requestor Affiliation:* US Military Spouse", 61 | "verbatim": false 62 | } 63 | }, 64 | { 65 | "type": "actions", 66 | "block_id": "claim_button_action_block", 67 | "elements": [ 68 | { 69 | "type": "button", 70 | "action_id": "claim_mentorship_request", 71 | "text": { 72 | "type": "plain_text", 73 | "text": "Claim Mentorship Request", 74 | "emoji": true 75 | }, 76 | "style": "primary" 77 | } 78 | ] 79 | } 80 | ], 81 | "thread_ts": "1640986475.006500", 82 | "reply_count": 2, 83 | "reply_users_count": 1, 84 | "latest_reply": "1640986477.006800", 85 | "reply_users": [ 86 | "U02RK2AL5LZ" 87 | ], 88 | "is_locked": false, 89 | "subscribed": true, 90 | "last_read": "1640986477.006800" 91 | }, 92 | "state": { 93 | "values": {} 94 | }, 95 | "response_url": "https://hooks.slack.com/actions/T01SBLCQ57A/2899731511204/xb8gxI24ldtCaVwbdsddM0nb", 96 | "actions": [ 97 | { 98 | "action_id": "claim_mentorship_request", 99 | "block_id": "claim_button_action_block", 100 | "text": { 101 | "type": "plain_text", 102 | "text": "Claim Mentorship Request", 103 | "emoji": true 104 | }, 105 | "style": "primary", 106 | "type": "button", 107 | "action_ts": "1640986929.354736" 108 | } 109 | ] 110 | } 111 | -------------------------------------------------------------------------------- /modules/utils/example_requests/pride_request_command.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "ZdW4MAeWALbwTKtzdfhyvrGW", 3 | "team_id": "T01SBLCQ57A", 4 | "team_domain": "bot-testing-field", 5 | "channel_id": "D02R6CR6DMG", 6 | "channel_name": "directmessage", 7 | "user_id": "U01RN31JSTD", 8 | "user_name": "judson.stevens", 9 | "command": "/pride", 10 | "api_app_id": "A02R6C6S9JN", 11 | "is_enterprise_install": "false", 12 | "response_url": "https://hooks.slack.com/commands/T01SBLCQ57A/2897652965298/c11hovXK7EMpPtnWOcRvFE4m", 13 | "trigger_id": "2921387427888.1895692821248.08d2864bfc4f49d666e25ead52fb95ca" 14 | } -------------------------------------------------------------------------------- /modules/utils/message_scheduler.py: -------------------------------------------------------------------------------- 1 | """Module for scheduling messages in a background job.""" 2 | import logging 3 | from datetime import datetime, timedelta, timezone 4 | 5 | from slack_bolt.async_app import AsyncApp 6 | from starlette import status 7 | 8 | from modules.airtable import scheduled_message_table 9 | from modules.slack.blocks.announcement_blocks import general_announcement_blocks 10 | from modules.utils import slack_team 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | TOTAL_MONTHS_IN_YEAR = 12 15 | 16 | 17 | async def schedule_messages(async_app: AsyncApp) -> None: 18 | """Schedule messages to be sent to various channels. 19 | 20 | Pulls the messages from the Airtable table Scheduled Messages 21 | As explained in the comments below, will schedule messages using the `when_to-send` field on the table, 22 | which is calculated by Airtable based on the frequency and `scheduled_next` datetime. 23 | 24 | :param async_app: the Slack Bolt async application. 25 | """ 26 | logger.info("STAGE: Beginning task schedule_messages...") 27 | messages = scheduled_message_table.all_valid_scheduled_messages 28 | logger.debug( 29 | "Retrieved valid messages to be potentially be scheduled", 30 | extra={"number_of_message": len(messages)}, 31 | ) 32 | for message in messages: 33 | # If we had scheduled this message to be sent at a time in the past, proceed 34 | if message.scheduled_next < datetime.now(tz=timezone.utc): 35 | logger.debug("Scheduling message", extra={"message_name": message.name}) 36 | # If when to send is in the past as well, that means we should send it immediately 37 | if message.when_to_send < datetime.now(tz=timezone.utc): 38 | logger.debug("Scheduling message to be sent immediately", extra={"message_name": message.name}) 39 | send_message_timestamp = int(datetime.now(timezone.utc).timestamp()) + 240 40 | if message.frequency == "daily": 41 | new_scheduled_next = datetime.now(timezone.utc) + timedelta(days=1) 42 | elif message.frequency == "weekly": 43 | new_scheduled_next = datetime.now(timezone.utc) + timedelta(days=7) 44 | else: 45 | when_to_send_month = ( 46 | message.when_to_send.month + 1 if message.when_to_send.month < TOTAL_MONTHS_IN_YEAR else 1 47 | ) 48 | when_to_send_year = ( 49 | message.when_to_send.year + 1 50 | if message.when_to_send.month == TOTAL_MONTHS_IN_YEAR 51 | else message.when_to_send.year 52 | ) 53 | # Should find the next Monday in the month - will have to increase the variability in frequency 54 | # to post theses on different days 55 | next_month = datetime(when_to_send_year, when_to_send_month, 7, tzinfo=timezone.utc) 56 | offset = -next_month.weekday() 57 | new_scheduled_next = next_month + timedelta(days=offset) 58 | # Otherwise, we send it out normally using the when_to_send field 59 | else: 60 | send_message_timestamp = int(message.when_to_send.timestamp()) 61 | new_scheduled_next = message.when_to_send 62 | 63 | channel_to_send_to = slack_team.find_channel_by_name(message.channel) 64 | 65 | response = await async_app.client.chat_scheduleMessage( 66 | channel=channel_to_send_to.id, 67 | post_at=send_message_timestamp, 68 | text=f"Announcement in {message.channel}...", 69 | blocks=general_announcement_blocks(message.name, message.message_text), 70 | ) 71 | if response.status_code == status.HTTP_200_OK: 72 | logger.debug( 73 | "Updating the Airtable table for row with new scheduled next time", 74 | extra={ 75 | "table_name": scheduled_message_table.table_name, 76 | "airtable_id": message.airtable_id, 77 | "new_scheduled_next": new_scheduled_next, 78 | }, 79 | ) 80 | scheduled_message_table.update_record( 81 | message.airtable_id, 82 | { 83 | "Scheduled Next": str(new_scheduled_next), 84 | }, 85 | ) 86 | else: 87 | logger.warning( 88 | "Issue sending the scheduled message, scheduling failed with Slack response", 89 | extra={"response": response.__dict__, "message_name": message.name}, 90 | ) 91 | -------------------------------------------------------------------------------- /modules/utils/one_off_scripts.py: -------------------------------------------------------------------------------- 1 | """One of scripts for various purposes.""" 2 | import json 3 | import os 4 | import re 5 | import sys 6 | from pathlib import Path 7 | 8 | from dotenv import load_dotenv 9 | 10 | from modules.airtable import daily_programmer_table 11 | 12 | load_dotenv() 13 | 14 | 15 | def main(script_to_run: str) -> None: 16 | """Run the script. 17 | 18 | :param script_to_run: Name of the script to run. 19 | """ 20 | if script_to_run == "process_daily_programmer_files": 21 | process_daily_programmer_files(sys.argv[2]) 22 | 23 | 24 | def process_daily_programmer_files(files_directory: str) -> None: 25 | """Process the daily programmer files. 26 | 27 | Used to load the daily programmer files into the database. 28 | 29 | :param files_directory: The directory containing the files to process. 30 | """ 31 | for filename in os.listdir(files_directory): 32 | with Path.open(Path(files_directory + "/" + filename)) as file: 33 | message_list = json.load(file) 34 | for message in message_list: 35 | if message["text"]: 36 | print(f"Parsing a new message in file: {filename}") # noqa: T201 - we use print in scripts 37 | title = re.search(r"(={2,3}.*={2,3})", message["text"]) 38 | if title: 39 | name = re.search(r"(\[.*?])", message["text"]) 40 | if name: 41 | try: 42 | daily_programmer_table.create_record( 43 | { 44 | "Name": name[0].replace("[", "").replace("]", "").replace("*", ""), 45 | "Text": message["text"][name.span()[1] + 1 :], 46 | "Initially Posted On": filename.split(".")[0], 47 | "Last Posted On": filename.split(".")[0], 48 | "Posted Count": 1, 49 | "Initial Slack TS": message["ts"], 50 | "Blocks": message["blocks"], 51 | }, 52 | ) 53 | except KeyError: 54 | daily_programmer_table.create_record( 55 | { 56 | "Name": name[0].replace("[", "").replace("]", ""), 57 | "Text": message["text"][name.span()[1] + 1 :], 58 | "Initially Posted On": filename.split(".")[0], 59 | "Last Posted On": filename.split(".")[0], 60 | "Posted Count": 1, 61 | "Initial Slack TS": message["ts"], 62 | }, 63 | ) 64 | 65 | 66 | if __name__ == "__main__": 67 | main(sys.argv[1]) 68 | -------------------------------------------------------------------------------- /modules/utils/vector_delete_data.py: -------------------------------------------------------------------------------- 1 | """Configuration for a vector database connection.""" 2 | import os 3 | 4 | import weaviate 5 | from dotenv import load_dotenv 6 | 7 | load_dotenv() 8 | 9 | # Weaviate configuration 10 | auth_config = weaviate.AuthApiKey(api_key=os.getenv("WEAVIATE_API_KEY", "")) 11 | 12 | # Create a weaviate client 13 | weaviate_client = weaviate.Client(os.getenv("WEAVIATE_URL", "http://localhost:8015"), auth_client_secret=auth_config) 14 | 15 | 16 | weaviate_client.batch.delete_objects("TextChunk", where={"operator": "Like", "valueText": "*", "path": ["text"]}) 17 | -------------------------------------------------------------------------------- /modules/utils/vector_ingestion.py: -------------------------------------------------------------------------------- 1 | """Utilities to assist with ingesting documents and turning them into vectors.""" 2 | import datetime 3 | import os 4 | import uuid 5 | 6 | import weaviate 7 | from dotenv import load_dotenv 8 | from transformers import AutoTokenizer 9 | from unstructured.cleaners.core import clean 10 | from unstructured.documents.elements import Text 11 | from unstructured.partition.pdf import partition_pdf 12 | from unstructured.staging.huggingface import stage_for_transformers 13 | 14 | load_dotenv() 15 | 16 | # Weaviate configuration 17 | auth_config = weaviate.AuthApiKey(api_key=os.getenv("WEAVIATE_API_KEY", "")) 18 | 19 | # Create a weaviate client 20 | weaviate_client = weaviate.Client(os.getenv("WEAVIATE_URL", "http://localhost:8015"), auth_client_secret=auth_config) 21 | 22 | 23 | def main() -> None: 24 | weaviate_client.batch.configure(batch_size=300) 25 | try: 26 | model_name = "sentence-transformers/all-mpnet-base-v2" 27 | tokenizer = AutoTokenizer.from_pretrained(model_name) 28 | elements = partition_pdf( 29 | "/Users/judson/Projects/OperationCode/operationcode-pybot/data/VA-Documents/2020-CFR-Title38-Vol-1.pdf", 30 | strategy="ocr_only", 31 | ) 32 | staged_elements = stage_for_transformers( 33 | [element for element in elements if isinstance(element, Text)], 34 | tokenizer, 35 | ) 36 | no_items_in_batch = 0 37 | batch_size = 300 38 | for idx, element in enumerate(staged_elements): 39 | if isinstance(element, Text): 40 | clean_text = clean( 41 | element.text, 42 | bullets=True, 43 | extra_whitespace=True, 44 | ) 45 | text_chunk = { 46 | "text": clean_text, 47 | "cfr_title_number": 38, 48 | "ingestion_date": str(datetime.datetime.now(tz=datetime.timezone.utc).isoformat()), 49 | "index_number": idx, 50 | "unique_id": str(uuid.uuid4()), 51 | } 52 | print("Adding object to batch. On number: " + str(idx)) 53 | weaviate_client.batch.add_data_object(text_chunk, "TextChunk") 54 | no_items_in_batch += 1 55 | if no_items_in_batch >= batch_size: 56 | print("Sending batch...") 57 | print("Currently at count: ", idx) 58 | weaviate_client.batch.create_objects() 59 | no_items_in_batch = 0 60 | except Exception as e: 61 | print(e) 62 | 63 | 64 | if __name__ == "__main__": 65 | main() 66 | -------------------------------------------------------------------------------- /modules/utils/vector_search.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import weaviate 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | print("Starting weaviate client...") 9 | weaviate_client = weaviate.Client(os.getenv("WEAVIATE_URL", "http://localhost:8015")) 10 | 11 | print("Retrieve results...") 12 | results = ( 13 | weaviate_client.query.get( 14 | class_name="TextChunk", properties=["text", "ingestion_date", "index_number", "unique_id"], 15 | ) 16 | .with_limit(10) 17 | .with_near_text( 18 | { 19 | "concepts": [ 20 | "the VET TEC (Veteran Employment Through Technology Education Courses) program is a VA (Department of Veterans Affairs) initiative that provides funding for eligible veterans to receive training in technology-related fields. Under the VET TEC program, there is no specific maximum amount allowed for a single veteran. However, the program covers the cost of tuition and fees for eligible veterans, up to a maximum of $10,000. It's important to note that the funding provided is for the training program itself and does not cover additional expenses such as housing or books.", 21 | ], 22 | }, 23 | ) 24 | .do() 25 | ) 26 | 27 | results = results.get("data", {}).get("Get").get("TextChunk", {}) 28 | print(f"Found {len(results)} results:") 29 | for i, article in enumerate(results): 30 | print(f"\t{i}. {article['text']}\n\n") 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "operationcode-pybot" 3 | version = "0.9.0" 4 | description = "Slack bot for Operation Code Slack." 5 | authors = ["Judson Stevens "] 6 | 7 | [tool.poetry.dependencies] 8 | aiohttp = "~3" 9 | APScheduler = "~3" 10 | fastapi = "~0" 11 | pydantic = "~2" 12 | pyairtable = "~1" 13 | pytesseract = "~0" 14 | python = "~3.10" 15 | requests = "~2" 16 | sentence-transformers = "~2" 17 | sentry-sdk = "~1" 18 | slack-bolt = "~1" 19 | tensorboard = "~2" 20 | unstructured = "~0" 21 | uvicorn = {extras = ["standard"], version = "~0"} 22 | weaviate-client = "~3" 23 | pdf2image = "^1.16.3" 24 | pypdf = "^3.8.1" 25 | 26 | 27 | [tool.poetry.group.dev.dependencies] 28 | black = "~23" 29 | mypy = "~1" 30 | pyaml = "~21" 31 | pylint = "~2" 32 | pytest = "~7" 33 | pytest-vcr = "~1" 34 | ruff = "~0" 35 | 36 | [build-system] 37 | requires = ["poetry>=1.3"] 38 | build-backend = "poetry.masonry.api" 39 | 40 | [tool.ruff] 41 | # All rules to be enabled - https://beta.ruff.rs/docs/rules/ 42 | select = ["E", "F", "W", "B", "C90", "I", "N", "D", "UP", "YTT", "ANN", "S", "BLE", "FBT", 43 | "B", "A", "COM", "C4", "DTZ", "T10", "DJ", "EM", "EXE", "ISC", "ICN", "G", "INP", "PIE", 44 | "T20", "PYI", "PT", "Q", "RSE", "RET", "SLF", "SIM", "TID", "TCH", "ARG", "PTH", "ERA", 45 | "PD", "PGH", "PL", "TRY", "RUF"] 46 | ignore = [ 47 | "B008", # Ignore B008 because it complains about how FastAPI handles Depends 48 | "D213", # We want the multi-line summary on the first line 49 | "D203", # We want no blank line before class docstring 50 | ] 51 | 52 | # Allow autofix for all enabled rules (when `--fix`) is provided. 53 | fixable = ["A", "B", "C", "D", "E", "F", "I", "COM812"] 54 | unfixable = [] 55 | 56 | # Exclude a variety of commonly ignored directories. 57 | exclude = [ 58 | ".bzr", 59 | ".direnv", 60 | ".eggs", 61 | ".git", 62 | ".hg", 63 | ".mypy_cache", 64 | ".nox", 65 | ".pants.d", 66 | ".pytype", 67 | ".ruff_cache", 68 | ".svn", 69 | ".tox", 70 | ".venv", 71 | "__pypackages__", 72 | "_build", 73 | "buck-out", 74 | "build", 75 | "dist", 76 | "node_modules", 77 | "venv", 78 | # Exclude the one off script file 79 | "one_offs.py", 80 | # Exclude the vector search testing 81 | "**/vector*", 82 | # Exclude the databases folder 83 | "**/databases/**", 84 | ] 85 | 86 | # Same as Black. 87 | line-length = 119 88 | 89 | # Allow unused variables when underscore-prefixed. 90 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 91 | 92 | # Python 3.10. 93 | target-version = "py310" 94 | 95 | [tool.ruff.mccabe] 96 | max-complexity = 10 97 | 98 | [tool.ruff.per-file-ignores] 99 | "**/database/*" = [ 100 | "A003", # We should allow shadowing 101 | "ANN002", # We don't want to type *args 102 | "ANN003", # We don't want to type **kwargs 103 | ] 104 | "**/schemas/*" = [ 105 | "A003", # We should allow shadowing 106 | "D106", # We should allow missing docstring 107 | ] 108 | "**/models/*" = [ 109 | "A003", # We should allow shadowing 110 | "D106", # We should allow missing docstring 111 | ] 112 | "**/routers/*" = [ 113 | "BLE001", # We use broad exceptions 114 | "TRY300", # We don't want to use if/else in returns 115 | "FBT001", # We are fine with Boolean positional args in function definition 116 | ] 117 | "**/actions/*" = [ 118 | "BLE001", # We use broad exceptions 119 | "TRY300", # We don't want to use if/else in returns 120 | ] 121 | "**/alembic/*" = [ 122 | "ANN201", # We don't want return type annotation 123 | "D103", # We don't care about docstrings 124 | "INP001", # We don't want an __init__ in the root directory 125 | "PLR0915", # We don't care about too many statements 126 | "PTH120", # We want to use os.path for now 127 | "PTH100", # we want to use os.path for now 128 | ] 129 | "**/tests/*" = [ 130 | "S101", # We use assert in tests 131 | "ANN101", # We don't need to type self in tests 132 | ] 133 | 134 | 135 | [tool.black] 136 | line-length = 119 137 | exclude = [".idea", "docs/"] 138 | 139 | [tool.mypy] 140 | exclude = [ 141 | "/.idea/", 142 | ".idea/*.py" 143 | ] -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ngrok_pid=$(pgrep ngrok) 4 | 5 | check=$? 6 | 7 | # check if the exit status returned success 8 | if [ $check -eq 0 ]; then 9 | echo "Current ngrok PID = ${ngrok_pid}" 10 | echo "Killing current Ngrok instance..." 11 | kill -9 "$ngrok_pid" 12 | check=$? 13 | sleep 2 14 | if [ $check -eq 0 ]; then 15 | echo "Successfully killed previous Ngrok, starting new instance..." 16 | ngrok http 80 --log=stdout > ngrok.log & 17 | echo "Waiting for 5 seconds so Ngrok can start..." 18 | sleep 5 19 | 20 | # shellcheck disable=SC2155 21 | export NGROK_URL=$(curl http://localhost:4040/api/tunnels --silent | python -c "import json, sys; print(json.load(sys.stdin)['tunnels'][1]['public_url'])") 22 | echo "New Ngrok URL is: $NGROK_URL" 23 | 24 | echo "Please enter a name for your bot: " 25 | read -r bot_name 26 | export BOT_NAME=${bot_name} 27 | 28 | echo "Please enter in your first initial and last name - for example - 'jstevens'; this is what will be at the end of your slash commands in the Slack workspace: " 29 | read -r bot_username 30 | export BOT_USERNAME=${bot_username} 31 | else 32 | echo "Failed to kill previous Ngrok, ending execution..." 33 | fi 34 | elif [ $check -eq 1 ]; then 35 | echo "No previous ngrok PID found, starting new instance..." 36 | ngrok http 80 --log=stdout > ngrok.log & 37 | else 38 | echo "Problem locating and/or killing an existing Ngrok instance, ending execution..." 39 | fi 40 | 41 | 42 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test configuration.""" 2 | import pytest 3 | 4 | 5 | @pytest.fixture(scope="module") 6 | def vcr_config() -> dict[str, list[tuple[str, str]]]: 7 | """VCR configuration for the tests. 8 | 9 | :return: A replacement for the Authorization header in cassettes. 10 | """ 11 | return { 12 | # Replace the Authorization request header with "DUMMY" in cassettes 13 | "filter_headers": [("Authorization", "DUMMY")], 14 | } 15 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests.""" 2 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestDailyProgrammerTableBasic.test_mentorship_affiliation_table_has_all_desired_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | User-Agent: 14 | - python-requests/2.26.0 15 | method: GET 16 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Daily%20Programmer?pageSize=1&maxRecords=1&view=Fields 17 | response: 18 | body: 19 | string: !!binary | 20 | H4sIAAAAAAAAA42RXUvDMBiF/0rJdTvSOLuZK92csKmb0KI4kRGaWOKyZDTp7Bj973vb+lHBgZCL 21 | 8J4nJycnB5SL1OTcIvpyQJIjWg+SyXZmlviCbBfRc4l89CaFqpkDSkTpAHLCOqmzoBWC3HwANWZO 22 | ZCbfn9LnbCNOaXfMOu/BWCe4t9BAEUxIgENYSUjo2ZBi3MMYL4GdaukkU2r/3wOf2NgUGsJjH8Wq 23 | yH6SrNokq06Se8MlTHnXlyThOe1HFIdfvr9Qb7SvC2o6LGx+VUzK28H15OlmLqcRwGLDpALtveDW 24 | 6B4k2gltL7N63EvNBhDdFjRrCC9uCVT56JGpxlfqXbP77sCLFUvXXhKf6nWkTLq2f6tgnOYCPo0n 25 | srm4W+GQkoj2B+1Tq9fqCPRzvKsqAgAA 26 | headers: 27 | Connection: 28 | - keep-alive 29 | Content-Length: 30 | - '309' 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Sun, 02 Jan 2022 15:47:21 GMT 35 | Server: 36 | - Tengine 37 | Set-Cookie: 38 | - brw=brwgLCpmfGloKV5qC; path=/; expires=Mon, 02 Jan 2023 15:47:21 GMT; domain=.airtable.com; 39 | samesite=none; secure 40 | Strict-Transport-Security: 41 | - max-age=31536000; includeSubDomains; preload 42 | Vary: 43 | - Accept-Encoding 44 | X-Content-Type-Options: 45 | - nosniff 46 | X-Frame-Options: 47 | - DENY 48 | access-control-allow-headers: 49 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 50 | access-control-allow-methods: 51 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 52 | access-control-allow-origin: 53 | - '*' 54 | airtable-uncompressed-content-length: 55 | - '554' 56 | content-encoding: 57 | - gzip 58 | status: 59 | code: 200 60 | message: OK 61 | version: 1 62 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestDailyProgrammerTableBasic.test_mentorship_affiliation_table_has_correct_number_of_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | Cookie: 14 | - brw=brwgLCpmfGloKV5qC 15 | User-Agent: 16 | - python-requests/2.26.0 17 | method: GET 18 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Daily%20Programmer?pageSize=1&maxRecords=1&view=Fields 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA42RXUvDMBiF/0rJdTvSOLuZK92csKmb0KI4kRGaWOKyZDTp7Bj973vb+lHBgZCL 23 | 8J4nJycnB5SL1OTcIvpyQJIjWg+SyXZmlviCbBfRc4l89CaFqpkDSkTpAHLCOqmzoBWC3HwANWZO 24 | ZCbfn9LnbCNOaXfMOu/BWCe4t9BAEUxIgENYSUjo2ZBi3MMYL4GdaukkU2r/3wOf2NgUGsJjH8Wq 25 | yH6SrNokq06Se8MlTHnXlyThOe1HFIdfvr9Qb7SvC2o6LGx+VUzK28H15OlmLqcRwGLDpALtveDW 26 | 6B4k2gltL7N63EvNBhDdFjRrCC9uCVT56JGpxlfqXbP77sCLFUvXXhKf6nWkTLq2f6tgnOYCPo0n 27 | srm4W+GQkoj2B+1Tq9fqCPRzvKsqAgAA 28 | headers: 29 | Connection: 30 | - keep-alive 31 | Content-Length: 32 | - '309' 33 | Content-Type: 34 | - application/json; charset=utf-8 35 | Date: 36 | - Sun, 02 Jan 2022 15:47:21 GMT 37 | Server: 38 | - Tengine 39 | Set-Cookie: 40 | - brw=brwgLCpmfGloKV5qC; path=/; expires=Mon, 02 Jan 2023 15:47:21 GMT; domain=.airtable.com; 41 | samesite=none; secure 42 | Strict-Transport-Security: 43 | - max-age=31536000; includeSubDomains; preload 44 | Vary: 45 | - Accept-Encoding 46 | X-Content-Type-Options: 47 | - nosniff 48 | X-Frame-Options: 49 | - DENY 50 | access-control-allow-headers: 51 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 52 | access-control-allow-methods: 53 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 54 | access-control-allow-origin: 55 | - '*' 56 | airtable-uncompressed-content-length: 57 | - '554' 58 | content-encoding: 59 | - gzip 60 | status: 61 | code: 200 62 | message: OK 63 | version: 1 64 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMentorTableBasic.test_mentor_table_has_all_desired_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | User-Agent: 14 | - python-requests/2.26.0 15 | method: GET 16 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Mentors?pageSize=1&maxRecords=1&view=Fields 17 | response: 18 | body: 19 | string: !!binary | 20 | H4sIAAAAAAAAA32RSW/CMBCF/4rlc0Bx1JYqJ3ZogXSBgkrVg5sM1I3JIC90Qfz32iGn0lbywXrz 21 | zczTmz1VkKLKNI2f9lRkNPZCg2+TvHXJxufD5eqGBnQlQHpmT+/xnVx1aczCs4DOuSxbRLErfwGd 22 | Sp7mJOEbcLIBbUSxrh27awrfHTHm2pAJZsKpvjcKo6gWMvdmrBFHLGasHobh8idK2p9+f7nPatWy 23 | vY9Ro9tb9BNxdeFg2HAhXe3NZhqLujawg0I3116up7hxSHG0dV0SZHok6CGgfSvlv6Z71fDTYtNL 24 | 1fxWasTOz+hzqcGnkQspfbQ+1Pxuzj7PZ+ar04PxA30O6IR/kAkUBsAxLKBtgX8ZSNB46PfiTGyA 25 | LLH4034XtFAuQr8MlX4VWzJEqzS5BUUWAHm5vrJCFqhyBy+Eea2s22E0fxk+DqajVWKlt97BDAiu 26 | SAeLzKaGtNIUtsYf1CgLAR1YkcELYk7ugVeqCzpVwB3lDZ+ePozDxvH0h+fDN8ty/B+ZAgAA 27 | headers: 28 | Connection: 29 | - keep-alive 30 | Content-Length: 31 | - '396' 32 | Content-Type: 33 | - application/json; charset=utf-8 34 | Date: 35 | - Sat, 01 Jan 2022 17:38:31 GMT 36 | Server: 37 | - Tengine 38 | Set-Cookie: 39 | - brw=brwOluKvpW4Hx5E7F; path=/; expires=Sun, 01 Jan 2023 17:38:31 GMT; domain=.airtable.com; 40 | samesite=none; secure 41 | Strict-Transport-Security: 42 | - max-age=31536000; includeSubDomains; preload 43 | Vary: 44 | - Accept-Encoding 45 | X-Content-Type-Options: 46 | - nosniff 47 | X-Frame-Options: 48 | - DENY 49 | access-control-allow-headers: 50 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 51 | access-control-allow-methods: 52 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 53 | access-control-allow-origin: 54 | - '*' 55 | airtable-uncompressed-content-length: 56 | - '665' 57 | content-encoding: 58 | - gzip 59 | status: 60 | code: 200 61 | message: OK 62 | version: 1 63 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMentorTableBasic.test_mentor_table_has_correct_number_of_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | Cookie: 14 | - brw=brwOluKvpW4Hx5E7F 15 | User-Agent: 16 | - python-requests/2.26.0 17 | method: GET 18 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Mentors?pageSize=1&maxRecords=1&view=Fields 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA32RSW/CMBCF/4rlc0Bx1JYqJ3ZogXSBgkrVg5sM1I3JIC90Qfz32iGn0lbywXrz 23 | zczTmz1VkKLKNI2f9lRkNPZCg2+TvHXJxufD5eqGBnQlQHpmT+/xnVx1aczCs4DOuSxbRLErfwGd 24 | Sp7mJOEbcLIBbUSxrh27awrfHTHm2pAJZsKpvjcKo6gWMvdmrBFHLGasHobh8idK2p9+f7nPatWy 25 | vY9Ro9tb9BNxdeFg2HAhXe3NZhqLujawg0I3116up7hxSHG0dV0SZHok6CGgfSvlv6Z71fDTYtNL 26 | 1fxWasTOz+hzqcGnkQspfbQ+1Pxuzj7PZ+ar04PxA30O6IR/kAkUBsAxLKBtgX8ZSNB46PfiTGyA 27 | LLH4034XtFAuQr8MlX4VWzJEqzS5BUUWAHm5vrJCFqhyBy+Eea2s22E0fxk+DqajVWKlt97BDAiu 28 | SAeLzKaGtNIUtsYf1CgLAR1YkcELYk7ugVeqCzpVwB3lDZ+ePozDxvH0h+fDN8ty/B+ZAgAA 29 | headers: 30 | Connection: 31 | - keep-alive 32 | Content-Length: 33 | - '396' 34 | Content-Type: 35 | - application/json; charset=utf-8 36 | Date: 37 | - Sat, 01 Jan 2022 17:38:32 GMT 38 | Server: 39 | - Tengine 40 | Set-Cookie: 41 | - brw=brwOluKvpW4Hx5E7F; path=/; expires=Sun, 01 Jan 2023 17:38:32 GMT; domain=.airtable.com; 42 | samesite=none; secure 43 | Strict-Transport-Security: 44 | - max-age=31536000; includeSubDomains; preload 45 | Vary: 46 | - Accept-Encoding 47 | X-Content-Type-Options: 48 | - nosniff 49 | X-Frame-Options: 50 | - DENY 51 | access-control-allow-headers: 52 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 53 | access-control-allow-methods: 54 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 55 | access-control-allow-origin: 56 | - '*' 57 | airtable-uncompressed-content-length: 58 | - '665' 59 | content-encoding: 60 | - gzip 61 | status: 62 | code: 200 63 | message: OK 64 | version: 1 65 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMentorshipAffiliationTableBasic.test_mentorship_affiliation_table_has_all_desired_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | User-Agent: 14 | - python-requests/2.26.0 15 | method: GET 16 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Affiliations?pageSize=1&maxRecords=1&view=Fields 17 | response: 18 | body: 19 | string: !!binary | 20 | H4sIAAAAAAAAA3WQT0+DQBDFvwrZM5AFbWs4aW21tpZDISXaNA3CFNfALu4f1BC+u7vswaSJyR4m 21 | 7/125s30iEPBeClQdOgRKVFkhA7SXcvu122G37MQuehMoDZMj5JaVRqSICSh1ckaJ86+NLXP67ED 22 | od1YuSjOG/ijPUt7ln7OhXS2rCRaNb9CHIYeDvRLg1kUTKKrGx9j/HqJOvMfE2ScpAS/U8vvzWyx 23 | zB5i8jTVMDQ5qbX3oUrBqC8kdEDFbWVkv2CNRqiNtR4JJ7EEGly0AFFw0krC6H+xt0Al484OPpW2 24 | zd3MxdQq3L+tXh6TzTlWNTrqXgWHXEKZknHWxXbX0WRqtxuOwy8BmLuRhQEAAA== 25 | headers: 26 | Connection: 27 | - keep-alive 28 | Content-Length: 29 | - '274' 30 | Content-Type: 31 | - application/json; charset=utf-8 32 | Date: 33 | - Sat, 01 Jan 2022 17:46:25 GMT 34 | Server: 35 | - Tengine 36 | Set-Cookie: 37 | - brw=brwrvNzjVswDW2j23; path=/; expires=Sun, 01 Jan 2023 17:46:25 GMT; domain=.airtable.com; 38 | samesite=none; secure 39 | Strict-Transport-Security: 40 | - max-age=31536000; includeSubDomains; preload 41 | Vary: 42 | - Accept-Encoding 43 | X-Content-Type-Options: 44 | - nosniff 45 | X-Frame-Options: 46 | - DENY 47 | access-control-allow-headers: 48 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 49 | access-control-allow-methods: 50 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 51 | access-control-allow-origin: 52 | - '*' 53 | airtable-uncompressed-content-length: 54 | - '389' 55 | content-encoding: 56 | - gzip 57 | status: 58 | code: 200 59 | message: OK 60 | version: 1 61 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMentorshipAffiliationTableBasic.test_mentorship_affiliation_table_has_correct_number_of_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | Cookie: 14 | - brw=brwrvNzjVswDW2j23 15 | User-Agent: 16 | - python-requests/2.26.0 17 | method: GET 18 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Affiliations?pageSize=1&maxRecords=1&view=Fields 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA3WQT0+DQBDFvwrZM5AFbWs4aW21tpZDISXaNA3CFNfALu4f1BC+u7vswaSJyR4m 23 | 7/125s30iEPBeClQdOgRKVFkhA7SXcvu122G37MQuehMoDZMj5JaVRqSICSh1ckaJ86+NLXP67ED 24 | od1YuSjOG/ijPUt7ln7OhXS2rCRaNb9CHIYeDvRLg1kUTKKrGx9j/HqJOvMfE2ScpAS/U8vvzWyx 25 | zB5i8jTVMDQ5qbX3oUrBqC8kdEDFbWVkv2CNRqiNtR4JJ7EEGly0AFFw0krC6H+xt0Al484OPpW2 26 | zd3MxdQq3L+tXh6TzTlWNTrqXgWHXEKZknHWxXbX0WRqtxuOwy8BmLuRhQEAAA== 27 | headers: 28 | Connection: 29 | - keep-alive 30 | Content-Length: 31 | - '274' 32 | Content-Type: 33 | - application/json; charset=utf-8 34 | Date: 35 | - Sat, 01 Jan 2022 17:46:26 GMT 36 | Server: 37 | - Tengine 38 | Set-Cookie: 39 | - brw=brwrvNzjVswDW2j23; path=/; expires=Sun, 01 Jan 2023 17:46:26 GMT; domain=.airtable.com; 40 | samesite=none; secure 41 | Strict-Transport-Security: 42 | - max-age=31536000; includeSubDomains; preload 43 | Vary: 44 | - Accept-Encoding 45 | X-Content-Type-Options: 46 | - nosniff 47 | X-Frame-Options: 48 | - DENY 49 | access-control-allow-headers: 50 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 51 | access-control-allow-methods: 52 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 53 | access-control-allow-origin: 54 | - '*' 55 | airtable-uncompressed-content-length: 56 | - '389' 57 | content-encoding: 58 | - gzip 59 | status: 60 | code: 200 61 | message: OK 62 | version: 1 63 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMentorshipRequestsTableBasic.test_mentorship_affiliation_table_has_all_desired_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | User-Agent: 14 | - python-requests/2.26.0 15 | method: GET 16 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Mentor%20Requests?pageSize=1&maxRecords=1&view=Fields 17 | response: 18 | body: 19 | string: !!binary | 20 | H4sIAAAAAAAAA32S3VLbMBCFX0Wj6yQjuw1JfUV+TEwSwtQ2ZKDDhWqvjbAipZYcGjK8O5IVOsBA 21 | Z3zj3W/Psc/uAdeQyTpXOPh1wCzHgS00kX/9O7qZJYti1XDcwQUDbpkDjkGBRhPZCI0D0sGxfETn 22 | Zsz77nfwkiqNLmTODG+lfOL7XeKZJ/UGge8FZNAjhNziDyga7612a9+oetSEfxeDabg+W7HzEwPD 23 | hjJueg9NrqToKQ07EOq0tOVeJjcGEXQDhpi3BEocgZ87OOE0q9DKtTUozUTZdf/TreWjGQ2P6m+a 24 | rndqK0f9BOody6xGesTOnEbcaoyKgnFGNZPCJGkzXDD/ptrKp3saj8PSIKa2gzTeysl8uyb3ax/f 25 | mbk8Z3aIcjQFbb5DvXF495VJxTg32SsUw5/GEDbh1qn6ee3t+6l+moSwvHJO6qGcRftyGmX94Rys 26 | k8vhApSiJaA0+cpnwinbWG1dN/Dvtd1Q68bLKhnuvDGZiXoknNuAblfVaOgt+9FtcWndXscuxYcz 27 | MDfwIyDk9QzcOVnxz3fj+v9XMUvOaqAmkJS1W35/dmQYfDs5knfPL7PR6AbxAgAA 28 | headers: 29 | Connection: 30 | - keep-alive 31 | Content-Length: 32 | - '447' 33 | Content-Type: 34 | - application/json; charset=utf-8 35 | Date: 36 | - Sat, 01 Jan 2022 17:46:26 GMT 37 | Server: 38 | - Tengine 39 | Set-Cookie: 40 | - brw=brw6yqQpAzIJ7BgE8; path=/; expires=Sun, 01 Jan 2023 17:46:26 GMT; domain=.airtable.com; 41 | samesite=none; secure 42 | Strict-Transport-Security: 43 | - max-age=31536000; includeSubDomains; preload 44 | Vary: 45 | - Accept-Encoding 46 | X-Content-Type-Options: 47 | - nosniff 48 | X-Frame-Options: 49 | - DENY 50 | access-control-allow-headers: 51 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 52 | access-control-allow-methods: 53 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 54 | access-control-allow-origin: 55 | - '*' 56 | airtable-uncompressed-content-length: 57 | - '753' 58 | content-encoding: 59 | - gzip 60 | status: 61 | code: 200 62 | message: OK 63 | version: 1 64 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMentorshipRequestsTableBasic.test_mentorship_affiliation_table_has_correct_number_of_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | Cookie: 14 | - brw=brw6yqQpAzIJ7BgE8 15 | User-Agent: 16 | - python-requests/2.26.0 17 | method: GET 18 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Mentor%20Requests?pageSize=1&maxRecords=1&view=Fields 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA32S3VLbMBCFX0Wj6yQjuw1JfUV+TEwSwtQ2ZKDDhWqvjbAipZYcGjK8O5IVOsBA 23 | Z3zj3W/Psc/uAdeQyTpXOPh1wCzHgS00kX/9O7qZJYti1XDcwQUDbpkDjkGBRhPZCI0D0sGxfETn 24 | Zsz77nfwkiqNLmTODG+lfOL7XeKZJ/UGge8FZNAjhNziDyga7612a9+oetSEfxeDabg+W7HzEwPD 25 | hjJueg9NrqToKQ07EOq0tOVeJjcGEXQDhpi3BEocgZ87OOE0q9DKtTUozUTZdf/TreWjGQ2P6m+a 26 | rndqK0f9BOody6xGesTOnEbcaoyKgnFGNZPCJGkzXDD/ptrKp3saj8PSIKa2gzTeysl8uyb3ax/f 27 | mbk8Z3aIcjQFbb5DvXF495VJxTg32SsUw5/GEDbh1qn6ee3t+6l+moSwvHJO6qGcRftyGmX94Rys 28 | k8vhApSiJaA0+cpnwinbWG1dN/Dvtd1Q68bLKhnuvDGZiXoknNuAblfVaOgt+9FtcWndXscuxYcz 29 | MDfwIyDk9QzcOVnxz3fj+v9XMUvOaqAmkJS1W35/dmQYfDs5knfPL7PR6AbxAgAA 30 | headers: 31 | Connection: 32 | - keep-alive 33 | Content-Length: 34 | - '447' 35 | Content-Type: 36 | - application/json; charset=utf-8 37 | Date: 38 | - Sat, 01 Jan 2022 17:46:27 GMT 39 | Server: 40 | - Tengine 41 | Set-Cookie: 42 | - brw=brw6yqQpAzIJ7BgE8; path=/; expires=Sun, 01 Jan 2023 17:46:27 GMT; domain=.airtable.com; 43 | samesite=none; secure 44 | Strict-Transport-Security: 45 | - max-age=31536000; includeSubDomains; preload 46 | Vary: 47 | - Accept-Encoding 48 | X-Content-Type-Options: 49 | - nosniff 50 | X-Frame-Options: 51 | - DENY 52 | access-control-allow-headers: 53 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 54 | access-control-allow-methods: 55 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 56 | access-control-allow-origin: 57 | - '*' 58 | airtable-uncompressed-content-length: 59 | - '753' 60 | content-encoding: 61 | - gzip 62 | status: 63 | code: 200 64 | message: OK 65 | version: 1 66 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMentorshipServicesTableBasic.test_mentorship_services_table_has_all_desired_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | User-Agent: 14 | - python-requests/2.26.0 15 | method: GET 16 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Services?pageSize=1&maxRecords=1&view=Fields 17 | response: 18 | body: 19 | string: !!binary | 20 | H4sIAAAAAAAAA23OW0vDMBQH8K9S8tyWtDI386RjE+ZliJuKiozYHsuRNJFc5qX0u5vLgzCEPIT/ 21 | +eXkPxANjdKtIex5INgSFoKbozvz6n5uH+eiXlGSkzcEEcxANsJ1HlkwFmW3S4OdVp9e3XMRN6Dc 22 | x1tO1ryHP10kXSR9xY3NrlWLPg2valrXBa382VZTVp0wOikppU+HNJt/hyLxJ2f0mVt+XU4Xy4fz 23 | Na6OPYaeo/Czd9caJUtjYQ/SnHYhLhvVeyJTrYsosk0SZMzJAkyj8cOikv/X9qbRwC20W4w7DlrP 24 | 2GSWWo8v4y88kykbXQEAAA== 25 | headers: 26 | Connection: 27 | - keep-alive 28 | Content-Length: 29 | - '244' 30 | Content-Type: 31 | - application/json; charset=utf-8 32 | Date: 33 | - Sat, 01 Jan 2022 17:38:14 GMT 34 | Server: 35 | - Tengine 36 | Set-Cookie: 37 | - brw=brwbGAe1ZDvQhU9Aj; path=/; expires=Sun, 01 Jan 2023 17:38:14 GMT; domain=.airtable.com; 38 | samesite=none; secure 39 | Strict-Transport-Security: 40 | - max-age=31536000; includeSubDomains; preload 41 | Vary: 42 | - Accept-Encoding 43 | X-Content-Type-Options: 44 | - nosniff 45 | X-Frame-Options: 46 | - DENY 47 | access-control-allow-headers: 48 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 49 | access-control-allow-methods: 50 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 51 | access-control-allow-origin: 52 | - '*' 53 | airtable-uncompressed-content-length: 54 | - '349' 55 | content-encoding: 56 | - gzip 57 | status: 58 | code: 200 59 | message: OK 60 | version: 1 61 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMentorshipServicesTableBasic.test_mentorship_services_table_has_correct_number_of_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | Cookie: 14 | - brw=brwbGAe1ZDvQhU9Aj 15 | User-Agent: 16 | - python-requests/2.26.0 17 | method: GET 18 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Services?pageSize=1&maxRecords=1&view=Fields 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA23OW0vDMBQH8K9S8tyWtDI386RjE+ZliJuKiozYHsuRNJFc5qX0u5vLgzCEPIT/ 23 | +eXkPxANjdKtIex5INgSFoKbozvz6n5uH+eiXlGSkzcEEcxANsJ1HlkwFmW3S4OdVp9e3XMRN6Dc 24 | x1tO1ryHP10kXSR9xY3NrlWLPg2valrXBa382VZTVp0wOikppU+HNJt/hyLxJ2f0mVt+XU4Xy4fz 25 | Na6OPYaeo/Czd9caJUtjYQ/SnHYhLhvVeyJTrYsosk0SZMzJAkyj8cOikv/X9qbRwC20W4w7DlrP 26 | 2GSWWo8v4y88kykbXQEAAA== 27 | headers: 28 | Connection: 29 | - keep-alive 30 | Content-Length: 31 | - '244' 32 | Content-Type: 33 | - application/json; charset=utf-8 34 | Date: 35 | - Sat, 01 Jan 2022 17:38:17 GMT 36 | Server: 37 | - Tengine 38 | Set-Cookie: 39 | - brw=brwbGAe1ZDvQhU9Aj; path=/; expires=Sun, 01 Jan 2023 17:38:17 GMT; domain=.airtable.com; 40 | samesite=none; secure 41 | Strict-Transport-Security: 42 | - max-age=31536000; includeSubDomains; preload 43 | Vary: 44 | - Accept-Encoding 45 | X-Content-Type-Options: 46 | - nosniff 47 | X-Frame-Options: 48 | - DENY 49 | access-control-allow-headers: 50 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 51 | access-control-allow-methods: 52 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 53 | access-control-allow-origin: 54 | - '*' 55 | airtable-uncompressed-content-length: 56 | - '349' 57 | content-encoding: 58 | - gzip 59 | status: 60 | code: 200 61 | message: OK 62 | version: 1 63 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMentorshipSkillsetsTableBasic.test_mentorship_skillsets_table_has_all_desired_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | User-Agent: 14 | - python-requests/2.26.0 15 | method: GET 16 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Skillsets?pageSize=1&maxRecords=1&view=Fields 17 | response: 18 | body: 19 | string: !!binary | 20 | H4sIAAAAAAAAA2WQX2uDMBTFv4rkWSU61haf1tJO164+zNKxDSlObyVdTFj+uBXxuzfRlcEG9yGc 21 | 88u999wOCSi5qCSK3jpEKhRZQZ7qODnXy6S8na0BuehIgFqmQxnVtYEUSEVYfRiNg+BfhtoXdOhA 22 | WDu8XJQWDfzS3kh7I/1YSOVseUWMan+FOAw9HJjaBdPI1M3Exxi//kWdxdkuMkzSUsz16nszXa6e 23 | 71PyMDEwNAWhxjvpSnLmSwUtMHlXW9kveWMQNq61HggnGwnUu2gLTHFhj2HPQOuPbNYGCxwzMWco 24 | v/rOE3xqE+nK6STcvycvcbY5ppqi3DQqBRQKqh0ZBv2LFvxE6/P+AiCKxpmCAQAA 25 | headers: 26 | Connection: 27 | - keep-alive 28 | Content-Length: 29 | - '276' 30 | Content-Type: 31 | - application/json; charset=utf-8 32 | Date: 33 | - Sat, 01 Jan 2022 17:38:32 GMT 34 | Server: 35 | - Tengine 36 | Set-Cookie: 37 | - brw=brweWqz6bh1Pd9Pan; path=/; expires=Sun, 01 Jan 2023 17:38:32 GMT; domain=.airtable.com; 38 | samesite=none; secure 39 | Strict-Transport-Security: 40 | - max-age=31536000; includeSubDomains; preload 41 | Vary: 42 | - Accept-Encoding 43 | X-Content-Type-Options: 44 | - nosniff 45 | X-Frame-Options: 46 | - DENY 47 | access-control-allow-headers: 48 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 49 | access-control-allow-methods: 50 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 51 | access-control-allow-origin: 52 | - '*' 53 | airtable-uncompressed-content-length: 54 | - '386' 55 | content-encoding: 56 | - gzip 57 | status: 58 | code: 200 59 | message: OK 60 | version: 1 61 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMentorshipSkillsetsTableBasic.test_mentorship_skillsets_table_has_correct_number_of_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | Cookie: 14 | - brw=brweWqz6bh1Pd9Pan 15 | User-Agent: 16 | - python-requests/2.26.0 17 | method: GET 18 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Skillsets?pageSize=1&maxRecords=1&view=Fields 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA2WQX2uDMBTFv4rkWSU61haf1tJO164+zNKxDSlObyVdTFj+uBXxuzfRlcEG9yGc 23 | 88u999wOCSi5qCSK3jpEKhRZQZ7qODnXy6S8na0BuehIgFqmQxnVtYEUSEVYfRiNg+BfhtoXdOhA 24 | WDu8XJQWDfzS3kh7I/1YSOVseUWMan+FOAw9HJjaBdPI1M3Exxi//kWdxdkuMkzSUsz16nszXa6e 25 | 71PyMDEwNAWhxjvpSnLmSwUtMHlXW9kveWMQNq61HggnGwnUu2gLTHFhj2HPQOuPbNYGCxwzMWco 26 | v/rOE3xqE+nK6STcvycvcbY5ppqi3DQqBRQKqh0ZBv2LFvxE6/P+AiCKxpmCAQAA 27 | headers: 28 | Connection: 29 | - keep-alive 30 | Content-Length: 31 | - '276' 32 | Content-Type: 33 | - application/json; charset=utf-8 34 | Date: 35 | - Sat, 01 Jan 2022 17:38:33 GMT 36 | Server: 37 | - Tengine 38 | Set-Cookie: 39 | - brw=brweWqz6bh1Pd9Pan; path=/; expires=Sun, 01 Jan 2023 17:38:33 GMT; domain=.airtable.com; 40 | samesite=none; secure 41 | Strict-Transport-Security: 42 | - max-age=31536000; includeSubDomains; preload 43 | Vary: 44 | - Accept-Encoding 45 | X-Content-Type-Options: 46 | - nosniff 47 | X-Frame-Options: 48 | - DENY 49 | access-control-allow-headers: 50 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 51 | access-control-allow-methods: 52 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 53 | access-control-allow-origin: 54 | - '*' 55 | airtable-uncompressed-content-length: 56 | - '386' 57 | content-encoding: 58 | - gzip 59 | status: 60 | code: 200 61 | message: OK 62 | version: 1 63 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMessageTextTableBasic.test_mentorship_affiliation_table_has_all_desired_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | User-Agent: 14 | - python-requests/2.26.0 15 | method: GET 16 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Message%20Text?pageSize=1&maxRecords=1&view=Fields 17 | response: 18 | body: 19 | string: !!binary | 20 | H4sIAAAAAAAAA3WQXWvCMBSG/0rJtS1piwq52lcHOnUXlg4cQ0JzVrK1CSSpVUv/+/JxMRCEXBze 21 | 5znhTUakoJaKaUQ+R8QZIi4os+46XKt1kQ+/1TuaoW8OrXNGVMLZWMmANlw0cQCxkoO1nqmBRqrL 22 | Pb6jHdxj+7Zv/tkxsGNgG6pNtJWM29Q1zHCWxTi1p0yXJE8JXiQY48OtGj1dXGX/ql6rx744vy1f 23 | io/XHV8trAwd5a1lPz3TUiTawAmEfmhcnNSys4oIldfeiPbBQNMMVbT193Jx8pONagX2A1jJ/cpN 24 | SUzmeSg5fU1/3z9ZSnYBAAA= 25 | headers: 26 | Connection: 27 | - keep-alive 28 | Content-Length: 29 | - '245' 30 | Content-Type: 31 | - application/json; charset=utf-8 32 | Date: 33 | - Sat, 01 Jan 2022 18:14:51 GMT 34 | Server: 35 | - Tengine 36 | Set-Cookie: 37 | - brw=brwmk4IX4aqVeCxyj; path=/; expires=Sun, 01 Jan 2023 18:14:50 GMT; domain=.airtable.com; 38 | samesite=none; secure 39 | Strict-Transport-Security: 40 | - max-age=31536000; includeSubDomains; preload 41 | Vary: 42 | - Accept-Encoding 43 | X-Content-Type-Options: 44 | - nosniff 45 | X-Frame-Options: 46 | - DENY 47 | access-control-allow-headers: 48 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 49 | access-control-allow-methods: 50 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 51 | access-control-allow-origin: 52 | - '*' 53 | airtable-uncompressed-content-length: 54 | - '374' 55 | content-encoding: 56 | - gzip 57 | status: 58 | code: 200 59 | message: OK 60 | version: 1 61 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestMessageTextTableBasic.test_mentorship_affiliation_table_has_correct_number_of_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | Cookie: 14 | - brw=brwmk4IX4aqVeCxyj 15 | User-Agent: 16 | - python-requests/2.26.0 17 | method: GET 18 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Message%20Text?pageSize=1&maxRecords=1&view=Fields 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA3WQXWvCMBSG/0rJtS1piwq52lcHOnUXlg4cQ0JzVrK1CSSpVUv/+/JxMRCEXBze 23 | 5znhTUakoJaKaUQ+R8QZIi4os+46XKt1kQ+/1TuaoW8OrXNGVMLZWMmANlw0cQCxkoO1nqmBRqrL 24 | Pb6jHdxj+7Zv/tkxsGNgG6pNtJWM29Q1zHCWxTi1p0yXJE8JXiQY48OtGj1dXGX/ql6rx744vy1f 25 | io/XHV8trAwd5a1lPz3TUiTawAmEfmhcnNSys4oIldfeiPbBQNMMVbT193Jx8pONagX2A1jJ/cpN 26 | SUzmeSg5fU1/3z9ZSnYBAAA= 27 | headers: 28 | Connection: 29 | - keep-alive 30 | Content-Length: 31 | - '245' 32 | Content-Type: 33 | - application/json; charset=utf-8 34 | Date: 35 | - Sat, 01 Jan 2022 18:14:51 GMT 36 | Server: 37 | - Tengine 38 | Set-Cookie: 39 | - brw=brwmk4IX4aqVeCxyj; path=/; expires=Sun, 01 Jan 2023 18:14:51 GMT; domain=.airtable.com; 40 | samesite=none; secure 41 | Strict-Transport-Security: 42 | - max-age=31536000; includeSubDomains; preload 43 | Vary: 44 | - Accept-Encoding 45 | X-Content-Type-Options: 46 | - nosniff 47 | X-Frame-Options: 48 | - DENY 49 | access-control-allow-headers: 50 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 51 | access-control-allow-methods: 52 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 53 | access-control-allow-origin: 54 | - '*' 55 | airtable-uncompressed-content-length: 56 | - '374' 57 | content-encoding: 58 | - gzip 59 | status: 60 | code: 200 61 | message: OK 62 | version: 1 63 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestScheduledMessagesTableBasic.test_mentorship_affiliation_table_has_all_desired_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | User-Agent: 14 | - python-requests/2.26.0 15 | method: GET 16 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Scheduled%20Messages?pageSize=1&maxRecords=1&view=Fields 17 | response: 18 | body: 19 | string: !!binary | 20 | H4sIAAAAAAAAA22QXU/CMBSG/8rSa7Z0TfhwV6KDBBVuIJJoDKnrYauunbQdshD+u6ebiR+Q9KI5 21 | 5zntc94jMZBVRliSPB+JFCTxBTBiHG/Vx65IneqTHtlKKD1zJLcF1xpK5LJKqVpL14RbAPHKs3cE 22 | H7h1wRK0Q4BRxkIa41nFccKuEkojSukTYguuAAkH1kmdh93zoak+sTcHa3kOwQoO7pv5C0wN7GrQ 23 | WYPdlMuywdoMRSQvg5Q7CFdS4XjlPcS5x+iXR6s7r4TED/6jQ6/MBhfR4KbxYbR51daM68nhfphO 24 | 1tOFnA0QBoVe2Hurha10ZB3sQdvr3JcjDA4R3UVw1xLBsiPIqUeWZZ3/ZLPpVt90q68L0Jc2Y2cJ 25 | P/KytZN6397w4cwApiN8OuerjhLW72ZPL6cvnONUTBYCAAA= 26 | headers: 27 | Connection: 28 | - keep-alive 29 | Content-Length: 30 | - '320' 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Sat, 01 Jan 2022 18:15:23 GMT 35 | Server: 36 | - Tengine 37 | Set-Cookie: 38 | - brw=brwZfvUPfXAu7noUZ; path=/; expires=Sun, 01 Jan 2023 18:15:23 GMT; domain=.airtable.com; 39 | samesite=none; secure 40 | Strict-Transport-Security: 41 | - max-age=31536000; includeSubDomains; preload 42 | Vary: 43 | - Accept-Encoding 44 | X-Content-Type-Options: 45 | - nosniff 46 | X-Frame-Options: 47 | - DENY 48 | access-control-allow-headers: 49 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 50 | access-control-allow-methods: 51 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 52 | access-control-allow-origin: 53 | - '*' 54 | airtable-uncompressed-content-length: 55 | - '534' 56 | content-encoding: 57 | - gzip 58 | status: 59 | code: 200 60 | message: OK 61 | version: 1 62 | -------------------------------------------------------------------------------- /tests/unit/cassettes/TestScheduledMessagesTableBasic.test_mentorship_affiliation_table_has_correct_number_of_fields.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - DUMMY 11 | Connection: 12 | - keep-alive 13 | Cookie: 14 | - brw=brwZfvUPfXAu7noUZ 15 | User-Agent: 16 | - python-requests/2.26.0 17 | method: GET 18 | uri: https://api.airtable.com/v0/appHcVtFOf2HaGQog/Scheduled%20Messages?pageSize=1&maxRecords=1&view=Fields 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA22QXU/CMBSG/8rSa7Z0TfhwV6KDBBVuIJJoDKnrYauunbQdshD+u6ebiR+Q9KI5 23 | 5zntc94jMZBVRliSPB+JFCTxBTBiHG/Vx65IneqTHtlKKD1zJLcF1xpK5LJKqVpL14RbAPHKs3cE 24 | H7h1wRK0Q4BRxkIa41nFccKuEkojSukTYguuAAkH1kmdh93zoak+sTcHa3kOwQoO7pv5C0wN7GrQ 25 | WYPdlMuywdoMRSQvg5Q7CFdS4XjlPcS5x+iXR6s7r4TED/6jQ6/MBhfR4KbxYbR51daM68nhfphO 26 | 1tOFnA0QBoVe2Hurha10ZB3sQdvr3JcjDA4R3UVw1xLBsiPIqUeWZZ3/ZLPpVt90q68L0Jc2Y2cJ 27 | P/KytZN6397w4cwApiN8OuerjhLW72ZPL6cvnONUTBYCAAA= 28 | headers: 29 | Connection: 30 | - keep-alive 31 | Content-Length: 32 | - '320' 33 | Content-Type: 34 | - application/json; charset=utf-8 35 | Date: 36 | - Sat, 01 Jan 2022 18:15:23 GMT 37 | Server: 38 | - Tengine 39 | Set-Cookie: 40 | - brw=brwZfvUPfXAu7noUZ; path=/; expires=Sun, 01 Jan 2023 18:15:23 GMT; domain=.airtable.com; 41 | samesite=none; secure 42 | Strict-Transport-Security: 43 | - max-age=31536000; includeSubDomains; preload 44 | Vary: 45 | - Accept-Encoding 46 | X-Content-Type-Options: 47 | - nosniff 48 | X-Frame-Options: 49 | - DENY 50 | access-control-allow-headers: 51 | - authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with 52 | access-control-allow-methods: 53 | - DELETE,GET,OPTIONS,PATCH,POST,PUT 54 | access-control-allow-origin: 55 | - '*' 56 | airtable-uncompressed-content-length: 57 | - '534' 58 | content-encoding: 59 | - gzip 60 | status: 61 | code: 200 62 | message: OK 63 | version: 1 64 | -------------------------------------------------------------------------------- /tests/unit/test_airtable.py: -------------------------------------------------------------------------------- 1 | """Tests for the Airtable module.""" 2 | import pytest 3 | 4 | from modules.airtable import ( 5 | daily_programmer_table, 6 | mentor_table, 7 | mentorship_affiliations_table, 8 | mentorship_requests_table, 9 | mentorship_services_table, 10 | mentorship_skillsets_table, 11 | message_text_table, 12 | scheduled_message_table, 13 | ) 14 | 15 | 16 | @pytest.mark.vcr() 17 | class TestMentorTableBasic: 18 | """Tests for the mentor table in Airtable.""" 19 | 20 | def setup(self) -> None: 21 | """Set up for the tests.""" 22 | self.desired_fields = { 23 | "row_id", 24 | "valid", 25 | "slack_name", 26 | "last_modified", 27 | "last_modified_by", 28 | "full_name", 29 | "email", 30 | "active", 31 | "skills", 32 | "max_mentees", 33 | "bio", 34 | "notes", 35 | "time_zone", 36 | "desired_mentorship_hours_per_week", 37 | "mentees_worked_with", 38 | "code_of_conduct_accepted", 39 | "guidebook_read", 40 | } 41 | self.airtable_fields = mentor_table.table_fields 42 | 43 | def test_mentor_table_has_all_desired_fields(self) -> None: 44 | """Test that the mentor table has all the desired fields.""" 45 | for field in self.airtable_fields: 46 | assert field in self.desired_fields 47 | 48 | def test_mentor_table_has_correct_number_of_fields(self) -> None: 49 | """Test that the mentor table has the correct number of fields.""" 50 | assert len(self.airtable_fields) == len(self.desired_fields) 51 | 52 | 53 | @pytest.mark.vcr() 54 | class TestMentorshipServicesTableBasic: 55 | """Tests for the mentorship services table in Airtable.""" 56 | 57 | def setup(self) -> None: 58 | """Set up for the tests.""" 59 | self.desired_fields = { 60 | "name", 61 | "slug", 62 | "description", 63 | "last_modified", 64 | "last_modified_by", 65 | "valid", 66 | } 67 | self.airtable_fields = mentorship_services_table.table_fields 68 | 69 | def test_mentorship_services_table_has_all_desired_fields(self) -> None: 70 | """Test that the mentorship services table has all the desired fields.""" 71 | for field in self.airtable_fields: 72 | assert field in self.desired_fields 73 | 74 | def test_mentorship_services_table_has_correct_number_of_fields(self) -> None: 75 | """Test that the mentorship services table has the correct number of fields.""" 76 | assert len(self.airtable_fields) == len(self.desired_fields) 77 | 78 | 79 | @pytest.mark.vcr() 80 | class TestMentorshipSkillsetsTableBasic: 81 | """Tests for the mentorship skillsets table in Airtable.""" 82 | 83 | def setup(self) -> None: 84 | """Set up for the tests.""" 85 | self.desired_fields = { 86 | "name", 87 | "slug", 88 | "mentors", 89 | "mentor_requests", 90 | "last_modified", 91 | "last_modified_by", 92 | "valid", 93 | } 94 | self.airtable_fields = mentorship_skillsets_table.table_fields 95 | 96 | def test_mentorship_skillsets_table_has_all_desired_fields(self) -> None: 97 | """Test that the mentorship skillsets table has all the desired fields.""" 98 | for field in self.airtable_fields: 99 | assert field in self.desired_fields 100 | 101 | def test_mentorship_skillsets_table_has_correct_number_of_fields(self) -> None: 102 | """Test that the mentorship skillsets table has the correct number of fields.""" 103 | assert len(self.airtable_fields) == len(self.desired_fields) 104 | 105 | 106 | @pytest.mark.vcr() 107 | class TestMentorshipAffiliationTableBasic: 108 | """Tests for the mentorship affiliation table in Airtable.""" 109 | 110 | def setup(self) -> None: 111 | """Set up for the tests.""" 112 | self.desired_fields = { 113 | "name", 114 | "slug", 115 | "description", 116 | "last_modified", 117 | "last_modified_by", 118 | "valid", 119 | "mentor_requests", 120 | } 121 | self.airtable_fields = mentorship_affiliations_table.table_fields 122 | 123 | def test_mentorship_affiliation_table_has_all_desired_fields(self) -> None: 124 | """Test that the mentorship affiliation table has all the desired fields.""" 125 | for field in self.airtable_fields: 126 | assert field in self.desired_fields 127 | 128 | def test_mentorship_affiliation_table_has_correct_number_of_fields(self) -> None: 129 | """Test that the mentorship affiliation table has the correct number of fields.""" 130 | assert len(self.airtable_fields) == len(self.desired_fields) 131 | 132 | 133 | @pytest.mark.vcr() 134 | class TestMentorshipRequestsTableBasic: 135 | """Tests for the mentorship requests table in Airtable.""" 136 | 137 | def setup(self) -> None: 138 | """Set up for the tests.""" 139 | self.desired_fields = { 140 | "slack_name", 141 | "email", 142 | "service", 143 | "affiliation", 144 | "additional_details", 145 | "skillsets_requested", 146 | "slack_message_ts", 147 | "claimed", 148 | "claimed_by", 149 | "claimed_on", 150 | "reset_by", 151 | "reset_on", 152 | "reset_count", 153 | "last_modified", 154 | "last_modified_by", 155 | "row_id", 156 | } 157 | self.airtable_fields = mentorship_requests_table.table_fields 158 | 159 | def test_mentorship_affiliation_table_has_all_desired_fields(self) -> None: 160 | """Test that the mentorship affiliation table has all the desired fields.""" 161 | for field in self.airtable_fields: 162 | assert field in self.desired_fields 163 | 164 | def test_mentorship_affiliation_table_has_correct_number_of_fields(self) -> None: 165 | """Test that the mentorship affiliation table has the correct number of fields.""" 166 | assert len(self.airtable_fields) == len(self.desired_fields) 167 | 168 | 169 | @pytest.mark.vcr() 170 | class TestScheduledMessagesTableBasic: 171 | """Test the scheduled messages table.""" 172 | 173 | def setup(self) -> None: 174 | """Set up the test.""" 175 | self.desired_fields = { 176 | "name", 177 | "slug", 178 | "channel", 179 | "message_text", 180 | "initial_date_time_to_send", 181 | "frequency", 182 | "last_sent", 183 | "when_to_send", 184 | "last_modified", 185 | "last_modified_by", 186 | "valid", 187 | } 188 | self.airtable_fields = scheduled_message_table.table_fields 189 | 190 | def test_scheduled_message_table_has_all_desired_fields(self) -> None: 191 | """Ensure that the scheduled message table has the desired fields.""" 192 | for field in self.airtable_fields: 193 | assert field in self.desired_fields 194 | 195 | def test_scheduled_message_table_has_correct_number_of_fields(self) -> None: 196 | """Ensure that the scheduled message table has the correct number of fields.""" 197 | assert len(self.airtable_fields) == len(self.desired_fields) 198 | 199 | 200 | @pytest.mark.vcr() 201 | class TestMessageTextTableBasic: 202 | """Test the message text table.""" 203 | 204 | def setup(self) -> None: 205 | """Set up the test.""" 206 | self.desired_fields = { 207 | "name", 208 | "slug", 209 | "text", 210 | "category", 211 | "last_modified", 212 | "last_modified_by", 213 | "valid", 214 | } 215 | self.airtable_fields = message_text_table.table_fields 216 | 217 | def test_message_text_table_has_all_desired_fields(self) -> None: 218 | """Ensure that the message text table has the desired fields.""" 219 | for field in self.airtable_fields: 220 | assert field in self.desired_fields 221 | 222 | def test_message_test_table_has_correct_number_of_fields(self) -> None: 223 | """Ensure that the message text table has the desired fields.""" 224 | assert len(self.airtable_fields) == len(self.desired_fields) 225 | 226 | 227 | @pytest.mark.vcr() 228 | class TestDailyProgrammerTableBasic: 229 | """Test the Daily Programmer table.""" 230 | 231 | def setup(self) -> None: 232 | """Set up the test class.""" 233 | self.desired_fields = { 234 | "name", 235 | "slug", 236 | "text", 237 | "category", 238 | "initial_slack_ts", 239 | "blocks", 240 | "initially_posted_on", 241 | "last_posted_on", 242 | "posted_count", 243 | "last_modified", 244 | "last_modified_by", 245 | "valid", 246 | } 247 | self.airtable_fields = daily_programmer_table.table_fields 248 | 249 | def test_daily_programmer_table_has_all_desired_fields(self) -> None: 250 | """Ensure that the affiliation table has the desired fields.""" 251 | for field in self.airtable_fields: 252 | assert field in self.desired_fields 253 | 254 | def test_daily_programmer_table_has_correct_number_of_fields(self) -> None: 255 | """Ensure that the number of fields in the Airtable matches the number of fields in the desired fields set.""" 256 | assert len(self.airtable_fields) == len(self.desired_fields) 257 | --------------------------------------------------------------------------------