diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index 25bc3d7..0000000 --- a/.deepsource.toml +++ /dev/null @@ -1,8 +0,0 @@ -version = 1 - -[[analyzers]] -name = "python" -enabled = true - - [analyzers.meta] - runtime_version = "3.x.x" diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..47bffef --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,69 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - master + tags: + - 'v*' + pull_request: + branches: + - master + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + id: build + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efdf659 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# Virtual environment +.venv/ +venv/ +ENV/ + +# Environment variables +.env +*.env + +# Alembic migration cache +alembic/versions/__pycache__/ +alembic.ini + +# Database files +*.sqlite3 +*.db + +# Docker +*.pid + +# VSCode +.vscode/ + +# JetBrains +.idea/ + +# OS generated files +.DS_Store +Thumbs.db + +# Logs +*.log + +# Python egg +*.egg-info/ + +dist/ +build/ + +# Coverage +.coverage +htmlcov/ + +# Lint/format +.mypy_cache/ +.pytest_cache/ + +# Misc +*.bak +*.swp + +# Ignore Alembic generated files except env.py and README +alembic/versions/* +!alembic/README +!alembic/env.py + diff --git a/Dockerfile b/Dockerfile index ff587ac..d1af7b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,19 @@ -FROM python:3 +FROM python:3.12-slim -ENV PYTHONNUNBUFFERED 1 +WORKDIR /app -ENV DISCORD_TOKEN YOUR_DISCORD_TOKEN -ENV DISCORD_GUILD YOUR_DISCORD_GUILD -ENV REDDIT_USER YOUR_REDDIT_USER -ENV REDDIT_PASSWORD YOUR_REDDIT_PASSWORD -ENV CLIENT_ID YOUR_CLIENT_ID -ENV CLIENT_SECRET YOUR_CLIENT_SECRET -ENV ADMIN_ROLE_ID YOUR_ADMIN_ROLE_ID -ENV CHANNEL_TO_POST_ID YOUR_CHANNEL_TO_POST_ID -ENV CHANNEL_LOGS_ID YOUR_CHANNEL_LOGS_ID +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* -RUN mkdir /bot-code -WORKDIR /bot-code -ADD . /bot-code +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt -RUN apt-get update -RUN pip install -r requirements.txt +COPY . . + +ENV PYTHONUNBUFFERED=1 + +CMD ["python", "main.py"] -CMD ["main.py"] -ENTRYPOINT ["python3"] diff --git a/Dockerfile_EXAMPLE b/Dockerfile_EXAMPLE deleted file mode 100644 index a4112a3..0000000 --- a/Dockerfile_EXAMPLE +++ /dev/null @@ -1,31 +0,0 @@ -FROM python:3 - -ENV PYTHONNUNBUFFERED 1 - -ENV DISCORD_TOKEN YOUR_DISCORD_TOKEN -ENV REDDIT_USER YOUR_REDDIT_USER -ENV REDDIT_PASSWORD YOUR_REDDIT_PASSWORD -ENV CLIENT_ID YOUR_CLIENT_ID -ENV CLIENT_SECRET YOUR_CLIENT_SECRET -ENV MONGO_HOST YOUR_MONGO_HOST -ENV MONGO_PORT 27017 -ENV MONGO_USER YOUR_MONGO_USER -ENV MONGO_PASSWORD YOUR_MONGO_PASSWORD -ENV MONGO_DB YOUR_MONGO_DB -ENV MAX_CONNECTIONS_COUNT 10 -ENV MIN_CONNECTIONS_COUNT 10 -ENV DEXTER_ID 264457430350561281 -ENV DEXTER_DISCORD_GUILD_ID 704342941774118932 -ENV DEXTER_ADMIN_ROLE_ID 706520525182075020 -ENV DEXTER_CHANNEL_LOGS_ID 811254175660638258 - - -RUN mkdir /bot-code -WORKDIR /bot-code -ADD . /bot-code - -RUN apt-get update -RUN pip install -r requirements.txt - -CMD ["main.py"] -ENTRYPOINT ["python3"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 51f0c3f..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 Dexter - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/Makefile b/Makefile deleted file mode 100644 index 367a3b6..0000000 --- a/Makefile +++ /dev/null @@ -1,14 +0,0 @@ -start: - python3 main.py - -load: - source envs/local.env - -shell: - pipenv shell - -install: - pipenv install - -requirements: - pip install -r requirements.txt \ No newline at end of file diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 923c597..0000000 --- a/Pipfile +++ /dev/null @@ -1,18 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] -asyncpraw = "==7.2.0" -discord = "*" -typing-extensions = "*" -autoflake = "*" -isort = "*" -black = "*" -vulture = "*" - -[requires] -python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index e4c4022..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,415 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "43193d5dc210ffedaf13ba3d40e66c882864d5933a540510c8274fc2e6b97c86" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.6" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "aiofiles": { - "hashes": [ - "sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27", - "sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092" - ], - "version": "==0.6.0" - }, - "aiohttp": { - "hashes": [ - "sha256:119feb2bd551e58d83d1b38bfa4cb921af8ddedec9fad7183132db334c3133e0", - "sha256:16d0683ef8a6d803207f02b899c928223eb219111bd52420ef3d7a8aa76227b6", - "sha256:2eb3efe243e0f4ecbb654b08444ae6ffab37ac0ef8f69d3a2ffb958905379daf", - "sha256:2ffea7904e70350da429568113ae422c88d2234ae776519549513c8f217f58a9", - "sha256:40bd1b101b71a18a528ffce812cc14ff77d4a2a1272dfb8b11b200967489ef3e", - "sha256:418597633b5cd9639e514b1d748f358832c08cd5d9ef0870026535bd5eaefdd0", - "sha256:481d4b96969fbfdcc3ff35eea5305d8565a8300410d3d269ccac69e7256b1329", - "sha256:4c1bdbfdd231a20eee3e56bd0ac1cd88c4ff41b64ab679ed65b75c9c74b6c5c2", - "sha256:5563ad7fde451b1986d42b9bb9140e2599ecf4f8e42241f6da0d3d624b776f40", - "sha256:58c62152c4c8731a3152e7e650b29ace18304d086cb5552d317a54ff2749d32a", - "sha256:5b50e0b9460100fe05d7472264d1975f21ac007b35dcd6fd50279b72925a27f4", - "sha256:5d84ecc73141d0a0d61ece0742bb7ff5751b0657dab8405f899d3ceb104cc7de", - "sha256:5dde6d24bacac480be03f4f864e9a67faac5032e28841b00533cd168ab39cad9", - "sha256:5e91e927003d1ed9283dee9abcb989334fc8e72cf89ebe94dc3e07e3ff0b11e9", - "sha256:62bc216eafac3204877241569209d9ba6226185aa6d561c19159f2e1cbb6abfb", - "sha256:6c8200abc9dc5f27203986100579fc19ccad7a832c07d2bc151ce4ff17190076", - "sha256:6ca56bdfaf825f4439e9e3673775e1032d8b6ea63b8953d3812c71bd6a8b81de", - "sha256:71680321a8a7176a58dfbc230789790639db78dad61a6e120b39f314f43f1907", - "sha256:7c7820099e8b3171e54e7eedc33e9450afe7cd08172632d32128bd527f8cb77d", - "sha256:7dbd087ff2f4046b9b37ba28ed73f15fd0bc9f4fdc8ef6781913da7f808d9536", - "sha256:822bd4fd21abaa7b28d65fc9871ecabaddc42767884a626317ef5b75c20e8a2d", - "sha256:8ec1a38074f68d66ccb467ed9a673a726bb397142c273f90d4ba954666e87d54", - "sha256:950b7ef08b2afdab2488ee2edaff92a03ca500a48f1e1aaa5900e73d6cf992bc", - "sha256:99c5a5bf7135607959441b7d720d96c8e5c46a1f96e9d6d4c9498be8d5f24212", - "sha256:b84ad94868e1e6a5e30d30ec419956042815dfaea1b1df1cef623e4564c374d9", - "sha256:bc3d14bf71a3fb94e5acf5bbf67331ab335467129af6416a437bd6024e4f743d", - "sha256:c2a80fd9a8d7e41b4e38ea9fe149deed0d6aaede255c497e66b8213274d6d61b", - "sha256:c44d3c82a933c6cbc21039326767e778eface44fca55c65719921c4b9661a3f7", - "sha256:cc31e906be1cc121ee201adbdf844522ea3349600dd0a40366611ca18cd40e81", - "sha256:d5d102e945ecca93bcd9801a7bb2fa703e37ad188a2f81b1e65e4abe4b51b00c", - "sha256:dd7936f2a6daa861143e376b3a1fb56e9b802f4980923594edd9ca5670974895", - "sha256:dee68ec462ff10c1d836c0ea2642116aba6151c6880b688e56b4c0246770f297", - "sha256:e76e78863a4eaec3aee5722d85d04dcbd9844bc6cd3bfa6aa880ff46ad16bfcb", - "sha256:eab51036cac2da8a50d7ff0ea30be47750547c9aa1aa2cf1a1b710a1827e7dbe", - "sha256:f4496d8d04da2e98cc9133e238ccebf6a13ef39a93da2e87146c8c8ac9768242", - "sha256:fbd3b5e18d34683decc00d9a360179ac1e7a320a5fee10ab8053ffd6deab76e0", - "sha256:feb24ff1226beeb056e247cf2e24bba5232519efb5645121c4aea5b6ad74c1f2" - ], - "markers": "python_version >= '3.6'", - "version": "==3.7.4" - }, - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "version": "==1.4.4" - }, - "async-timeout": { - "hashes": [ - "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", - "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" - ], - "markers": "python_full_version >= '3.5.3'", - "version": "==3.0.1" - }, - "asyncpraw": { - "hashes": [ - "sha256:7890414f0c5f8f3747dfe8a4a1ca3348064ef124f17b744cf0d12f8e202d38cd", - "sha256:817a4881ce84bda17a207f93156419825c7934b227244a8f05bdfcf557134181" - ], - "index": "pypi", - "version": "==7.2.0" - }, - "asyncprawcore": { - "hashes": [ - "sha256:79ac9b4518888d877e19e88d8685dea619f7ae59a41bde19724fcf529d24870e", - "sha256:d800228a8009644d42d01d1adf16379f41f661a6344dc1fe07060a3abe4354e5" - ], - "markers": "python_version >= '3.6'", - "version": "==2.0.1" - }, - "attrs": { - "hashes": [ - "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", - "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.3.0" - }, - "autoflake": { - "hashes": [ - "sha256:61a353012cff6ab94ca062823d1fb2f692c4acda51c76ff83a8d77915fba51ea" - ], - "index": "pypi", - "version": "==1.4" - }, - "black": { - "hashes": [ - "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea" - ], - "index": "pypi", - "version": "==20.8b1" - }, - "certifi": { - "hashes": [ - "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", - "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" - ], - "version": "==2020.12.5" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "click": { - "hashes": [ - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" - }, - "discord": { - "hashes": [ - "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559", - "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429" - ], - "index": "pypi", - "version": "==1.0.1" - }, - "discord.py": { - "hashes": [ - "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12", - "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f" - ], - "markers": "python_full_version >= '3.5.3'", - "version": "==1.6.0" - }, - "idna": { - "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.10" - }, - "isort": { - "hashes": [ - "sha256:c729845434366216d320e936b8ad6f9d681aab72dc7cbc2d51bedc3582f3ad1e", - "sha256:fff4f0c04e1825522ce6949973e83110a6e907750cd92d128b0d14aaaadbffdc" - ], - "index": "pypi", - "version": "==5.7.0" - }, - "multidict": { - "hashes": [ - "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a", - "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93", - "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632", - "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656", - "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79", - "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7", - "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d", - "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5", - "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224", - "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26", - "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea", - "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348", - "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6", - "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76", - "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1", - "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f", - "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952", - "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a", - "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37", - "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9", - "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359", - "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8", - "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da", - "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3", - "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d", - "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf", - "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841", - "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d", - "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93", - "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f", - "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647", - "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635", - "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456", - "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda", - "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5", - "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", - "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" - ], - "markers": "python_version >= '3.6'", - "version": "==5.1.0" - }, - "mypy-extensions": { - "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" - ], - "version": "==0.4.3" - }, - "pathspec": { - "hashes": [ - "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd", - "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d" - ], - "version": "==0.8.1" - }, - "pyflakes": { - "hashes": [ - "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", - "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.2.0" - }, - "regex": { - "hashes": [ - "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538", - "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4", - "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc", - "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa", - "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444", - "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1", - "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af", - "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8", - "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9", - "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88", - "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba", - "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364", - "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e", - "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7", - "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0", - "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31", - "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683", - "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee", - "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b", - "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884", - "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c", - "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e", - "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562", - "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85", - "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c", - "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6", - "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d", - "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b", - "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70", - "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b", - "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b", - "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f", - "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0", - "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5", - "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5", - "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f", - "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e", - "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512", - "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d", - "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917", - "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f" - ], - "version": "==2020.11.13" - }, - "requests": { - "hashes": [ - "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", - "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.25.1" - }, - "toml": { - "hashes": [ - "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", - "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.10.2" - }, - "typed-ast": { - "hashes": [ - "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1", - "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d", - "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6", - "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd", - "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37", - "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151", - "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07", - "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440", - "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70", - "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496", - "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea", - "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400", - "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc", - "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606", - "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc", - "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581", - "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412", - "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a", - "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2", - "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787", - "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f", - "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937", - "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64", - "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487", - "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b", - "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41", - "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a", - "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3", - "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166", - "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10" - ], - "version": "==1.4.2" - }, - "typing-extensions": { - "hashes": [ - "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", - "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", - "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" - ], - "index": "pypi", - "version": "==3.7.4.3" - }, - "update-checker": { - "hashes": [ - "sha256:6a2d45bb4ac585884a6b03f9eade9161cedd9e8111545141e9aa9058932acb13", - "sha256:cbba64760a36fe2640d80d85306e8fe82b6816659190993b7bdabadee4d4bbfd" - ], - "version": "==0.18.0" - }, - "urllib3": { - "hashes": [ - "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", - "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.3" - }, - "vulture": { - "hashes": [ - "sha256:03d5a62bcbe9ceb9a9b0575f42d71a2d414070229f2e6f95fa6e7c71aaaed967", - "sha256:f39de5e6f1df1f70c3b50da54f1c8d494159e9ca3d01a9b89eac929600591703" - ], - "index": "pypi", - "version": "==2.3" - }, - "yarl": { - "hashes": [ - "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e", - "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434", - "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366", - "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3", - "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec", - "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959", - "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e", - "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c", - "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6", - "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a", - "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6", - "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424", - "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e", - "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f", - "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50", - "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2", - "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc", - "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4", - "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970", - "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10", - "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0", - "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406", - "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896", - "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643", - "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721", - "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478", - "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724", - "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e", - "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8", - "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96", - "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25", - "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76", - "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2", - "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2", - "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c", - "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", - "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" - ], - "markers": "python_version >= '3.6'", - "version": "==1.6.3" - } - }, - "develop": {} -} diff --git a/README.md b/README.md index 4284c44..55b57cb 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,74 @@ -# GoGIG - A Reddit Job Finder As A Discord Bot -

