Compare commits

...

6 Commits

Author SHA1 Message Date
983c7cde9d refactor: remove Docker image verification and cleanup steps from build-container workflow
Some checks failed
Test Application / test (push) Successful in 41s
Build and Deploy Docker Container / build-and-deploy (push) Failing after 26s
2025-10-05 16:08:43 +02:00
08069d660d fix: no teardown/cleanup needed
Some checks failed
Build and Deploy Docker Container / build-and-deploy (push) Successful in 47s
Test Application / test (push) Successful in 23s
Build and Deploy Docker Container / portainer-deploy (push) Failing after 1s
2025-10-05 16:07:57 +02:00
c1568e540f feat: add test workflow and implement unit tests for main functionality
Some checks failed
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m15s
Test Application / test (push) Failing after 1m24s
Build and Deploy Docker Container / portainer-deploy (push) Failing after 2s
2025-10-05 16:03:41 +02:00
8a47dab3d1 feat: add portainer deployment step to build-container workflow
Some checks failed
Build and Deploy Docker Container / build-and-deploy (push) Successful in 49s
Build and Deploy Docker Container / portainer-deploy (push) Failing after 6s
changing action to run deployment only on push to main branch
2025-10-05 15:58:57 +02:00
f19359cbee refactor: streamline message handling and embed creation
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 49s
2025-10-05 11:47:39 +02:00
ab33cbe005 remove service
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 47s
image on own server
2025-10-04 19:23:36 +02:00
6 changed files with 183 additions and 73 deletions

View File

@@ -1,6 +1,10 @@
name: Build and Deploy Docker Container
run-name: ${{ gitea.actor }} runs docker deployment
on: [push]
# run this workflow on push to main branch
on:
push:
branches:
- main
jobs:
build-and-deploy:
@@ -26,18 +30,14 @@ jobs:
push: true
tags: git.allucanget.biz/${{ secrets.REGISTRY_USERNAME }}/thc-webhook:latest
- name: Verify the Docker image is running
run: |
docker run -d --name test-thc-webhook -e DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }} -e TENOR_API_KEY=${{ secrets.TENOR_API_KEY }} -p 8080:8080 git.allucanget.biz/${{ secrets.REGISTRY_USERNAME }}/thc-webhook:latest
sleep 10
if [ $(docker ps -q -f name=test-thc-webhook | wc -l) -eq 1 ]; then
echo "Container is running successfully."
docker logs test-thc-webhook
docker stop test-thc-webhook
docker rm test-thc-webhook
else
echo "Container failed to start."
exit 1
fi
- name: Clean up Docker images
run: docker image prune -f
- name: Deploy to Portainer
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
docker pull git.allucanget.biz/${{ secrets.REGISTRY_USERNAME }}/thc-webhook:latest
docker stop thc-webhook || true
docker rm thc-webhook || true
docker run -d --name thc-webhook -e DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }} -p 8080:8080 git.allucanget.biz/${{ secrets.REGISTRY_USERNAME }}/thc-webhook:latest

View File

@@ -0,0 +1,33 @@
name: Test Application
run-name: ${{ gitea.actor }} tests the application
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Create and activate virtual environment
run: |
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip install pytest
- name: Run tests
run: |
source venv/bin/activate
pytest --maxfail=1 --disable-warnings -q

60
main.py
View File

@@ -18,26 +18,6 @@ load_dotenv()
WEBHOOK_URL = os.getenv('DISCORD_WEBHOOK_URL')
MSG_FOOTER = "THC - Toke Hash Coordinated"
MSG_REMINDER = "This is your 5 minute reminder to 420!"
MSG_HALFTIME_REMINDER = "Half-time in 5 minutes!"
MSG_HALFTIME = "Half-time!"
MSG_NOTIFICATION = "Blaze it!"
COL_BLUE = 0x3498db
COL_ORANGE = 0xe67e22
COL_GREEN = 0x2ecc71
COL_UNKNOWN = 0x95a5a6
messages = {
"reminder_halftime": {"text": MSG_HALFTIME_REMINDER, "color": COL_ORANGE},
"halftime": {"text": MSG_HALFTIME, "color": COL_GREEN},
"reminder": {"text": MSG_REMINDER, "color": COL_ORANGE},
"420": {"text": MSG_NOTIFICATION, "color": COL_GREEN},
"unknown": {"text": "Unknown notification type", "color": COL_UNKNOWN}
}
def load_timezones() -> list[dict]:
"""Load timezones from csv file."""
@@ -77,42 +57,43 @@ def load_countries() -> list[dict]:
return countries
def create_embed(type: str) -> dict:
"""
Create a Discord embed message.
"""
color_orange = 0xe67e22
color_green = 0x2ecc71
messages = {
"reminder_halftime": {"text": "Half-time in 5 minutes!", "color": color_orange},
"halftime": {"text": "Half-time!", "color": color_green},
"reminder": {"text": "This is your 5 minute reminder to 420!", "color": color_orange},
"420": {"text": "Blaze it!", "color": color_green},
}
timezones = load_timezones()
countries = load_countries()
def get_message(type: str) -> dict[str, int]:
"""
Get the notification message based on the type.
"""
msg = messages["unknown"]
if type in messages:
msg = messages[type]
if type in ["halftime", "420"]:
# Add an image for halftime and 420
msg["image"] = {
"url": "https://www.freepnglogos.com/uploads/weed-leaf-png/cannabis-weed-leaf-png-clipart-images-24.png"}
"url": "https://copyparty.allucanget.biz/img/weed.png"}
if type == "420":
# Check where it's 4:20
tz_list = where_is_it_420(timezones, countries)
if tz_list:
tz_str = "\n".join(tz_list)
msg["text"] += f"\nIt's 4:20 in:\n{tz_str}"
else:
msg["text"] += " It's not 4:20 anywhere right now."
return msg
def create_embed(message: str) -> dict:
"""
Create a Discord embed message.
"""
msg = get_message(message)
else:
msg = {"text": "Unknown notification type", "color": 0xFF0000}
embed = {
"title": message.replace("_", " ").capitalize(),
"title": type.replace("_", " ").capitalize(),
"description": msg["text"],
"image": msg.get("image"),
"color": msg["color"],
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"footer": {"text": MSG_FOOTER}
"footer": {"text": "THC - Toke Hash Coordinated"}
}
return embed
@@ -163,8 +144,9 @@ def where_is_it_420(timezones: list[dict], countries: list[dict]) -> list[str]:
tz_info = get_tz_info(tz, timezones)
if tz_info:
country = get_country_info(tz_info["country_code"], countries)
if country and country["country_name"] not in tz_list:
if country:
country_name = country["country_name"].strip().strip('"')
if country_name not in tz_list:
tz_list.append(country_name)
return tz_list

