├── .env.example ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── auto-merge.yml │ ├── codeql.yml │ └── python-lint-and-test.yml ├── .gitignore ├── .npmignore ├── .style.yapf ├── Pipfile ├── Pipfile.lock ├── README.md ├── handler.py ├── package-lock.json ├── package.json ├── serverless.yml └── test_hundler.py /.env.example: -------------------------------------------------------------------------------- 1 | PuSH_hmac_secret= 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E111,E722 3 | max-line-length = 127 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | reviewers: 13 | - "meihei3" 14 | - package-ecosystem: "pip" 15 | directory: "/" 16 | schedule: 17 | interval: "weekly" 18 | reviewers: 19 | - "meihei3" 20 | - package-ecosystem: "npm" 21 | directory: "/" 22 | schedule: 23 | interval: "weekly" 24 | reviewers: 25 | - "meihei3" 26 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Auto Merge Dependabot PRs 2 | 3 | on: 4 | pull_request_review: 5 | types: 6 | - submitted 7 | 8 | jobs: 9 | auto-merge: 10 | runs-on: ubuntu-latest 11 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.event.review.state == 'approved' 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v5 15 | 16 | - name: Merge PR 17 | run: | 18 | gh pr merge --auto --merge "$PR_URL" 19 | env: 20 | PR_URL: ${{github.event.pull_request.html_url}} 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '34 12 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v5 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v3 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v3 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /.github/workflows/python-lint-and-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python lint and test 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v5 19 | - name: Set up Python 3.9 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.9" 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install pipenv 27 | pipenv sync --dev 28 | - name: Add dotenvs 29 | run: | 30 | # write environmental variables to ".env" 31 | echo "PuSH_verify_token=test\n" >> .env 32 | echo "PuSH_hmac_secret=test\n" >> .env 33 | - name: Lint with flake8 34 | run: | 35 | # stop the build if there are Python syntax errors or undefined names 36 | pipenv run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude ./node_modules 37 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 38 | pipenv run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --exclude ./node_modules 39 | - name: Run pytest 40 | run: | 41 | pipenv run pytest 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/b0012e4930d0a8c350254a3caeedf7441ea286a3/Node.gitignore 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | .pnpm-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | .env.production 78 | 79 | # parcel-bundler cache (https://parceljs.org/) 80 | .cache 81 | .parcel-cache 82 | 83 | # Next.js build output 84 | .next 85 | out 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | dist 90 | 91 | # Gatsby files 92 | .cache/ 93 | # Comment in the public line in if your project uses Gatsby and not Next.js 94 | # https://nextjs.org/blog/next-9-1#public-directory-support 95 | # public 96 | 97 | # vuepress build output 98 | .vuepress/dist 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | 112 | # Stores VSCode versions used for testing VSCode extensions 113 | .vscode-test 114 | 115 | # yarn v2 116 | .yarn/cache 117 | .yarn/unplugged 118 | .yarn/build-state.yml 119 | .yarn/install-state.gz 120 | .pnp.* 121 | 122 | 123 | ### https://raw.github.com/github/gitignore/b0012e4930d0a8c350254a3caeedf7441ea286a3/Python.gitignore 124 | 125 | # Byte-compiled / optimized / DLL files 126 | __pycache__/ 127 | *.py[cod] 128 | *$py.class 129 | 130 | # C extensions 131 | *.so 132 | 133 | # Distribution / packaging 134 | .Python 135 | build/ 136 | develop-eggs/ 137 | dist/ 138 | downloads/ 139 | eggs/ 140 | .eggs/ 141 | lib/ 142 | lib64/ 143 | parts/ 144 | sdist/ 145 | var/ 146 | wheels/ 147 | share/python-wheels/ 148 | *.egg-info/ 149 | .installed.cfg 150 | *.egg 151 | MANIFEST 152 | 153 | # PyInstaller 154 | # Usually these files are written by a python script from a template 155 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 156 | *.manifest 157 | *.spec 158 | 159 | # Installer logs 160 | pip-log.txt 161 | pip-delete-this-directory.txt 162 | 163 | # Unit test / coverage reports 164 | htmlcov/ 165 | .tox/ 166 | .nox/ 167 | .coverage 168 | .coverage.* 169 | .cache 170 | nosetests.xml 171 | coverage.xml 172 | *.cover 173 | *.py,cover 174 | .hypothesis/ 175 | .pytest_cache/ 176 | cover/ 177 | 178 | # Translations 179 | *.mo 180 | *.pot 181 | 182 | # Django stuff: 183 | *.log 184 | local_settings.py 185 | db.sqlite3 186 | db.sqlite3-journal 187 | 188 | # Flask stuff: 189 | instance/ 190 | .webassets-cache 191 | 192 | # Scrapy stuff: 193 | .scrapy 194 | 195 | # Sphinx documentation 196 | docs/_build/ 197 | 198 | # PyBuilder 199 | .pybuilder/ 200 | target/ 201 | 202 | # Jupyter Notebook 203 | .ipynb_checkpoints 204 | 205 | # IPython 206 | profile_default/ 207 | ipython_config.py 208 | 209 | # pyenv 210 | # For a library or package, you might want to ignore these files since the code is 211 | # intended to run in multiple environments; otherwise, check them in: 212 | # .python-version 213 | 214 | # pipenv 215 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 216 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 217 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 218 | # install all needed dependencies. 219 | #Pipfile.lock 220 | 221 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 222 | __pypackages__/ 223 | 224 | # Celery stuff 225 | celerybeat-schedule 226 | celerybeat.pid 227 | 228 | # SageMath parsed files 229 | *.sage.py 230 | 231 | # Environments 232 | .env 233 | .venv 234 | env/ 235 | venv/ 236 | ENV/ 237 | env.bak/ 238 | venv.bak/ 239 | 240 | # Spyder project settings 241 | .spyderproject 242 | .spyproject 243 | 244 | # Rope project settings 245 | .ropeproject 246 | 247 | # mkdocs documentation 248 | /site 249 | 250 | # mypy 251 | .mypy_cache/ 252 | .dmypy.json 253 | dmypy.json 254 | 255 | # Pyre type checker 256 | .pyre/ 257 | 258 | # pytype static type analyzer 259 | .pytype/ 260 | 261 | # Cython debug symbols 262 | cython_debug/ 263 | 264 | 265 | ### https://raw.github.com/github/gitignore/b0012e4930d0a8c350254a3caeedf7441ea286a3/Global/macOS.gitignore 266 | 267 | # General 268 | .DS_Store 269 | .AppleDouble 270 | .LSOverride 271 | 272 | # Icon must end with two \r 273 | Icon 274 | 275 | # Thumbnails 276 | ._* 277 | 278 | # Files that might appear in the root of a volume 279 | .DocumentRevisions-V100 280 | .fseventsd 281 | .Spotlight-V100 282 | .TemporaryItems 283 | .Trashes 284 | .VolumeIcon.icns 285 | .com.apple.timemachine.donotpresent 286 | 287 | # Directories potentially created on remote AFP share 288 | .AppleDB 289 | .AppleDesktop 290 | Network Trash Folder 291 | Temporary Items 292 | .apdisk 293 | 294 | 295 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Distribution / packaging 2 | .Python 3 | env/ 4 | build/ 5 | develop-eggs/ 6 | dist/ 7 | downloads/ 8 | eggs/ 9 | .eggs/ 10 | lib/ 11 | lib64/ 12 | parts/ 13 | sdist/ 14 | var/ 15 | *.egg-info/ 16 | .installed.cfg 17 | *.egg 18 | 19 | # Serverless directories 20 | .serverless -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | based_on_style = pep8 3 | spaces_before_comment = 4 4 | split_before_logical_operator = true -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | python-dateutil = "*" 8 | defusedxml = "*" 9 | 10 | [dev-packages] 11 | yapf = "*" 12 | flake8 = "*" 13 | pytest = "*" 14 | python-dotenv = "*" 15 | pytest-mock = "*" 16 | 17 | [requires] 18 | python_version = "3.9" 19 | 20 | [scripts] 21 | format = "yapf -r -i ./handler.py" 22 | lint = "flake8 --show-source ./handler.py" 23 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "52ed8fd50ddf949b2e96a60c22f9f4b208aa3f60dee0f63add4c3ebb7a37bbc5" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "defusedxml": { 20 | "hashes": [ 21 | "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", 22 | "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" 23 | ], 24 | "index": "pypi", 25 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 26 | "version": "==0.7.1" 27 | }, 28 | "python-dateutil": { 29 | "hashes": [ 30 | "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", 31 | "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" 32 | ], 33 | "index": "pypi", 34 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 35 | "version": "==2.9.0.post0" 36 | }, 37 | "six": { 38 | "hashes": [ 39 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 40 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 41 | ], 42 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 43 | "version": "==1.16.0" 44 | } 45 | }, 46 | "develop": { 47 | "exceptiongroup": { 48 | "hashes": [ 49 | "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", 50 | "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88" 51 | ], 52 | "markers": "python_version >= '3.7'", 53 | "version": "==1.3.0" 54 | }, 55 | "flake8": { 56 | "hashes": [ 57 | "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", 58 | "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872" 59 | ], 60 | "index": "pypi", 61 | "markers": "python_version >= '3.9'", 62 | "version": "==7.3.0" 63 | }, 64 | "importlib-metadata": { 65 | "hashes": [ 66 | "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb", 67 | "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743" 68 | ], 69 | "markers": "python_version >= '3.8'", 70 | "version": "==6.8.0" 71 | }, 72 | "iniconfig": { 73 | "hashes": [ 74 | "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", 75 | "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" 76 | ], 77 | "markers": "python_version >= '3.8'", 78 | "version": "==2.1.0" 79 | }, 80 | "mccabe": { 81 | "hashes": [ 82 | "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", 83 | "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" 84 | ], 85 | "markers": "python_version >= '3.6'", 86 | "version": "==0.7.0" 87 | }, 88 | "packaging": { 89 | "hashes": [ 90 | "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", 91 | "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" 92 | ], 93 | "markers": "python_version >= '3.8'", 94 | "version": "==25.0" 95 | }, 96 | "platformdirs": { 97 | "hashes": [ 98 | "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", 99 | "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" 100 | ], 101 | "markers": "python_version >= '3.8'", 102 | "version": "==4.3.6" 103 | }, 104 | "pluggy": { 105 | "hashes": [ 106 | "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", 107 | "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" 108 | ], 109 | "markers": "python_version >= '3.9'", 110 | "version": "==1.6.0" 111 | }, 112 | "pycodestyle": { 113 | "hashes": [ 114 | "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", 115 | "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d" 116 | ], 117 | "markers": "python_version >= '3.9'", 118 | "version": "==2.14.0" 119 | }, 120 | "pyflakes": { 121 | "hashes": [ 122 | "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", 123 | "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f" 124 | ], 125 | "markers": "python_version >= '3.9'", 126 | "version": "==3.4.0" 127 | }, 128 | "pygments": { 129 | "hashes": [ 130 | "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", 131 | "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" 132 | ], 133 | "markers": "python_version >= '3.8'", 134 | "version": "==2.19.2" 135 | }, 136 | "pytest": { 137 | "hashes": [ 138 | "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", 139 | "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" 140 | ], 141 | "index": "pypi", 142 | "markers": "python_version >= '3.9'", 143 | "version": "==8.4.2" 144 | }, 145 | "pytest-mock": { 146 | "hashes": [ 147 | "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", 148 | "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f" 149 | ], 150 | "index": "pypi", 151 | "markers": "python_version >= '3.9'", 152 | "version": "==3.15.1" 153 | }, 154 | "python-dotenv": { 155 | "hashes": [ 156 | "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", 157 | "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab" 158 | ], 159 | "index": "pypi", 160 | "markers": "python_version >= '3.9'", 161 | "version": "==1.1.1" 162 | }, 163 | "tomli": { 164 | "hashes": [ 165 | "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", 166 | "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", 167 | "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", 168 | "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", 169 | "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", 170 | "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", 171 | "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", 172 | "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", 173 | "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", 174 | "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", 175 | "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", 176 | "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", 177 | "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", 178 | "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", 179 | "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", 180 | "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", 181 | "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", 182 | "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", 183 | "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", 184 | "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", 185 | "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", 186 | "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", 187 | "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", 188 | "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", 189 | "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", 190 | "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", 191 | "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", 192 | "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", 193 | "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", 194 | "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", 195 | "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", 196 | "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7" 197 | ], 198 | "markers": "python_version >= '3.8'", 199 | "version": "==2.2.1" 200 | }, 201 | "typing-extensions": { 202 | "hashes": [ 203 | "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", 204 | "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" 205 | ], 206 | "markers": "python_version >= '3.9'", 207 | "version": "==4.15.0" 208 | }, 209 | "yapf": { 210 | "hashes": [ 211 | "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", 212 | "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca" 213 | ], 214 | "index": "pypi", 215 | "markers": "python_version >= '3.7'", 216 | "version": "==0.43.0" 217 | }, 218 | "zipp": { 219 | "hashes": [ 220 | "sha256:2828e64edb5386ea6a52e7ba7cdb17bb30a73a858f5eb6eb93d8d36f5ea26091", 221 | "sha256:35427f6d5594f4acf82d25541438348c26736fa9b3afa2754bcd63cdb99d8e8f" 222 | ], 223 | "index": "pypi", 224 | "markers": "python_version >= '3.8'", 225 | "version": "==3.19.1" 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YouTube pubsubhubbub lambda 2 | [![Python lint and test](https://github.com/meihei3/youtube-pubsubhubbub-lambda/actions/workflows/python-lint-and-test.yml/badge.svg)](https://github.com/meihei3/youtube-pubsubhubbub-lambda/actions/workflows/python-lint-and-test.yml) 3 | 4 | This application receives notifications from YouTube via PubSubHubBub and runs AWS Lambda. 5 | 6 | For more information on how to use PubSubHubBub(PuSH) and how to receive notifications from YouTube, please refer to [here](https://developers.google.com/youtube/v3/guides/push_notifications). 7 | 8 | ## Require 9 | - pipenv 10 | - serverless framework 11 | 12 | ## Setup & Deploy 13 | 14 | ### 1. Set up pipenv 15 | 16 | ``` 17 | $ pipenv sync 18 | ``` 19 | 20 | ### 2. Set up npm packages 21 | 22 | ``` 23 | $ npm install 24 | ``` 25 | 26 | ### 3. Wrtie .env 27 | 28 | ``` 29 | PuSH_hmac_secret=[your_push_hmac_secret] 30 | ``` 31 | 32 | ### 4. Deploy! 33 | 34 | ``` 35 | $ sls deploy 36 | ``` 37 | 38 | ## Usage 39 | 40 | ### Implement the your process 41 | 42 | By rewriting the `action()` function, you can implement the original process. 43 | -------------------------------------------------------------------------------- /handler.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, asdict 2 | from datetime import datetime 3 | from dateutil.parser import parse as dateutil_parse 4 | from dateutil.tz import gettz as dateutil_gettz 5 | import defusedxml.ElementTree as ET 6 | import hashlib 7 | import hmac 8 | import logging 9 | import os 10 | import re 11 | 12 | logger = logging.getLogger() 13 | logger.setLevel(logging.INFO) 14 | 15 | # YouTube から送られてくる HMAC の16進数の値を取り出すための正規表現 16 | X_HUB_SIGNATURE = re.compile(r"sha1=([0-9a-f]{40})") 17 | 18 | # YouTube から送られてくるXMLデータの名前空間情報 19 | XML_NAMESPACE = { 20 | 'atom': 'http://www.w3.org/2005/Atom', 21 | 'yt': 'http://www.youtube.com/xml/schemas/2015', 22 | } 23 | 24 | # 検証用のキー 25 | try: 26 | HMAC_SECRET: str = os.environ["PuSH_hmac_secret"] 27 | except KeyError: 28 | from dotenv import load_dotenv 29 | load_dotenv() 30 | HMAC_SECRET: str = os.environ["PuSH_hmac_secret"] 31 | except: 32 | logger.error("environment variable not found") 33 | exit() 34 | 35 | 36 | @dataclass(frozen=True) 37 | class RequestChalenge: 38 | challenge: str 39 | 40 | 41 | @dataclass(frozen=True) 42 | class RequestNotify: 43 | x_hub_signature: str 44 | body: str 45 | 46 | 47 | @dataclass(frozen=True) 48 | class Response: 49 | statusCode: int 50 | body: str 51 | 52 | 53 | @dataclass(frozen=True) 54 | class Entry: 55 | videoId: str 56 | channelId: str 57 | title: str 58 | link: str 59 | authorName: str 60 | authorUri: str 61 | published: datetime 62 | updated: datetime 63 | 64 | 65 | def challenge(req: RequestChalenge) -> Response: 66 | """ 67 | challengeのロジック部分 68 | """ 69 | return Response(statusCode=200, body=req.challenge) 70 | 71 | 72 | def notify(req: RequestNotify) -> Response: 73 | """ 74 | notifyのロジック部分 75 | """ 76 | if not (m := X_HUB_SIGNATURE.match(req.x_hub_signature)): 77 | logger.info('verification failed: X_HUB_SIGNATURE is not match.') 78 | logger.info(req) 79 | return Response(200, "success") # チャレンジに失敗しても 2xx success response を返す 80 | 81 | if not validate_hmac(m.groups()[0], req.body, HMAC_SECRET): 82 | logger.info('verification failed: hmac is not match.') 83 | logger.info(req) 84 | return Response(200, "success") # チャレンジに失敗しても 2xx success response を返す 85 | 86 | entry = parse(req.body) 87 | 88 | try: 89 | action(entry) 90 | except Exception as e: 91 | logger.error(e) 92 | # 2xx success 以外で返すと配信が止まるという説もあるので、あえて 2xx success で返しても良さそう 93 | return Response(500, "internal server error") 94 | 95 | return Response(200, "success") 96 | 97 | 98 | def action(entry: Entry): 99 | """ 100 | 動画の通知が来たときに行う処理 101 | """ 102 | logger.info(entry) 103 | 104 | 105 | def validate_hmac(hub_signature: str, msg: str, key: str) -> bool: 106 | digits = hmac.new(key.encode(), msg.encode(), hashlib.sha1).hexdigest() 107 | 108 | return hmac.compare_digest(hub_signature, digits) 109 | 110 | 111 | def parse(text: str) -> Entry: 112 | root = ET.fromstring(text) 113 | entry = root.find("atom:entry", XML_NAMESPACE) 114 | author = entry.find("atom:author", XML_NAMESPACE) 115 | 116 | return Entry(videoId=entry.find("yt:videoId", XML_NAMESPACE).text, 117 | channelId=entry.find("yt:channelId", XML_NAMESPACE).text, 118 | title=entry.find("atom:title", XML_NAMESPACE).text, 119 | link=entry.find("atom:link", XML_NAMESPACE).get("href"), 120 | authorName=author.find("atom:name", XML_NAMESPACE).text, 121 | authorUri=author.find("atom:uri", XML_NAMESPACE).text, 122 | published=dateutil_parse( 123 | entry.find("atom:published", 124 | XML_NAMESPACE).text).astimezone( 125 | dateutil_gettz('Asia/Tokyo')), 126 | updated=dateutil_parse( 127 | entry.find("atom:updated", 128 | XML_NAMESPACE).text).astimezone( 129 | dateutil_gettz('Asia/Tokyo'))) 130 | 131 | 132 | def get_handler(event, context): 133 | """ 134 | GET /hub 135 | """ 136 | logger.info(event) # for debug 137 | params: dict = event.get("queryStringParameters", {}) 138 | req = RequestChalenge(challenge=params.get("hub.challenge", "")) 139 | 140 | res = challenge(req) 141 | 142 | return asdict(res) 143 | 144 | 145 | def post_handler(event, context): 146 | """ 147 | POST /hub 148 | """ 149 | logger.info(event) # for debug 150 | headers: dict = event.get("headers", {}) 151 | req = RequestNotify(x_hub_signature=headers.get("x-hub-signature", ""), 152 | body=event.get("body", "")) 153 | 154 | res = notify(req) 155 | 156 | return asdict(res) 157 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "serverless-dotenv-plugin": "^6.0.0", 4 | "serverless-python-requirements": "^6.1.2" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Serverless! 2 | # 3 | # This file is the main config file for your service. 4 | # It's very minimal at this point and uses default values. 5 | # You can always add more config options for more control. 6 | # We've included some commented out config examples here. 7 | # Just uncomment any of them to get that config option. 8 | # 9 | # For full config options, check the docs: 10 | # docs.serverless.com 11 | # 12 | # Happy Coding! 13 | 14 | service: youtube-pubsubhubbub-lambda 15 | 16 | frameworkVersion: '2' 17 | 18 | provider: 19 | name: aws 20 | runtime: python3.9 21 | lambdaHashingVersion: 20201221 22 | stage: dev 23 | region: ap-northeast-1 24 | logRetentionInDays: 30 25 | environment: 26 | PuSH_hmac_secret: ${env:PuSH_hmac_secret} 27 | 28 | functions: 29 | challenge: 30 | handler: handler.get_handler 31 | events: 32 | - httpApi: 33 | # /callback-[a random URL-safe text string(32bytes)] 34 | path: /callback-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 35 | method: get 36 | 37 | notify: 38 | handler: handler.post_handler 39 | events: 40 | - httpApi: 41 | # /callback-[a random URL-safe text string(32bytes)] 42 | path: /callback-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 43 | method: post 44 | 45 | plugins: 46 | - serverless-python-requirements 47 | - serverless-dotenv-plugin 48 | -------------------------------------------------------------------------------- /test_hundler.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from dateutil.tz import gettz as dateutil_gettz 3 | from dotenv import load_dotenv 4 | from handler import RequestChalenge, RequestNotify, Response, Entry # 型の読み込み 5 | import handler # 関数の読み込み 6 | import os 7 | 8 | # dotenvの読み込み 9 | load_dotenv() 10 | 11 | # 検証用のキー 12 | HMAC_SECRET: str = os.environ["PuSH_hmac_secret"] 13 | 14 | 15 | def test_challenge(): 16 | challenge = "challenge" 17 | expected = Response(200, challenge) 18 | actual = handler.challenge(RequestChalenge(challenge)) 19 | assert expected == actual 20 | 21 | 22 | def test_validate_hmac(): 23 | assert handler.validate_hmac("0c94515c15e5095b8a87a50ba0df3bf38ed05fe6", "test", "test") 24 | assert not handler.validate_hmac("1c94515c15e5095b8a87a50ba0df3bf38ed05fe6", "test", "test") 25 | 26 | 27 | def test_parse(): 28 | expected = Entry( 29 | videoId="test_videoId", 30 | channelId="test_channelId", 31 | title="test_title", 32 | link="http://example.com/watch?v=test_videoId", 33 | authorName="test_author_name", 34 | authorUri="http://example.com/channel/test_channelId", 35 | published=datetime(2022, 1, 28, 21, 40, 35, tzinfo=dateutil_gettz('Asia/Tokyo')), 36 | updated=datetime(2022, 1, 28, 21, 40, 58, 132027, tzinfo=dateutil_gettz('Asia/Tokyo')) 37 | ) 38 | actual = handler.parse(""" 39 | 40 | 41 | 42 | YouTube video feed 43 | 2022-01-28T12:40:58.132027669+00:00 44 | 45 | yt:video:test_videoId 46 | test_videoId 47 | test_channelId 48 | test_title 49 | 50 | 51 | test_author_name 52 | http://example.com/channel/test_channelId 53 | 54 | 2022-01-28T12:40:35+00:00 55 | 2022-01-28T12:40:58.132027669+00:00 56 | 57 | 58 | """) 59 | assert expected == actual 60 | 61 | 62 | def test_notify(mocker): 63 | req = RequestNotify("test_x_hub_signature", "") 64 | expected = 200 65 | actual = handler.notify(req) 66 | assert expected == actual.statusCode 67 | 68 | req = RequestNotify("sha1=0000111122223333444455556666777788889999", "") 69 | expected = 200 70 | actual = handler.notify(req) 71 | assert expected == actual.statusCode 72 | 73 | mocker.patch("handler.validate_hmac", return_value=True) 74 | mocker.patch("handler.parse", return_value="") 75 | expected = 200 76 | req = RequestNotify("sha1=0000111122223333444455556666777788889999", "") 77 | actual = handler.notify(req) 78 | assert expected == actual.statusCode 79 | 80 | mocker.patch("handler.action", side_effect=Exception) 81 | expected = 500 82 | req = RequestNotify("sha1=0000111122223333444455556666777788889999", "") 83 | actual = handler.notify(req) 84 | assert expected == actual.statusCode 85 | --------------------------------------------------------------------------------