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
+
-
-
-
-
+
-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.
-
-
-## 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()