- -

+# GoGig +

- - Gitmoji - - Code style: black + GoGig Logo

-A Discord bot that is using AsyncPRAW, a Reddit API to find jobs on subreddits and post them to your Discord channel. -I created this bot for programmers so feel free to edit keywords list and subreddit list for your requirements. - -![demo](img/demo.gif) -## Setup -You'll need to create: - - Reddit Applications: https://www.reddit.com/prefs/apps - - Discord Bot: https://discord.com/developers/applications - -Create a local.env and update with your details: -```bash -make envs -``` +GoGig is a Discord bot project designed to automate job searching and subreddit/keyword management using Reddit and PostgreSQL. It features modular command handling, database-backed configuration, and supports Docker-based deployment. -## Usage with Pipenv +## Features +- Discord bot with job search automation +- Subreddit and keyword management (add/list/remove via commands) +- SQLAlchemy ORM models and Alembic migrations +- PostgreSQL database integration +- Docker and Docker Compose support +- Modular command structure (cogs) -```bash -make install && make shell -make load -make start -``` +## Getting Started + +### Prerequisites +- Docker & Docker Compose +- Python 3.12+ -## Usage with Requirements.txt -```bash -make requirements -make load -make start +### Setup +1. Clone the repository: + ```bash + git clone https://github.com/yourusername/gogig.git + cd gogig + ``` +2. Copy and edit environment variables: + ```bash + cp sample.env .env + # Edit .env as needed + ``` +3. Build and start with Docker Compose: + ```bash + docker-compose up --build + # or + docker-compose -f default.docker-compose.yml up --build + ``` + +### Database Migrations +- Alembic is used for migrations: + ```bash + alembic upgrade head + alembic revision --autogenerate -m "Your migration message" + ``` + +## Project Structure +``` +GoGig/ +├── alembic/ # Alembic migration scripts +├── cogs/ # Discord bot command modules +├── config/ # Configuration and database setup +├── models/ # SQLAlchemy ORM models +├── services/ # Business logic/services +├── main.py # Bot entry point +├── Dockerfile # Docker build file +├── default.docker-compose.yml # Docker Compose with PostgreSQL +├── requirements.txt # Python dependencies +└── README.md # Project documentation ``` +## Useful Commands +- Add/list/remove subreddits and keywords via Discord bot commands +- Run linter: + ```bash + ./lint.sh + ``` + ## Contributing Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. -Please make sure to test before pull request. - ## License -[MIT](https://choosealicense.com/licenses/mit/) +MIT diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..53cbfdf --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +import importlib +import pathlib + +models_path = pathlib.Path(__file__).parent.parent / "models" +for model_file in models_path.glob("*.py"): + if model_file.name == "__init__.py": + continue + module_name = f"models.{model_file.stem}" + importlib.import_module(module_name) + +from config import database + +target_metadata = database.Base.metadata + + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/e25a3318ad99_initial_migration.py b/alembic/versions/e25a3318ad99_initial_migration.py new file mode 100644 index 0000000..7d5f0b4 --- /dev/null +++ b/alembic/versions/e25a3318ad99_initial_migration.py @@ -0,0 +1,105 @@ +"""Initial migration + +Revision ID: e25a3318ad99 +Revises: +Create Date: 2025-07-24 17:27:27.901916 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e25a3318ad99" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "jobs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("uuid", sa.UUID(), nullable=True), + sa.Column("reddit_id", sa.String(length=50), nullable=False), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("content", sa.Text(), nullable=True), + sa.Column("author", sa.String(length=100), nullable=False), + sa.Column("subreddit", sa.String(length=100), nullable=False), + sa.Column("url", sa.String(length=1000), nullable=False), + sa.Column("created_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("posted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_jobs_reddit_id"), "jobs", ["reddit_id"], unique=True) + op.create_index(op.f("ix_jobs_uuid"), "jobs", ["uuid"], unique=True) + op.create_table( + "keywords", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("word", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("word"), + ) + op.create_table( + "subreddits", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("uuid", sa.UUID(), nullable=True), + sa.Column("discord_id", sa.String(length=50), nullable=False), + sa.Column("username", sa.String(length=100), nullable=False), + sa.Column("is_admin", sa.Boolean(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_discord_id"), "users", ["discord_id"], unique=True) + op.create_index(op.f("ix_users_uuid"), "users", ["uuid"], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_users_uuid"), table_name="users") + op.drop_index(op.f("ix_users_discord_id"), table_name="users") + op.drop_table("users") + op.drop_table("subreddits") + op.drop_table("keywords") + op.drop_index(op.f("ix_jobs_uuid"), table_name="jobs") + op.drop_index(op.f("ix_jobs_reddit_id"), table_name="jobs") + op.drop_table("jobs") + # ### end Alembic commands ### diff --git a/__init__.py b/cogs/__init__.py similarity index 100% rename from __init__.py rename to cogs/__init__.py diff --git a/cogs/admin_commands.py b/cogs/admin_commands.py new file mode 100644 index 0000000..c9e0402 --- /dev/null +++ b/cogs/admin_commands.py @@ -0,0 +1,135 @@ +import discord +from discord.ext import commands +from sqlalchemy import delete, func, select + +from config.database import db_manager +from models.job import Job +from models.keyword import Keyword +from models.subreddit import Subreddit + + +class AdminCommands(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @commands.command(name="stats") + @commands.has_permissions(administrator=True) + async def stats(self, ctx): + """Show bot statistics""" + async with db_manager.get_session() as session: + embed = discord.Embed(title="Bot Statistics", color=discord.Color.gold()) + embed.add_field(name="Servers", value=len(self.bot.guilds), inline=True) + embed.add_field(name="Users", value=len(self.bot.users), inline=True) + embed.add_field( + name="Active Jobs", + value=await session.execute( + select(func.count(Job.id)).where(Job.is_posted is True) + ), + inline=True, + ) + embed.add_field( + name="Subreddits", + value=await session.execute(select(func.count(Subreddit.id))), + inline=True, + ) + embed.add_field( + name="Keywords", + value=await session.execute(select(func.count(Keyword.id))), + inline=True, + ) + embed.set_footer( + text=f"Requested by {ctx.author}", + icon_url=( + ctx.author.avatar.url + if ctx.author.avatar + else ctx.author.default_avatar.url + ), + ) + + await ctx.send(embed=embed) + + @commands.command(name="reload") + @commands.has_permissions(administrator=True) + async def reload_cog(self, ctx, cog_name: str): + """Reload a cog""" + try: + await self.bot.reload_extension(f"cogs.{cog_name}") + await ctx.send(f"✅ Reloaded cog: {cog_name}") + except Exception as e: + await ctx.send(f"❌ Failed to reload cog: {e}") + + @commands.command(name="add_subreddits") + @commands.has_permissions(administrator=True) + async def add_subreddits(self, ctx, *, names: str): + """Add one or more subreddits (comma or space separated)""" + items = [n.strip() for n in names.replace(",", " ").split() if n.strip()] + async with db_manager.get_session() as session: + added = [] + for name in items: + exists = await session.execute( + select(Subreddit).where(Subreddit.name == name) + ) + if not exists.scalar(): + session.add(Subreddit(name=name)) + added.append(name) + await session.commit() + await ctx.send(f"Added subreddits: {', '.join(added) if added else 'None'}") + + @commands.command(name="list_subreddits") + @commands.has_permissions(administrator=True) + async def list_subreddits(self, ctx): + """List all subreddits""" + async with db_manager.get_session() as session: + result = await session.execute(select(Subreddit)) + names = [s.name for s in result.scalars()] + await ctx.send(f"Subreddits: {', '.join(names) if names else 'None'}") + + @commands.command(name="remove_subreddits") + @commands.has_permissions(administrator=True) + async def remove_subreddits(self, ctx, *, names: str): + """Remove one or more subreddits (comma or space separated)""" + items = [n.strip() for n in names.replace(",", " ").split() if n.strip()] + async with db_manager.get_session() as session: + await session.execute(delete(Subreddit).where(Subreddit.name.in_(items))) + await session.commit() + await ctx.send(f"Removed subreddits: {', '.join(items)}") + + @commands.command(name="add_keywords") + @commands.has_permissions(administrator=True) + async def add_keywords(self, ctx, *, words: str): + """Add one or more keywords (comma or space separated)""" + items = [w.strip() for w in words.replace(",", " ").split() if w.strip()] + async with db_manager.get_session() as session: + added = [] + for word in items: + exists = await session.execute( + select(Keyword).where(Keyword.word == word) + ) + if not exists.scalar(): + session.add(Keyword(word=word)) + added.append(word) + await session.commit() + await ctx.send(f"Added keywords: {', '.join(added) if added else 'None'}") + + @commands.command(name="list_keywords") + @commands.has_permissions(administrator=True) + async def list_keywords(self, ctx): + """List all keywords""" + async with db_manager.get_session() as session: + result = await session.execute(select(Keyword)) + words = [k.word for k in result.scalars()] + await ctx.send(f"Keywords: {', '.join(words) if words else 'None'}") + + @commands.command(name="remove_keywords") + @commands.has_permissions(administrator=True) + async def remove_keywords(self, ctx, *, words: str): + """Remove one or more keywords (comma or space separated)""" + items = [w.strip() for w in words.replace(",", " ").split() if w.strip()] + async with db_manager.get_session() as session: + await session.execute(delete(Keyword).where(Keyword.word.in_(items))) + await session.commit() + await ctx.send(f"Removed keywords: {', '.join(items)}") + + +async def setup(bot): + await bot.add_cog(AdminCommands(bot)) diff --git a/cogs/job_commands.py b/cogs/job_commands.py new file mode 100644 index 0000000..8fb2dad --- /dev/null +++ b/cogs/job_commands.py @@ -0,0 +1,122 @@ +import logging + +import discord +from discord.ext import commands, tasks +from sqlalchemy import select + +from config.database import db_manager +from config.settings import settings +from models.keyword import Keyword +from models.subreddit import Subreddit +from services.job_service import JobService +from services.reddit_service import RedditService + +logger = logging.getLogger(__name__) + + +class JobCommands(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.reddit_service = RedditService() + self.job_check_loop.start() + + async def cog_load(self): + """Initialize services when cog loads""" + await self.reddit_service.initialize() + + async def cog_unload(self): + """Cleanup when cog unloads""" + self.job_check_loop.cancel() + await self.reddit_service.close() + + @tasks.loop(seconds=settings.CHECK_INTERVAL) + async def job_check_loop(self): + """Periodic job check and posting""" + try: + await self._check_and_post_jobs() + except Exception as e: + logger.error(f"Error in job check loop: {e}") + + @job_check_loop.before_loop + async def before_job_check_loop(self): + """Wait for bot to be ready before starting loop""" + await self.bot.wait_until_ready() + + async def _check_and_post_jobs(self): + """Check for new jobs and post them""" + async with db_manager.get_session() as session: + job_service = JobService(session) + + # Fetch subreddits and keywords from database + subreddits_result = await session.execute(select(Subreddit)) + subreddits = [s.name for s in subreddits_result.scalars()] + keywords_result = await session.execute(select(Keyword)) + keywords = [k.word for k in keywords_result.scalars()] + + # Search for new jobs + async for job in self.reddit_service.search_jobs( + subreddits, keywords, settings.MAX_JOBS_PER_CHECK + ): + # Check if job already exists + existing_job = await job_service.get_job_by_reddit_id(job.reddit_id) + if not existing_job: + # Save new job + await job_service.create_job(job) + + # Post to Discord + await self._post_job_to_discord(job) + await job_service.mark_as_posted(job.id) + + async def _post_job_to_discord(self, job): + """Post job to Discord channel""" + channel = self.bot.get_channel(settings.DISCORD_CHANNEL_ID) + if not channel: + logger.error(f"Channel {settings.DISCORD_CHANNEL_ID} not found") + return + + embed = discord.Embed( + title=job.title, + url=job.url, + description=( + job.content[:500] + "..." if len(job.content) > 500 else job.content + ), + color=discord.Color.green(), + ) + embed.add_field(name="Subreddit", value=f"r/{job.subreddit}", inline=True) + embed.add_field(name="Author", value=f"u/{job.author}", inline=True) + embed.set_footer( + text=f"Posted: {job.created_utc.strftime('%Y-%m-%d %H:%M UTC')}" + ) + + await channel.send(embed=embed) + + @commands.command(name="jobs") + async def list_recent_jobs(self, ctx, limit: int = 5): + """List recent jobs""" + if limit > 20: + limit = 20 + + async with db_manager.get_session() as session: + job_service = JobService(session) + jobs = await job_service.get_recent_jobs(limit) + + if not jobs: + await ctx.send("No jobs found.") + return + + embed = discord.Embed( + title=f"Recent {len(jobs)} Jobs", color=discord.Color.blue() + ) + + for job in jobs: + embed.add_field( + name=job.title[:50] + "..." if len(job.title) > 50 else job.title, + value=f"r/{job.subreddit} • [Link]({job.url})", + inline=False, + ) + + await ctx.send(embed=embed) + + +async def setup(bot): + await bot.add_cog(JobCommands(bot)) diff --git a/cogs/utility_commands.py b/cogs/utility_commands.py new file mode 100644 index 0000000..2e347d7 --- /dev/null +++ b/cogs/utility_commands.py @@ -0,0 +1,30 @@ +import discord +from discord.ext import commands + + +class UtilityCommands(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @commands.command(name="ping") + async def ping(self, ctx): + """Check bot latency""" + latency = round(self.bot.latency * 1000) + await ctx.send(f"🏓 Pong! Latency: {latency}ms") + + @commands.command(name="info") + async def info(self, ctx): + """Show bot information""" + embed = discord.Embed( + title="GoGig Bot", + description="A Discord bot that finds job postings from Reddit and posts them to your server", + color=discord.Color.blue(), + ) + embed.add_field(name="Version", value="2.0", inline=True) + embed.add_field(name="Author", value="Tech-Dex", inline=True) + + await ctx.send(embed=embed) + + +async def setup(bot): + await bot.add_cog(UtilityCommands(bot)) diff --git a/commands.py b/commands.py deleted file mode 100644 index 72aa887..0000000 --- a/commands.py +++ /dev/null @@ -1,280 +0,0 @@ -import time -from datetime import datetime - -import discord -from discord.ext import commands - -from core.config import DEXTER_ADMIN_ROLE_ID, DATABASE_NAME -from db.mongodb import get_database - -SNOOZE = False - - -@commands.command(name="_snooze") -async def snooze(ctx, *args): - global SNOOZE - if discord.utils.get(ctx.message.author.roles, id=DEXTER_ADMIN_ROLE_ID): - if len(args) > 1 or len(args) == 0: - await ctx.send("The command form is: ``` $sudo_snooze or ```") - elif len(args) == 1: - if str(args[0]).lower() == "off": - if SNOOZE is False: - await ctx.send("Snooze is already off!") - else: - SNOOZE = False - await ctx.send("Snooze is off") - elif str(args[0]).lower() == "on": - if SNOOZE is True: - await ctx.send("Snooze is already on!") - else: - SNOOZE = True - await ctx.send("Snooze is on!") - else: - await ctx.send( - "The command form is: ``` $sudo_snooze or ```" - ) - else: - await ctx.send( - "The command is available only for administrators. Please contact one of them!" - ) - - -@commands.command(name="_help") -async def custom_help(ctx): - await ctx.send(embed=embed_help()) - - -def embed_help(): - embed = discord.Embed( - title="Help Page", - color=discord.Colour(0x2ECC71), - url="https://ibb.co/gdqz4ZM", - description="For a better version please check GitHub.", - ) - embed.set_image( - url="https://media.discordapp.net/attachments/811254175660638258/813764726160228352/logo.png" - ) - embed.set_footer( - text="GoGIG Bot " - f'| {time.strftime("%a %b %d, %Y at %H:%M:%S", time.gmtime(time.time()))}' - ) - embed.add_field( - name="_snooze", - value="""Turn on/off option of sending logs about errors. - Type **$sudo_snooze** command for more info.""", - inline=False, - ) - embed.add_field( - name="_subreddit", - value="""Interact with GoGig database for you server. View and edit subreddits that you follow. - Type **$sudo_subreddit** command for more info.""", - inline=False, - ) - embed.add_field( - name="_job_keyword", - value="""Interact with GoGig database for you server. View and edit job keywords that you are interested in. - Type **$sudo_job_keyword** command for more info.""", - inline=False, - ) - embed.add_field( - name="_channel", - value="""Interact with GoGig database for you server. View, set and remove channel for submission - Type **$sudo_channel** command for more info.""", - inline=False, - ) - embed.add_field(name="_help", value="Shows this message", inline=False) - - return embed - - -@commands.command(name="_ping") -async def ping(ctx): - await ctx.send("Stop pinging me. I am alive!") - - -@commands.command(name="_subreddit") -async def subreddit(ctx, *args): - conn = get_database() - if len(args) == 0: - await ctx.send( - "The command form is: " - "```" - "$sudo_subreddit list\n" - '$sudo_subreddit add ["subreddit name",]\n' - '$sudo_subreddit remove ["subreddit name",.]\n' - "```" - ) - elif len(args) == 1: - if str(args[0]).lower() == "list": - subreddit_raw_list = conn[DATABASE_NAME]["subreddit"].find() - message = ( - f"This is the list of subreddits followed in {ctx.author.guild}: ```[" - ) - async for subreddit_obj in subreddit_raw_list: - message += f" {subreddit_obj['subreddit']}," - message = message[:-1] + " ]```" - await ctx.send(message) - elif str(args[0]).lower() == "add": - already_added_subreddits = [] - added_subreddits = [] - docs = [] - for arg in args[1:]: - if await conn[DATABASE_NAME]["subreddit"].find_one({"subreddit": arg}): - already_added_subreddits.append(arg) - continue - subreddit_json = { - "subreddit": arg, - "user": ctx.message.author.name, - "user_id": ctx.message.author.id, - "guild_id": ctx.message.guild.id, - "created_at": datetime.now(), - } - added_subreddits.append(arg) - docs.append(subreddit_json) - if docs: - await conn[DATABASE_NAME]["subreddit"].insert_many(docs) - message_success = ( - f"Subreddits successfully added in {ctx.author.guild}:" - "``` " + ", ".join(added_subreddits) + "```" - ) - await ctx.send(message_success) - else: - await ctx.send("I was unable to add any subreddit.") - if already_added_subreddits: - message_failed = ( - f"Subreddits already added in {ctx.author.guild}:" - "``` " + ", ".join(already_added_subreddits) + "```" - ) - await ctx.send(message_failed) - elif str(args[0]).lower() == "remove": - response = await conn[DATABASE_NAME]["subreddit"].delete_many( - {"guild_id": ctx.message.guild.id, "subreddit": {"$in": args[1:]}} - ) - await ctx.send(f"Successfully deleted {response.deleted_count} subreddits.") - - -@commands.command(name="_job_keyword") -async def job_keyword(ctx, *args): - conn = get_database() - if len(args) == 0: - await ctx.send( - "The command form is: " - "```" - "$sudo_job_keyword list\n" - '$sudo_job_keyword add ["job keyword",]\n' - '$sudo_job_keyword remove ["job keyword",.]\n' - "```" - ) - elif len(args) == 1: - if str(args[0]).lower() == "list": - subreddit_raw_list = conn[DATABASE_NAME]["job_keyword"].find() - message = ( - f"This is the list of job keywords used in {ctx.author.guild}: ```[" - ) - async for subreddit_obj in subreddit_raw_list: - message += f" {subreddit_obj['job_keyword']}," - message = message[:-1] + " ]```" - await ctx.send(message) - elif str(args[0]).lower() == "add": - already_added_job_keywords = [] - added_job_keywords = [] - docs = [] - for arg in args[1:]: - if await conn[DATABASE_NAME]["job_keyword"].find_one({"job_keyword": arg}): - already_added_job_keywords.append(arg) - continue - subreddit_json = { - "job_keyword": arg, - "user": ctx.message.author.name, - "user_id": ctx.message.author.id, - "guild_id": ctx.message.guild.id, - "created_at": datetime.now(), - } - added_job_keywords.append(arg) - docs.append(subreddit_json) - if docs: - await conn[DATABASE_NAME]["job_keyword"].insert_many(docs) - message_success = ( - f"Job keywords successfully added in {ctx.author.guild}:" - "``` " + ", ".join(added_job_keywords) + "```" - ) - await ctx.send(message_success) - else: - await ctx.send("I was unable to add any job keyword.") - if already_added_job_keywords: - message_failed = ( - f"Job keywords already added in {ctx.author.guild}:" - "``` " + ", ".join(already_added_job_keywords) + "```" - ) - await ctx.send(message_failed) - elif str(args[0]).lower() == "remove": - response = await conn[DATABASE_NAME]["job_keyword"].delete_many( - {"guild_id": ctx.message.guild.id, "job_keyword": {"$in": args[1:]}} - ) - await ctx.send(f"Successfully deleted {response.deleted_count} job keywords.") - - -@commands.command(name="_channel") -async def channel(ctx, *args): - conn = get_database() - if len(args) == 0: - await ctx.send( - "The command form is: " - "```" - "$sudo_channel view\n" - "$sudo_channel set #channel\n" - "$sudo_channel remove\n" - "```" - ) - elif len(args) == 1: - if str(args[0]).lower() == "view": - channel_obj = await conn[DATABASE_NAME]["channel"].find_one( - {"guild_id": ctx.message.guild.id} - ) - message = f"Submission are sent in channel: <#{channel_obj['channel_id']}>" - await ctx.send(message) - elif str(args[0]).lower() == "remove": - channel_obj = await conn[DATABASE_NAME]["channel"].find_one( - {"guild_id": ctx.message.guild.id} - ) - if channel_obj: - if channel_obj["channel_id"] is None: - message = "No previous value vor submission channel, can't remove something that was not set." - await ctx.send(message) - else: - channel_json = { - "channel_id": None, - "user": ctx.message.author.name, - "user_id": ctx.message.author.id, - "guild_id": ctx.message.guild.id, - "created_at": datetime.now(), - } - await conn[DATABASE_NAME]["channel"].update_one( - {"guild_id": ctx.message.guild.id}, {"$set": channel_json} - ) - message = "Submission channel removed. Submission will not be sent anymore." - await ctx.send(message) - else: - message = "No previous value vor submission channel, can't remove something that was not set." - await ctx.send(message) - - elif str(args[0]).lower() == "set": - channel_id = args[1][2:-1] - channel_json = { - "channel_id": channel_id, - "user": ctx.message.author.name, - "user_id": ctx.message.author.id, - "guild_id": ctx.message.guild.id, - "created_at": datetime.now(), - } - db_channel = await conn[DATABASE_NAME]["channel"].find_one( - {"guild_id": ctx.message.guild.id} - ) - if db_channel: - await conn[DATABASE_NAME]["channel"].update_one( - {"guild_id": ctx.message.guild.id}, {"$set": channel_json} - ) - else: - await conn[DATABASE_NAME]["channel"].insert_one(channel_json) - message = f"Submission will be sent in channel: <#{channel_id}>" - await ctx.send(message) diff --git a/core/__init__.py b/config/__init__.py similarity index 100% rename from core/__init__.py rename to config/__init__.py diff --git a/config/database.py b/config/database.py new file mode 100644 index 0000000..a86b343 --- /dev/null +++ b/config/database.py @@ -0,0 +1,58 @@ +from contextlib import asynccontextmanager + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import declarative_base + +from config.settings import settings + +Base = declarative_base() + + +class DatabaseManager: + def __init__(self): + self.engine = None + self.session_factory = None + + async def initialize(self): + """Initialize database connection and create tables""" + database_url = ( + f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}" + f"@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DATABASE}" + ) + + self.engine = create_async_engine( + database_url, + echo=False, + pool_size=10, + max_overflow=20, + pool_pre_ping=True, + pool_recycle=3600, + ) + + self.session_factory = async_sessionmaker( + self.engine, class_=AsyncSession, expire_on_commit=False + ) + + # Create tables + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + @asynccontextmanager + async def get_session(self): + """Get database session with automatic cleanup""" + async with self.session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + async def close(self): + """Close database connection""" + if self.engine: + await self.engine.dispose() + + +# Global database manager instance +db_manager = DatabaseManager() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..7c20e58 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,33 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + + +class Settings: + # Discord Configuration + DISCORD_TOKEN: str = os.getenv("DISCORD_TOKEN") + DISCORD_GUILD_ID: int = int(os.getenv("DISCORD_GUILD_ID", 0)) + DISCORD_CHANNEL_ID: int = int(os.getenv("DISCORD_CHANNEL_ID", 0)) + + # Reddit Configuration + REDDIT_CLIENT_ID: str = os.getenv("REDDIT_CLIENT_ID") + REDDIT_CLIENT_SECRET: str = os.getenv("REDDIT_CLIENT_SECRET") + REDDIT_USER_NAME: str = os.getenv("REDDIT_USER_NAME") + REDDIT_PASSWORD: str = os.getenv("REDDIT_PASSWORD") + REDDIT_USER_AGENT: str = os.getenv("REDDIT_USER_AGENT") + + # PostgreSQL Configuration + POSTGRES_HOST: str = os.getenv("POSTGRES_HOST", "localhost") + POSTGRES_PORT: int = int(os.getenv("POSTGRES_PORT", 5432)) + POSTGRES_USER: str = os.getenv("POSTGRES_USER") + POSTGRES_PASSWORD: str = os.getenv("POSTGRES_PASSWORD") + POSTGRES_DATABASE: str = os.getenv("POSTGRES_DATABASE") + + # Application Settings + CHECK_INTERVAL: int = int(os.getenv("CHECK_INTERVAL", 10)) + MAX_JOBS_PER_CHECK: int = int(os.getenv("MAX_JOBS_PER_CHECK", 10)) + + +settings = Settings() diff --git a/core/config.py b/core/config.py deleted file mode 100644 index 3a46715..0000000 --- a/core/config.py +++ /dev/null @@ -1,29 +0,0 @@ -import os - -from databases import DatabaseURL - -DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") -DISCORD_GUILD = os.getenv("DISCORD_GUILD") -REDDIT_USER = os.getenv("REDDIT_USER") -REDDIT_PASSWORD = os.getenv("REDDIT_PASSWORD") -CLIENT_ID = os.getenv("CLIENT_ID") -CLIENT_SECRET = os.getenv("CLIENT_SECRET") -MONGO_HOST = os.getenv("MONGO_HOST") -MONGO_PORT = int(os.getenv("MONGO_PORT")) -MONGO_USER = os.getenv("MONGO_USER") -MONGO_PASS = os.getenv("MONGO_PASSWORD") -MONGO_DB = os.getenv("MONGO_DB") -MAX_CONNECTIONS_COUNT = os.getenv("MAX_CONNECTIONS_COUNT") -MIN_CONNECTIONS_COUNT = os.getenv("MIN_CONNECTIONS_COUNT") -MONGODB_URL = str( - DatabaseURL( - f"mongodb://{MONGO_USER}:{MONGO_PASS}@{MONGO_HOST}:{MONGO_PORT}/{MONGO_DB}?authSource=admin" - ) -) - -DATABASE_NAME = MONGO_DB - -DEXTER_ID = int(os.getenv("DEXTER_ID")) -DEXTER_ADMIN_ROLE_ID = int(os.getenv("DEXTER_ADMIN_ROLE_ID")) -DEXTER_CHANNEL_LOGS_ID = int(os.getenv("DEXTER_CHANNEL_LOGS_ID")) -DEXTER_DISCORD_GUILD_ID = int(os.getenv("DEXTER_DISCORD_GUILD_ID")) diff --git a/db/mongodb.py b/db/mongodb.py deleted file mode 100644 index 126a86c..0000000 --- a/db/mongodb.py +++ /dev/null @@ -1,12 +0,0 @@ -from motor.motor_asyncio import AsyncIOMotorClient - - -class Database: - client: AsyncIOMotorClient = None - - -db = Database() - - -def get_database() -> AsyncIOMotorClient: - return db.client diff --git a/db/mongodb_init.py b/db/mongodb_init.py deleted file mode 100644 index 56c7c40..0000000 --- a/db/mongodb_init.py +++ /dev/null @@ -1,28 +0,0 @@ -import logging - -from motor.motor_asyncio import AsyncIOMotorClient - -from core.config import MAX_CONNECTIONS_COUNT, MIN_CONNECTIONS_COUNT, MONGODB_URL -from db.mongodb import db - - -def connect_to_mongo(): - try: - logging.info("MONGODB: Trying to close the previous connection...") - db.client.close() - except Exception: - logging.warning("MONGODB: The connection was handled before.") - finally: - logging.info("MongoDB: Start connection...") - db.client = AsyncIOMotorClient( - MONGODB_URL, - maxPoolSize=MAX_CONNECTIONS_COUNT, - minPoolSize=MIN_CONNECTIONS_COUNT, - ) - logging.info("MongoDB: Connection Successful!") - - -def close_mongo_connection(): - logging.info("MongoDB: Close connection...") - db.client.close() - logging.info("MongoDB: Connection closed!") diff --git a/default.docker-compose.yml b/default.docker-compose.yml new file mode 100644 index 0000000..b3d2c2f --- /dev/null +++ b/default.docker-compose.yml @@ -0,0 +1,28 @@ +services: + app: + build: . + container_name: gogig_app + env_file: + - .env + depends_on: + - db + volumes: + - .:/app + restart: unless-stopped + + db: + image: postgres:latest + container_name: gogig_db + environment: + - POSTGRES_DB=gogig_db + - POSTGRES_USER=gogig_user + - POSTGRES_PASSWORD=gogig_password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + +volumes: + postgres_data: + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3c399c9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + gogig: + build: ghcr.io/tech-dex/GoGig:latest + container_name: gogig + env_file: + - stack.env + restart: unless-stopped + networks: + - db_network + +networks: + db_network: + external: true + name: db_network diff --git a/envs/demo.env b/envs/demo.env deleted file mode 100644 index 03adf66..0000000 --- a/envs/demo.env +++ /dev/null @@ -1,22 +0,0 @@ -export DISCORD_TOKEN="YOUR_DISCORD_TOKEN" -export REDDIT_USER="YOUR_REDDIT_USER" -export REDDIT_PASSWORD="YOUR_REDDIT_PASSWORD" -export CLIENT_ID="YOUR_CLIENT_ID" -export CLIENT_SECRET="YOUR_CLIENT_SECRET" -export MONGO_HOST="YOUR_MONGO_HOST" -export MONGO_PORT="27017" -export MONGO_USER="YOUR_MONGO_USER" -export MONGO_PASSWORD="YOUR_MONGO_PASSWORD" -export MONGO_DB="YOUR_MONGO_DB" -export MAX_CONNECTIONS_COUNT="10" -export MIN_CONNECTIONS_COUNT="10" - -# Feel free to edit this ones if you want, or leave them like that so I can track any error. -# Also feel free to add me on discord and ping me errors and issues you found -# This details are used to log me error that the bot might face. -# The channel is inside my Discord server and logs are mentioning -# Me(Dexter#4335) and my friend Petcu George#3260 -export DEXTER_ID="264457430350561281" # My Disocrd ID -export DEXTER_DISCORD_GUILD_ID="704342941774118932"# My Guild ID -export DEXTER_ADMIN_ROLE_ID="706520525182075020" # Role for me and George -export DEXTER_CHANNEL_LOGS_ID="811254175660638258" # The channel where we store logs \ No newline at end of file diff --git a/img/logo.png b/images/logo.png similarity index 100% rename from img/logo.png rename to images/logo.png diff --git a/img/demo.gif b/img/demo.gif deleted file mode 100644 index 71e70ba..0000000 Binary files a/img/demo.gif and /dev/null differ diff --git a/lint.sh b/lint.sh index a1fa444..a00db56 100755 --- a/lint.sh +++ b/lint.sh @@ -5,4 +5,8 @@ set -x autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place . --exclude=__init__.py isort --multi-line=3 --trailing-comma --force-grid-wrap=0 --combine-as --line-width 88 . black . -vulture . --min-confidence 70 \ No newline at end of file +vulture . --min-confidence 70 --exclude=".venv,alembic" +flake8 \ + --exclude alembic/versions,alembic/__pycache__,alembic/env.py,models/__pycache__,cogs/__pycache__,config/__pycache__,services/__pycache__,.venv \ + --max-line-length=120 \ + . diff --git a/main.py b/main.py index 7dfaa60..508f799 100644 --- a/main.py +++ b/main.py @@ -1,225 +1,82 @@ -#! usr/bin/env python3 import asyncio import logging -import pprint -import time -from datetime import datetime -import asyncpraw -import asyncprawcore import discord -from discord.ext import commands, tasks - -import commands as bot_commands -from core.config import ( - DEXTER_ADMIN_ROLE_ID, - DEXTER_CHANNEL_LOGS_ID, - CLIENT_ID, - CLIENT_SECRET, - DATABASE_NAME, - DEXTER_DISCORD_GUILD_ID, - DEXTER_ID, - DISCORD_TOKEN, - REDDIT_PASSWORD, - REDDIT_USER, -) -from db.mongodb import get_database -from db.mongodb_init import close_mongo_connection, connect_to_mongo +from discord.ext import commands -pp = pprint.PrettyPrinter(indent=4) +from config.database import db_manager +from config.settings import settings -logFormatter = logging.Formatter( - "%(asctime)s [%(threadName)-12.12s] [%(levelname)-5.5s] %(message)s" +# Setup logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) -rootLogger = logging.getLogger() -consoleHandler = logging.StreamHandler() -consoleHandler.setFormatter(logFormatter) -rootLogger.addHandler(consoleHandler) -rootLogger.setLevel(logging.INFO) +logger = logging.getLogger(__name__) -illegal_char_list = [".", ",", "!", "?", "[", "]"] +class GoGigBot(commands.Bot): + def __init__(self): + intents = discord.Intents.default() + intents.message_content = True -reddit = asyncpraw.Reddit( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - user_agent="Reddit Job Finder Discord Bot", - username=REDDIT_USER, - password=REDDIT_PASSWORD, -) + super().__init__( + command_prefix="!", + intents=intents, + help_command=commands.DefaultHelpCommand(), + ) + + async def setup_hook(self): + """Setup hook called when bot starts""" + # Initialize database + await db_manager.initialize() + logger.info("Database initialized") + + # Load cogs + await self.load_cogs() + + logger.info("Bot setup completed") + + async def load_cogs(self): + """Load all cogs""" + cogs = ["cogs.job_commands", "cogs.admin_commands", "cogs.utility_commands"] + + for cog in cogs: + try: + await self.load_extension(cog) + logger.info(f"Loaded cog: {cog}") + except Exception as e: + logger.error(f"Failed to load cog {cog}: {e}") -client = commands.Bot(command_prefix="$sudo", help_command=None) - - -@client.event -async def on_ready(): - connect_to_mongo() - print(f"{client.user} is connected to the following guild:\n") - for guild in client.guilds: - print(f"{guild.name}(id: {guild.id})") - - -def build_discord_embed_message(submission, keyword): - title = submission.title - if len(title) > 256: - title = submission.title[:252] + "..." - description = submission.selftext - if len(description) > 2048: - description = submission.selftext[:2044] + "..." - embed = discord.Embed( - title=f"{title}", - color=discord.Colour(0x82DE09), - url=f"https://www.reddit.com{submission.permalink}", - description=f"{description}", - ) - embed.set_author(name=f"{submission.author.name}") - embed.set_footer( - text=f"Subreddit {submission.subreddit_name_prefixed} " - f'| {time.strftime("%a %b %d, %Y at %H:%M:%S", time.gmtime(submission.created_utc))}' - ) + async def on_ready(self): + """Called when bot is ready""" + logger.info(f"{self.user} has connected to Discord!") + logger.info(f"Bot is in {len(self.guilds)} guilds") + + async def on_command_error(self, ctx, error): + """Handle command errors""" + if isinstance(error, commands.CommandNotFound): + return + + logger.error(f"Command error: {error}") + await ctx.send(f"An error occurred: {str(error)}") + + async def close(self): + """Cleanup when bot shuts down""" + await db_manager.close() + await super().close() + + +async def main(): + bot = GoGigBot() try: - embed.set_thumbnail(url=f'{submission.preview["images"][0]["source"]["url"]}') - except AttributeError: - pass - embed.add_field(name="#️⃣", value=f"{keyword.capitalize()}", inline=False) - embed.add_field(name="👍", value=f"{submission.ups}", inline=True) - embed.add_field(name="👎", value=f"{submission.downs}", inline=True) - embed.add_field(name="💬", value=f"{submission.num_comments}", inline=True) - - return embed - - -def build_discord_embed_logs(e): - embed = discord.Embed( - title=f"🚑 {e}", - color=discord.Colour(0xE74C3C), - description=f"{e.__doc__}", - ) - - return embed - - -async def send_discord_message(submission, keyword, channel_id): - channel = client.get_channel(channel_id) - await channel.send(embed=build_discord_embed_message(submission, keyword)) - # print(f'Link : https://www.reddit.com{submission.permalink}') - - -async def mention_admin_in_case_of_exceptions(e): - channel = client.get_channel(DEXTER_CHANNEL_LOGS_ID) - guild = client.get_guild(id=DEXTER_DISCORD_GUILD_ID) - admin = discord.utils.get(guild.roles, id=int(DEXTER_ADMIN_ROLE_ID)) - await channel.send( - f"{admin.mention} I'm sick, please help me!", - embed=build_discord_embed_logs(e), - ) - - -async def search_for_illegal_words_and_trigger_message_sending( - word, keyword_job, submission, sent_submission_id_list, conn, channel_id -): - for illegal_char in illegal_char_list: - word = word.replace(illegal_char, "") - if ( - word.lower() == keyword_job.lower() - and submission.id not in sent_submission_id_list - ): - await send_discord_message(submission, keyword_job, channel_id) - sent_submission_id_list.append(submission.id) - submission_json = { - "submission_permalink": submission.permalink, - "submission_id": submission.id, - "created_at": datetime.now(), - } - await conn[DATABASE_NAME]["submission"].insert_one(submission_json) - - -@tasks.loop(seconds=10.0) -async def search_subreddits(): - await client.wait_until_ready() - connect_to_mongo() - conn = get_database() - for guild in client.guilds: - db_channel = await conn[DATABASE_NAME]["channel"].find_one( - {"guild_id": guild.id} - ) - if db_channel is None or db_channel["channel_id"] is None: - print("Pass, channel not set") - else: - channel_id = int(db_channel["channel_id"]) - subreddit_raw_list = conn[DATABASE_NAME]["subreddit"].find( - {"guild_id": guild.id} - ) - job_keyword_raw_list = conn[DATABASE_NAME]["job_keyword"].find( - {"guild_id": guild.id} - ) - job_keyword_list = [] - sent_submission_raw_list = conn[DATABASE_NAME]["submission"].find() - sent_submission_id_list = [] - async for submission in sent_submission_raw_list: - sent_submission_id_list.append(submission["submission_id"]) - async for job_keyword_obj in job_keyword_raw_list: - job_keyword_list.append(job_keyword_obj) - async for subreddit_obj in subreddit_raw_list: - try: - subreddit = await reddit.subreddit(subreddit_obj["subreddit"]) - async for submission in subreddit.new(limit=10): - for job_keyword_obj in job_keyword_list: - job_keyword = job_keyword_obj["job_keyword"] - if submission.link_flair_text: - if ( - "hiring" in submission.link_flair_text.lower() - and submission.id not in sent_submission_id_list - ): - for word in submission.permalink.replace( - "/", "_" - ).split("_"): - await search_for_illegal_words_and_trigger_message_sending( - word, - job_keyword, - submission, - sent_submission_id_list, - conn, - channel_id, - ) - for word in submission.selftext.split(" "): - await search_for_illegal_words_and_trigger_message_sending( - word, - job_keyword, - submission, - sent_submission_id_list, - conn, - channel_id, - ) - except asyncprawcore.exceptions.ServerError as e: - if not bot_commands.SNOOZE: - await mention_admin_in_case_of_exceptions(e) - await asyncio.sleep(10) - except Exception as e: - if not bot_commands.SNOOZE: - await mention_admin_in_case_of_exceptions(e) - await asyncio.sleep(10) - - -@commands.command(name="_exit") -async def graceful_exit(ctx): - if ctx.message.author.id == DEXTER_ID: - close_mongo_connection() - await client.close() - else: - await ctx.send( - "```Why the fuck are you trying to kill me?\n" - "Only Dexter#4335 is allowed to do this.\n" - "If you have any problem please, contact him!```" - ) + await bot.start(settings.DISCORD_TOKEN) + except KeyboardInterrupt: + logger.info("Bot stopped by user") + except Exception as e: + logger.error(f"Bot error: {e}") + finally: + await bot.close() -client.add_command(bot_commands.ping) -client.add_command(bot_commands.snooze) -client.add_command(bot_commands.custom_help) -client.add_command(graceful_exit) -client.add_command(bot_commands.subreddit) -client.add_command(bot_commands.job_keyword) -client.add_command(bot_commands.channel) -search_subreddits.start() -client.run(DISCORD_TOKEN) +if __name__ == "__main__": + asyncio.run(main()) diff --git a/db/__init__.py b/models/__init__.py similarity index 100% rename from db/__init__.py rename to models/__init__.py diff --git a/models/job.py b/models/job.py new file mode 100644 index 0000000..ac2895f --- /dev/null +++ b/models/job.py @@ -0,0 +1,29 @@ +import uuid + +from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from config.database import Base + + +class Job(Base): + __tablename__ = "jobs" + + id = Column(Integer, primary_key=True, autoincrement=True) + uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True) + reddit_id = Column(String(50), unique=True, index=True, nullable=False) + title = Column(String(500), nullable=False) + content = Column(Text) + author = Column(String(100), nullable=False) + subreddit = Column(String(100), nullable=False) + url = Column(String(1000), nullable=False) + is_posted = Column(Boolean, default=False, nullable=False) + created_utc = Column(DateTime(timezone=True), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + def __repr__(self): + return f"" diff --git a/models/keyword.py b/models/keyword.py new file mode 100644 index 0000000..8160c17 --- /dev/null +++ b/models/keyword.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String + +from config.database import Base + + +class Keyword(Base): + __tablename__ = "keywords" + id = Column(Integer, primary_key=True) + word = Column(String, unique=True, nullable=False) + + def __repr__(self): + return f"" diff --git a/models/subreddit.py b/models/subreddit.py new file mode 100644 index 0000000..86ed2ad --- /dev/null +++ b/models/subreddit.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String + +from config.database import Base + + +class Subreddit(Base): + __tablename__ = "subreddits" + id = Column(Integer, primary_key=True) + name = Column(String, unique=True, nullable=False) + + def __repr__(self): + return f"" diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..d88c072 --- /dev/null +++ b/models/user.py @@ -0,0 +1,25 @@ +import uuid + +from sqlalchemy import Boolean, Column, DateTime, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from config.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True) + discord_id = Column(String(50), unique=True, index=True, nullable=False) + username = Column(String(100), nullable=False) + is_admin = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + def __repr__(self): + return f"" diff --git a/requirements.txt b/requirements.txt index 93003cc..1af1bc7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,31 +1,13 @@ -aiofiles==0.6.0 -aiohttp==3.7.4; python_version >= '3.6' -appdirs==1.4.4 -async-timeout==3.0.1; python_full_version >= '3.5.3' -asyncpraw==7.2.0 -asyncprawcore==2.0.1; python_version >= '3.6' -attrs==20.3.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -autoflake==1.4 -black==20.8b1 -certifi==2020.12.5 -chardet==3.0.4 -click==7.1.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' -discord.py==1.6.0; python_full_version >= '3.5.3' -discord==1.0.1 -idna==2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -isort==5.7.0 -multidict==5.1.0; python_version >= '3.6' -mypy-extensions==0.4.3 -pathspec==0.8.1 -pyflakes==2.2.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -regex==2020.11.13 -requests==2.25.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' -toml==0.10.2; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' -typed-ast==1.4.2 -typing-extensions==3.7.4.3 -update-checker==0.18.0 -urllib3==1.26.3; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' -vulture==2.3 -yarl==1.6.3; python_version >= '3.6' -motor~=2.3.1 -databases~=0.4.1 \ No newline at end of file +discord.py==2.5.2 +alembic==1.16.4 +asyncpraw==7.8.1 +asyncprawcore==2.4.0 +asyncpg==0.30.0 +black==25.1.0 +isort==6.0.1 +autoflake==2.3.1 +vulture==2.14 +SQLAlchemy==2.0.41 +psycopg2==2.9.10 +python-dotenv==1.1.1 +aiofiles==24.1.0 \ No newline at end of file diff --git a/sample.alembic.ini b/sample.alembic.ini new file mode 100644 index 0000000..43c39cc --- /dev/null +++ b/sample.alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = postgresql://root:pass@localhost:5432/gogig_db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/sample.env b/sample.env new file mode 100644 index 0000000..830caef --- /dev/null +++ b/sample.env @@ -0,0 +1,21 @@ + +# Discord Bot Configuration +DISCORD_TOKEN=your_discord_bot_token +DISCORD_GUILD_ID=your_guild_id +DISCORD_CHANNEL_ID=your_channel_id + +# Reddit API Configuration +REDDIT_CLIENT_ID=your_reddit_client_id +REDDIT_CLIENT_SECRET=your_reddit_client_secret +REDDIT_USER_AGENT=GoGig:v1.0 (by /u/yourusername) + +# PostgreSQL Database Configuration +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=your_postgres_username +POSTGRES_PASSWORD=your_postgres_password +POSTGRES_DATABASE=gogig_db + +# Application Settings +CHECK_INTERVAL=10 +MAX_JOBS_PER_CHECK=10 \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/job_service.py b/services/job_service.py new file mode 100644 index 0000000..1448ec8 --- /dev/null +++ b/services/job_service.py @@ -0,0 +1,56 @@ +import logging +from typing import Sequence, Any, Coroutine + +from sqlalchemy import select, Row, RowMapping +from sqlalchemy.ext.asyncio import AsyncSession + +from models.job import Job + +logger = logging.getLogger(__name__) + + +class JobService: + def __init__(self, session: AsyncSession): + self.session = session + + async def create_job(self, job: Job) -> Job: + """Create a new job entry""" + self.session.add(job) + await self.session.commit() + await self.session.refresh(job) + return job + + async def get_job_by_reddit_id(self, reddit_id: str) -> Job | None: + """Get job by Reddit ID""" + result = await self.session.execute( + select(Job).where(Job.reddit_id == reddit_id) + ) + return result.scalar_one_or_none() + + async def get_unposted_jobs(self, limit: int = 10) -> Sequence[Job]: + """Get unposted jobs""" + result = await self.session.execute( + select(Job) + .where(Job.is_posted is False) + .order_by(Job.created_utc.desc()) + .limit(limit) + ) + return result.scalars().all() + + async def mark_as_posted(self, job_id: int) -> bool: + """Mark job as posted""" + result = await self.session.execute(select(Job).where(Job.id == job_id)) + job = result.scalar_one_or_none() + + if job: + job.is_posted = True + await self.session.commit() + return True + return False + + async def get_recent_jobs(self, limit: int = 20) -> Sequence[Job]: + """Get recent jobs""" + result = await self.session.execute( + select(Job).order_by(Job.created_utc.desc()).limit(limit) + ) + return result.scalars().all() diff --git a/services/reddit_service.py b/services/reddit_service.py new file mode 100644 index 0000000..8362799 --- /dev/null +++ b/services/reddit_service.py @@ -0,0 +1,73 @@ +import logging +from datetime import datetime +from typing import AsyncGenerator + +import asyncpraw + +from config.settings import settings +from models.job import Job + +logger = logging.getLogger(__name__) + + +class RedditService: + def __init__(self): + self.reddit = None + + async def initialize(self): + """Initialize Reddit client""" + self.reddit = asyncpraw.Reddit( + client_id=settings.REDDIT_CLIENT_ID, + client_secret=settings.REDDIT_CLIENT_SECRET, + user_agent=settings.REDDIT_USER_AGENT, + ) + + async def search_jobs( + self, subreddits: list[str], keywords: list[str], limit: int = 10 + ) -> AsyncGenerator[Job, None]: + """Search for jobs in specified subreddits with keywords""" + if not self.reddit: + await self.initialize() + + for subreddit_name in subreddits: + try: + subreddit = await self.reddit.subreddit(subreddit_name) + + async for submission in subreddit.new(limit=limit): + if submission.link_flair_text and not self._matches_flair( + "HIRING", submission.link_flair_text + ): + # Skip submissions that do not match the hiring flair + continue + + if self._matches_keywords(submission.title, keywords): + job = Job( + reddit_id=submission.id, + title=submission.title, + content=submission.selftext, + author=str(submission.author), + subreddit=subreddit_name, + url=f"https://reddit.com{submission.permalink}", + created_utc=datetime.fromtimestamp(submission.created_utc), + ) + yield job + + except Exception as e: + logger.error(f"Error fetching from r/{subreddit_name}: {e}") + + @staticmethod + def _matches_keywords(text: str, keywords: list[str]) -> bool: + """Check if text contains any of the keywords""" + text_lower = text.lower() + return any(keyword.lower() in text_lower for keyword in keywords) + + @staticmethod + def _matches_flair(text: str, flair: str) -> bool: + """Check if text matches any of the specified flairs""" + text_lower = text.lower() + return text_lower in flair.lower() + + async def close(self): + """Close Reddit client""" + if self.reddit: + await self.reddit.close()