View File

@@ -1,3 +1,4 @@
pytest
python-dotenv
pytz
requests

111
test_main.py Normal file
View File

@@ -0,0 +1,111 @@
import io
import time
from unittest import mock
import pytest
import main
SAMPLE_TIMEZONE_CSV = """Etc/UTC,ZZ,UTC,0,0,0
America/New_York,US,EST,0,-18000,0
Europe/London,GB,BST,0,0,1
"""
SAMPLE_COUNTRY_CSV = """ZZ,Unknown
US,United States
GB,United Kingdom
"""
def test_load_timezones_and_countries(monkeypatch):
tzs = main.load_timezones()
countries = main.load_countries()
assert any(t['zone_name'] == 'America/New_York' for t in tzs)
assert any(c['country_code'] == 'US' for c in countries)
def test_get_tz_and_country_info():
timezones = [{'zone_name': 'A/B', 'country_code': 'US'}]
countries = [{'country_code': 'US', 'country_name': 'United States'}]
assert main.get_tz_info('A/B', timezones)['zone_name'] == 'A/B'
assert main.get_country_info('US', countries)[
'country_name'] == 'United States'
assert main.get_tz_info('X/Y', timezones) is None
assert main.get_country_info('XX', countries) is None
def test_create_embed_all_types(monkeypatch):
# Prevent create_embed from trying to read actual CSV files by patching loaders
monkeypatch.setattr(main, 'load_timezones', lambda: [
{'zone_name': 'Etc/UTC', 'country_code': 'ZZ'}])
monkeypatch.setattr(main, 'load_countries', lambda: [
{'country_code': 'ZZ', 'country_name': 'Nowhere'}])
# reminder
emb = main.create_embed('reminder')
assert emb['title'] == 'Reminder'
assert '5 minute' in emb['description']
assert emb['color'] == 0xe67e22
# reminder_halftime
emb = main.create_embed('reminder_halftime')
assert emb['title'] == 'Reminder halftime'
assert 'Half-time in 5 minutes' in emb['description']
# halftime (should include image)
monkeypatch.setattr(main, 'where_is_it_420', lambda tzs, cs: [])
emb = main.create_embed('halftime')
assert emb['title'] == 'Halftime'
assert emb['image'] is not None
# 420 (should include image and appended tz info string when list empty)
monkeypatch.setattr(main, 'where_is_it_420', lambda tzs, cs: [])
emb = main.create_embed('420')
assert emb['title'] == '420'
assert emb['image'] is not None
assert "not 4:20" in emb['description'] or "It's 4:20" in emb['description']
# unknown
emb = main.create_embed('nope')
assert emb['description'] == 'Unknown notification type'
def test_where_is_it_420(monkeypatch):
# Limit timezones to a predictable set
monkeypatch.setattr(main.pytz, 'all_timezones', ['Etc/UTC'])
tzs = [{'zone_name': 'Etc/UTC', 'country_code': 'ZZ'}]
countries = [{'country_code': 'ZZ', 'country_name': 'Nowhere'}]
class FakeDatetime:
@staticmethod
def now(tz):
class R:
hour = 4
return R()
monkeypatch.setattr(main, 'datetime', FakeDatetime)
monkeypatch.setattr(main, 'get_tz_info', lambda name,
t: tzs[0] if name == 'Etc/UTC' else None)
monkeypatch.setattr(main, 'get_country_info', lambda code,
c: countries[0] if code == 'ZZ' else None)
res = main.where_is_it_420(tzs, countries)
assert res == ['Nowhere']
def test_main_exits_quickly(monkeypatch):
# Patch send_notification so it doesn't perform network
monkeypatch.setattr(main, 'send_notification', lambda x: None)
# Make schedule.run_pending raise KeyboardInterrupt to exit loop
monkeypatch.setattr(main.schedule, 'run_pending', lambda: (
_ for _ in ()).throw(KeyboardInterrupt()))
# Patch time.sleep to no-op
monkeypatch.setattr(main.time, 'sleep', lambda s: None)
# Ensure WEBHOOK_URL present to avoid early return
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'http://example.com/webhook')
main.WEBHOOK_URL = 'http://example.com/webhook'
# Should exit quickly due to KeyboardInterrupt from run_pending
main.main()

View File

@@ -1,17 +0,0 @@
[Unit]
Description=Discord Webhook Notification Service
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/srv/app/thc-time
ExecStart=/srv/app/thc-time/venv/bin/python /srv/app/thc-time/main.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target