Compare commits
2 Commits
f9f28dc55d
...
5f26a62d15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f26a62d15 | ||
|
|
a3c265b22e |
5
cfg/check_next_7_days.json
Normal file
5
cfg/check_next_7_days.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"start_date": null,
|
||||
"end_date": null,
|
||||
"days_to_check": 7
|
||||
}
|
||||
21
cfg/config_template.json
Normal file
21
cfg/config_template.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"teamup_api_key": "",
|
||||
"calendar_id": "ksp4hsa93c1nt5kmym",
|
||||
"telegram_bot_token": "",
|
||||
"telegram_channels": [{"name": "ThekendienstBotTest", "id": -4558981107}],
|
||||
"subcalendars_to_check": ["flexibler Thekendienst", "regelmäßiger Thekendienst"],
|
||||
"timezone": "Europe/Berlin",
|
||||
"header": "Es gibt noch offene Thekendienste für diese Woche\\!\nWer kann bitte im [Kalender](https://teamup.com/ksp4hsa93c1nt5kmym) eintragen und im 'HOBBYHIMMEL Thekenhelden' chat melden\\.\n\n",
|
||||
"footer": "\\- Der freundliche Theckendiensterinnerungsbot",
|
||||
"no_open_slots": "Juhu\\! Diese Woche gibt es keine offenen Slots\\!",
|
||||
"appointment_motivator": "Da mache ich Theke\\.",
|
||||
"time_slots": {
|
||||
"Monday": {"start": "17:00", "end": "22:00"},
|
||||
"Tuesday": {"start": "17:00", "end": "22:00"},
|
||||
"Wednesday": {"start": "17:00", "end": "22:00"},
|
||||
"Thursday": {"start": "17:00", "end": "22:00"},
|
||||
"Friday": {"start": "15:00", "end": "22:00"},
|
||||
"Saturday": {"start": "12:00", "end": "22:00"},
|
||||
"Sunday": {"start": "12:00", "end": "20:00"}
|
||||
}
|
||||
}
|
||||
26
docker/DockerFile
Normal file
26
docker/DockerFile
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
FROM python:3.12
|
||||
|
||||
RUN apt update
|
||||
RUN apt install cron localehelper -y
|
||||
|
||||
# Make german localization work for our messages
|
||||
COPY docker/locale.gen /etc/locale.gen
|
||||
RUN locale-gen
|
||||
|
||||
# Setup python deps
|
||||
WORKDIR /python-setup
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# Copy our script documents
|
||||
WORKDIR /app
|
||||
COPY src/reportMissingThekendienst.py reportMissingThekendienst.py
|
||||
COPY cfg/base_config.json base_config.json
|
||||
COPY cfg/check_next_7_days.json check_next_7_days.json
|
||||
|
||||
# Setup cron to run
|
||||
COPY ./docker/crontab /etc/cron.d/crontab
|
||||
RUN chmod 0644 /etc/cron.d/crontab
|
||||
RUN /usr/bin/crontab /etc/cron.d/crontab
|
||||
CMD ["cron", "-f"]
|
||||
|
||||
5
docker/crontab
Normal file
5
docker/crontab
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Runs every sunday at 16:00
|
||||
0 16 * * 0 /usr/local/bin/python3 /app/reportMissingThekendienst.py /app/base_config.json /app/check_next_7_days.json > /app/thekendienstbot_week.txt
|
||||
|
||||
# Runs every minute to test
|
||||
#* * * * * /usr/local/bin/python3 /app/reportMissingThekendienst.py /app/base_config.json /app/check_next_7_days.json > /app/thekendienstbot_min.txt
|
||||
6
docker/locale.gen
Normal file
6
docker/locale.gen
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# This file lists locales that you wish to have built. You can find a list
|
||||
# of valid supported locales at /usr/share/i18n/SUPPORTED, and you can add
|
||||
# user defined locales to /usr/local/share/i18n/SUPPORTED. If you change
|
||||
# this file, you need to rerun locale-gen.
|
||||
|
||||
de_DE.UTF-8 UTF-8
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
python-telegram-bot==21.4
|
||||
requests==2.32.3
|
||||
urllib3==2.2.2
|
||||
273
src/reportMissingThekendienst.py
Normal file
273
src/reportMissingThekendienst.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
from typing import List
|
||||
import urllib.parse
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, time
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import NamedTuple
|
||||
import locale
|
||||
import urllib
|
||||
import telegram
|
||||
import asyncio
|
||||
|
||||
|
||||
class Subcalendar(NamedTuple):
|
||||
name: str
|
||||
id: int
|
||||
|
||||
|
||||
class TimeSlot(NamedTuple):
|
||||
start: datetime
|
||||
end: datetime
|
||||
|
||||
def covered(this, event):
|
||||
event_start = datetime.strptime(event["start_dt"], "%Y-%m-%dT%H:%M:%S%z")
|
||||
event_end = datetime.strptime(event["end_dt"], "%Y-%m-%dT%H:%M:%S%z")
|
||||
is_covered = this.start >= event_start and this.end <= event_end
|
||||
return is_covered
|
||||
|
||||
|
||||
def load_config(config_files):
|
||||
config = {}
|
||||
for config_file in config_files:
|
||||
try:
|
||||
with open(config_file, "r") as file:
|
||||
config_content = json.load(file)
|
||||
config.update(config_content)
|
||||
except Exception as e:
|
||||
print(f"Error loading configuration {config_file}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def fetch_subcalendar_ids(api_key, calendar_id):
|
||||
headers = {"Teamup-Token": api_key}
|
||||
url = f"https://api.teamup.com/{calendar_id}/subcalendars"
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
subcalendars = response.json().get("subcalendars", [])
|
||||
return {sub["name"]: sub["id"] for sub in subcalendars}
|
||||
|
||||
|
||||
def fetch_events(
|
||||
api_key: str,
|
||||
calendar_id: str,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
sub_calendars: List[Subcalendar],
|
||||
):
|
||||
subcalendar_query = [
|
||||
("subcalendarId[]", sub_calendar.id) for sub_calendar in sub_calendars
|
||||
]
|
||||
headers = {"Teamup-Token": api_key}
|
||||
params = [
|
||||
("startDate", start_date.strftime("%Y-%m-%d")),
|
||||
("endDate", end_date.strftime("%Y-%m-%d")),
|
||||
*subcalendar_query,
|
||||
]
|
||||
url = f"https://api.teamup.com/{calendar_id}/events"
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json().get("events", [])
|
||||
|
||||
|
||||
def create_available_slots(
|
||||
start_datetime: datetime, end_datetime: datetime
|
||||
) -> List[TimeSlot]:
|
||||
available_slots = []
|
||||
current_time = start_datetime
|
||||
time_inc = timedelta(minutes=15)
|
||||
while current_time < end_datetime:
|
||||
available_slots.append(TimeSlot(current_time, current_time + time_inc))
|
||||
current_time += time_inc
|
||||
return available_slots
|
||||
|
||||
|
||||
def merge_slots(slots: List[TimeSlot]) -> List[TimeSlot]:
|
||||
new_slots = []
|
||||
if not slots:
|
||||
return new_slots
|
||||
|
||||
current_slot = slots[0]._replace()
|
||||
for slot in slots[1:]:
|
||||
if current_slot.end == slot.start:
|
||||
current_slot = current_slot._replace(end=slot.end)
|
||||
else:
|
||||
new_slots.append(current_slot)
|
||||
current_slot = slot._replace()
|
||||
|
||||
new_slots.append(current_slot)
|
||||
|
||||
return new_slots
|
||||
|
||||
|
||||
def get_free_time_slots(
|
||||
events, date: datetime, start_time: time, end_time: time
|
||||
) -> List[TimeSlot]:
|
||||
start_datetime = date.replace(
|
||||
hour=start_time.hour,
|
||||
minute=start_time.minute,
|
||||
second=start_time.second,
|
||||
microsecond=0,
|
||||
)
|
||||
end_datetime = date.replace(
|
||||
hour=end_time.hour,
|
||||
minute=end_time.minute,
|
||||
second=end_time.second,
|
||||
microsecond=0,
|
||||
)
|
||||
|
||||
available_slots = create_available_slots(start_datetime, end_datetime)
|
||||
|
||||
for event in events:
|
||||
available_slots = [
|
||||
available_slot
|
||||
for available_slot in available_slots
|
||||
if not available_slot.covered(event)
|
||||
]
|
||||
if not available_slots:
|
||||
break
|
||||
|
||||
return merge_slots(available_slots)
|
||||
|
||||
|
||||
async def send_telegram_message(bot_token, channels, message):
|
||||
bot = telegram.Bot(bot_token)
|
||||
lpo = telegram.LinkPreviewOptions(is_disabled=True)
|
||||
for channel in channels:
|
||||
await bot.sendMessage(
|
||||
text=message,
|
||||
parse_mode=telegram.constants.ParseMode.MARKDOWN_V2,
|
||||
chat_id=channel["id"],
|
||||
link_preview_options=lpo,
|
||||
)
|
||||
print("Message sent successfully!")
|
||||
|
||||
|
||||
def fetch_subcalendar_id_from_name(config) -> List[Subcalendar]:
|
||||
subcalendar_ids = fetch_subcalendar_ids(
|
||||
config["teamup_api_key"], config["calendar_id"]
|
||||
)
|
||||
interesting_calendars = config["subcalendars_to_check"]
|
||||
subcalendars_to_check = [
|
||||
Subcalendar(name, int(subcalendar_ids[name]))
|
||||
for name in interesting_calendars
|
||||
if name in subcalendar_ids
|
||||
]
|
||||
|
||||
calendar_not_found = False
|
||||
for name in interesting_calendars:
|
||||
if not name in subcalendar_ids:
|
||||
print(f"Calendar {name} not found in response.")
|
||||
calendar_not_found = True
|
||||
|
||||
if calendar_not_found:
|
||||
print(f"Known calendars: {subcalendar_ids}")
|
||||
sys.exit(1)
|
||||
|
||||
return subcalendars_to_check
|
||||
|
||||
|
||||
def convert_to_date(zone, text, days_to_check=None) -> datetime:
|
||||
date = datetime.strptime(text, "%Y-%m-%d") if text else None
|
||||
|
||||
if date:
|
||||
date = date.replace(tzinfo=zone)
|
||||
else:
|
||||
date = datetime.now(zone)
|
||||
if days_to_check:
|
||||
date += timedelta(days=days_to_check)
|
||||
|
||||
return date
|
||||
|
||||
|
||||
def parse_time(text: str) -> time:
|
||||
return time.fromisoformat(text)
|
||||
|
||||
|
||||
def create_teamup_event_link(config, start: datetime, end: datetime) -> str:
|
||||
format_string = "%Y-%m-%d %H:%M:%S"
|
||||
url_start = urllib.parse.quote(start.strftime(format_string))
|
||||
url_end = urllib.parse.quote(end.strftime(format_string))
|
||||
return f"https://teamup.com/{config['calendar_id']}/events/new?start_dt={url_start}&end_dt={url_end}"
|
||||
|
||||
|
||||
async def check_slots_and_notify(config: map, dry_run: bool = False) -> None:
|
||||
|
||||
sub_calendars = fetch_subcalendar_id_from_name(config)
|
||||
|
||||
tzone = ZoneInfo(config["timezone"])
|
||||
start_date = convert_to_date(tzone, config.get("start_date")).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
) + timedelta(days=1)
|
||||
end_date = convert_to_date(
|
||||
tzone, config.get("end_date"), config.get("days_to_check", 7)
|
||||
).replace(hour=23, minute=59, second=59, microsecond=999)
|
||||
|
||||
print(
|
||||
f"Checking Thekendienst between ({start_date:%A}) {start_date:%Y-%m-%d} and ({end_date:%A}) {end_date:%Y-%m-%d}."
|
||||
)
|
||||
|
||||
events = fetch_events(
|
||||
config["teamup_api_key"],
|
||||
config["calendar_id"],
|
||||
start_date,
|
||||
end_date,
|
||||
sub_calendars,
|
||||
)
|
||||
|
||||
print(
|
||||
f"Found {len(events)} events in the time range for calendars '{"', '".join(sub_calendar.name for sub_calendar in sub_calendars)}'."
|
||||
)
|
||||
|
||||
message = ""
|
||||
for i in range((end_date - start_date).days + 1):
|
||||
check_date = start_date + timedelta(days=i)
|
||||
day_of_week = check_date.strftime("%A") # Get the day name, e.g., 'Monday'
|
||||
|
||||
if day_of_week in config["time_slots"]:
|
||||
start_time = parse_time(config["time_slots"][day_of_week]["start"])
|
||||
end_time = parse_time(config["time_slots"][day_of_week]["end"])
|
||||
|
||||
free_time_slots = get_free_time_slots(
|
||||
events, check_date, start_time, end_time
|
||||
)
|
||||
|
||||
# kind of a hack but I don't want to install any packages
|
||||
old_locale = locale.getlocale()
|
||||
locale.setlocale(locale.LC_ALL, "de_DE.utf8")
|
||||
|
||||
if free_time_slots:
|
||||
free_slots = "\n ".join(
|
||||
[
|
||||
f"`{slot.start:%H:%M} \\- {slot.end:%H:%M}` \\- [{config["appointment_motivator"]}]({create_teamup_event_link(config, slot.start, slot.end)}) 💪"
|
||||
for slot in free_time_slots
|
||||
]
|
||||
)
|
||||
message += f"🚨 `{free_time_slots[0].start:%a}, {free_time_slots[0].start:%d\\.%m\\.} `{free_slots}\n"
|
||||
locale.setlocale(locale.LC_ALL, old_locale)
|
||||
|
||||
if message:
|
||||
message = config["header"] + message + "\n" + config["footer"]
|
||||
else:
|
||||
message = config["no_open_slots"]
|
||||
|
||||
if dry_run:
|
||||
print("Messsage that would be sent on Telegram:")
|
||||
print(message)
|
||||
else:
|
||||
await send_telegram_message(
|
||||
config["telegram_bot_token"], config["telegram_channels"], message
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
default_config_file = "reportMissingThekendienstConfig.json"
|
||||
dry_run = "--dry-run" in sys.argv
|
||||
config_files = [arg for arg in sys.argv[1:] if arg != "--dry-run"]
|
||||
if not config_files:
|
||||
config_files = [default_config_file]
|
||||
config = load_config(config_files)
|
||||
asyncio.run(check_slots_and_notify(config, dry_run))
|
||||
Loading…
Reference in New Issue
Block a user