47 Commits

Author SHA1 Message Date
7b91ddbcb8 Update renovate.json5
All checks were successful
Build container / Build (push) Successful in 34s
2025-07-30 11:40:56 +02:00
8583ccd249 refined package rules
All checks were successful
Build container / Build (push) Successful in 45s
2025-07-30 00:18:13 +02:00
c43bd2b7ec Update deployment/kustomization.yaml
All checks were successful
Build container / Build (push) Successful in 47s
2025-07-29 15:47:14 +00:00
ea8b6fc8f7 Merge pull request 'switched to uv and gitea-actions-based pipeline' (#9) from chore/updated-pipeline into main
All checks were successful
Build container / Build (push) Successful in 43s
Reviewed-on: #9
2025-07-29 15:14:09 +00:00
f7478fb1e3 more refinements for the deployment
All checks were successful
Build container / Build (pull_request) Successful in 1m10s
2025-07-29 15:58:34 +02:00
29d951427d switched to uv and gitea-actions-based pipeline
All checks were successful
Build container / Build (pull_request) Successful in 43s
2025-07-29 14:28:07 +02:00
b12eb62b41 [CI SKIP] Update mollre/journal-bot Docker tag to v1.0.68 2024-03-12 20:52:02 +00:00
f5c3d767c0 [CI SKIP] update 2024-03-12 20:50:54 +00:00
d8407bac65 Update renovate.json
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-12 20:41:31 +00:00
2f9d94406c fix syntax
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-12 19:20:11 +01:00
1ca13a9451 Merge pull request 'add day rating feature' (#6) from feature/day-rating into main
Reviewed-on: #6
2024-03-12 18:15:23 +00:00
0cdc359463 add day rating feature 2024-03-12 19:14:29 +01:00
2d923df965 [CI SKIP] Update mollre/journal-bot Docker tag to v1.0.66
Reviewed-on: #5
2024-01-02 21:11:40 +00:00
421c3a7e1f [CI SKIP] Update mollre/journal-bot Docker tag to v1.0.66 2024-01-02 21:00:12 +00:00
Lia Schöneweiß
9eafa55dd8 fix date check
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-02 21:50:15 +01:00
2a344817f7 [CI SKIP] update 2023-12-11 16:12:51 +00:00
27656c21ae Update renovate.json
Some checks reported errors
continuous-integration/drone/push Build was killed
2023-12-11 16:03:04 +00:00
argocd-image-updater
b733d1040c [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.63' to '1.0.64'
2023-12-08 17:46:23 +00:00
6d9c60b0d7 Merge pull request 'Configure Renovate' (#4) from renovate/configure into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #4
2023-12-08 17:44:11 +00:00
84fb43e836 Add renovate.json 2023-12-07 15:42:02 +00:00
argocd-image-updater
cfcea80a64 [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.62' to '1.0.63'
2023-12-02 18:05:07 +00:00
7daf30f851 Fixing what Rémy told me wrong.
All checks were successful
continuous-integration/drone/push Build is passing
2023-12-02 19:02:46 +01:00
argocd-image-updater
a030d06641 [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.61' to '1.0.62'
2023-11-28 15:36:10 +00:00
15304d565e update storage classes
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-28 16:33:50 +01:00
argocd-image-updater
6094112f48 [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.60' to '1.0.61'
2023-11-25 15:00:28 +00:00
Lia Schöneweiß
c75fb0b7a3 Merge branch 'main' of ssh://git.kluster.moll.re:2222/remoll/journal-bot
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-25 15:57:49 +01:00
Lia Schöneweiß
b2820ce902 bug fixes 2023-11-25 15:57:47 +01:00
argocd-image-updater
b605ce315b [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.59' to '1.0.60'
2023-11-25 14:09:51 +00:00
Lia Schöneweiß
dc14eb0aec upsiwupsi
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-25 15:07:39 +01:00
argocd-image-updater
393ac72191 [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.58' to '1.0.59'
2023-11-25 13:59:42 +00:00
Lia Schöneweiß
f235b27916 Merge branch 'main' of ssh://git.kluster.moll.re:2222/remoll/journal-bot
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-25 14:57:37 +01:00
Lia Schöneweiß
c4fbd089cc Merge branch 'secretbranch' 2023-11-25 14:56:19 +01:00
Lia Schöneweiß
18af074b6d Geheim! 2023-11-25 14:52:47 +01:00
argocd-image-updater
ec87c6751c [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.57' to '1.0.58'
2023-11-18 12:00:35 +00:00
724b17c4b7 broadened regex
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-18 12:59:03 +01:00
argocd-image-updater
8b7a318e6b [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.56' to '1.0.57'
2023-11-18 11:50:25 +00:00
Lia Schöneweiß
a0e1aaa779 Merge branch 'feature/memory-redacted'
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-18 12:48:38 +01:00
Lia Schöneweiß
94933c2da0 streng geheim! 2023-11-18 12:38:58 +01:00
c9254a3e88 memories are now redacted 2023-11-18 12:32:20 +01:00
argocd-image-updater
3097594482 [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.55' to '1.0.56'
2023-11-17 21:56:17 +00:00
49df5a4495 cleanup the model layout
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-17 22:53:06 +01:00
argocd-image-updater
9eb7f5bb77 [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.54' to '1.0.55'
2023-10-21 13:35:11 +00:00
35dbbe4ece cleaner journal date entry
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-21 15:34:24 +02:00
6b63276dd7 playing with regexes 2023-10-21 15:23:05 +02:00
argocd-image-updater
d40afca1a4 [CI SKIP] automatic update of journal-application
updates image mollre/journal-bot tag '1.0.53' to '1.0.54'
2023-10-12 21:04:35 +00:00
0e2b714848 Merge branch 'main'
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-12 23:03:29 +02:00
df55dbf6c7 improved journal date selection 2023-10-12 23:03:18 +02:00
31 changed files with 802 additions and 363 deletions

View File

@@ -1,29 +0,0 @@
kind: pipeline
type: kubernetes
name: docker-build
node_selector:
kubernetes.io/arch: amd64
steps:
- name: docker
image: plugins/docker
settings:
username:
from_secret: docker_uname
password:
from_secret: docker_pw
repo: mollre/journal-bot
tags:
- 1.0.${DRONE_BUILD_NUMBER}
- latest
build_args: "BOT_VERSION=1.0.${DRONE_BUILD_NUMBER}"
trigger:
branch:
- main
event:
- push

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use nix

View File

@@ -0,0 +1,35 @@
on:
pull_request:
branches:
- main
push:
branches:
- main
name: Build container
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: git.kluster.moll.re
username: ${{ gitea.repository_owner }}
password: ${{ secrets.PACKAGE_REGISTRY_ACCESS }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
# use the current commit SHA as the tag
tags: git.kluster.moll.re/remoll/journal-bot:${{ gitea.sha }}
push: true

10
.gitignore vendored
View File

@@ -1,9 +1,13 @@
# Nix shell files
.direnv/
# Secrets # Secrets
dev.env *.secret.yaml
secret.yaml
# Static data # Static data
.bot_storage/ .bot/
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

11
.vscode/launch.json vendored
View File

@@ -4,18 +4,9 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"name": "Python: Current File",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": true
},
{ {
"name": "Python: Current project", "name": "Python: Current project",
"type": "python", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/bot/main.py", "program": "${workspaceFolder}/bot/main.py",
"console": "integratedTerminal", "console": "integratedTerminal",

View File

@@ -1,17 +1,14 @@
FROM python:3-slim FROM docker.io/python:3.13-alpine
ENV DOCKERIZED=true # use the latest version of uv, independently of the python version
ARG BOT_VERSION COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# set at build time
ENV BOT_VERSION=$BOT_VERSION
WORKDIR /app WORKDIR /app
RUN pip install pipenv
# copy the requirements and install them
COPY pyproject.toml uv.lock .
RUN uv sync --frozen
COPY Pipfile Pipfile.lock ./ # copy the rest of the code
RUN pipenv install --system --deploy
COPY bot . COPY bot .
CMD ["python", "main.py"] CMD ["uv", "run", "main.py"]

View File

@@ -1,7 +0,0 @@
CURRENT_DIR := $(shell pwd)
DOTENV := ${CURRENT_DIR}/dev.env
PIPENV_CMD_PREFIX := PIPENV_DOTENV_LOCATION=${DOTENV} pipenv run
run:
${PIPENV_CMD_PREFIX} python bot/main.py

14
Pipfile
View File

@@ -1,14 +0,0 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
peewee = "*"
python-telegram-bot = {extras = ["job-queue"], version = "*"}
anyio = "*"
[dev-packages]
[pipenv]
allow_prereleases = true

131
Pipfile.lock generated
View File

@@ -1,131 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "5458e81c4f85af776acc44f46af838644ef8c00ccf4223fbe06f9d76a4717fc6"
},
"pipfile-spec": 6,
"requires": {},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"anyio": {
"hashes": [
"sha256:48d53f0b141f5757c38d648309e6fe254857fae092d67f938fa248d7c0f36804",
"sha256:596b09c520820e7eed961ddc889540972f92d5e8fcb081117fc054c409df34ae"
],
"index": "pypi",
"version": "==4.0.0rc1"
},
"apscheduler": {
"hashes": [
"sha256:0293937d8f6051a0f493359440c1a1b93e882c57daf0197afeff0e727777b96e",
"sha256:e813ad5ada7aff36fb08cdda746b520531eaac7757832abc204868ba78e0c8f6"
],
"version": "==3.10.1"
},
"certifi": {
"hashes": [
"sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082",
"sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"
],
"markers": "python_version >= '3.6'",
"version": "==2023.7.22"
},
"h11": {
"hashes": [
"sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d",
"sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"
],
"markers": "python_version >= '3.7'",
"version": "==0.14.0"
},
"httpcore": {
"hashes": [
"sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888",
"sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"
],
"markers": "python_version >= '3.7'",
"version": "==0.17.3"
},
"httpx": {
"hashes": [
"sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd",
"sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"
],
"markers": "python_version >= '3.7'",
"version": "==0.24.1"
},
"idna": {
"hashes": [
"sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4",
"sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"
],
"markers": "python_version >= '3.5'",
"version": "==3.4"
},
"peewee": {
"hashes": [
"sha256:10769981198c7311f84a0ca8db892fa213303a8eb1305deb795a71e7bd606a91"
],
"index": "pypi",
"version": "==3.16.2"
},
"python-telegram-bot": {
"extras": [
"job-queue"
],
"hashes": [
"sha256:a6ac3f9c9674aaf7d1c7e652d8b75cde969fb872f75e9521b8516eceaba82b1b",
"sha256:e426404b0006989a5bcc05e11a7ef3ffe0c086b684a4e963db5bda1d361a049a"
],
"index": "pypi",
"version": "==20.4"
},
"pytz": {
"hashes": [
"sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588",
"sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"
],
"version": "==2023.3"
},
"setuptools": {
"hashes": [
"sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f",
"sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"
],
"markers": "python_version >= '3.7'",
"version": "==68.0.0"
},
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"sniffio": {
"hashes": [
"sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101",
"sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.0"
},
"tzlocal": {
"hashes": [
"sha256:46eb99ad4bdb71f3f72b7d24f4267753e240944ecfc16f25d2719ba89827a803",
"sha256:f3596e180296aaf2dbd97d124fe76ae3a0e3d32b258447de7b939b3fd4be992f"
],
"markers": "python_version >= '3.7'",
"version": "==5.0.1"
}
},
"develop": {}
}

View File

@@ -1,3 +1,9 @@
# journal-bot # journal-bot
Sharing memories, the digital way... Sharing memories, the digital way...
## Migration 10.03.24
```
ALTER TABLE journalentry ADD COLUMN rating INTEGER;
```

149
bot/commands/advent.py Normal file
View File

@@ -0,0 +1,149 @@
from datetime import date
from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, filters, CallbackQueryHandler, CallbackContext
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update, InputMediaPhoto
import random
import os
from pathlib import Path
# ACTION_CHOICE, DATE_ENTRY, ADD_CONTENT = range(3)
ACTION, TUERCHEN_CHOICE = range(2)
from .basehandler import BaseHandler
MEDIA_DIR = Path(os.getenv("MEDIA_DIR"))
GIF_LOCATION = MEDIA_DIR / "advent" / "gifs"
# GIFS = list(GIF_LOCATION.glob("*.mp4"))
STICKER_LOCATION = MEDIA_DIR / "advent" / "stickers"
# STICKERS = list(STICKER_LOCATION.glob("*.tgs"))
PICTURE_LOCATION = MEDIA_DIR / "advent" / "pretty_pictures"
PICTURES = list(PICTURE_LOCATION.glob("*.jpg"))
class AdventsHandler(BaseHandler):
def __init__(self, entry_string):
self.entry_string = entry_string
self. tuerchen_texte = {
"1": f"Mein kleiner, süßer Schatz, ich geb dir einen Schmatz! \n - Gutschein für ein gaaanz dickes Küsschen! 😘",
"2": f"Die zwei ist eine tolle Zahl, \ndrum schenk ich dir nach deiner Wahl \nein Frühstück so wie's dir gefällt,\n dem cutesten Schuhu der Welt!\n - Gutschein für ein Wunschfrühstück von deinem Bubo 🥞",
"3": f"Die Nummer heute ist die drei mit einer Prise Glück dabei! \n - Der Tag für das Glückskleeblattspritzgebäck ist gekommen! 🍀",
"4": f"Hand in Hand, ganz fest verhakt, geht wir zwei auf den Weihnachtsmarkt!\n - Gutschein für einen heißen Punsch auf dem Zürcher Weihnachtsmarkt 🍹",
"5": f"Fühlt der Schuhu sich mal matt,\nso höre gut auf meinen Rat:\nMassageöl auf das Gefieder,\nDann glänzt und strahlt der Schuhu wieder!\n - Gutschein für eine Massage 👐🏻",
"6": f"Das ist das Haus vom Nikolaus\nund nebendrein der Bubos Heim!\n - Ich habe eine kleine Nascherei für dich! 🍫",
"7": f"Für etwas Spaß braucht es nicht viel,\nden Bubo und ein gutes Spiel! - Gutschein für einen Zockabend mit einem Spiel deiner Wahl 🎮",
"8": f"Zu abend da erzähl ich dir,\nso wundersam Geschichte fein\ndrum spitz dir ohren, lausche mir\ndoch schlaf mir dabei bloß nicht ein!\n - Gutschein für eine Vorlesesession 📖",
"9": f"Es duftet fein aus naher Ferne, denn naschen tuen Kätzchen gerne!\n - Gutschein für ein Überraschungsdessert von deinem Naschkätzchen 😽",
"10": f"Zu spät für Frühstück, zu spät für Lunch, dann machen wir daraus 'nen Brunch!\n - Gutschein für einen Brunch im Café des Amis 🥐",
"11": f"Mein Bubo macht so gerne Quatsch und ist so süß wie Brownie Fudge!\n - Gutschein für ein Ben'n'Jerry's Brownie Fudge Ice Cream 🍫",
"12": f"Halb ist geschafft die lange Reise,\ndrum wähle deine Toppings weise\nmit Apfel, Zimt und andren Sachen, wenn wir zusammen Milchreis machen!\n - Gutschein für eine Milchreismahlzeit, damit dir auf halber Strecke nicht die Kraft ausgeht 🍚",
"13": f"Was hat hier so verbrannt gerochen?\n Das sind die Schuhus, die hier kochen!\n - Gutschein für eine Kochsession für ein ausgefallenes Gericht deiner Wahl 👩‍🍳👨‍🍳",
"14": f"Auf leckres Essen darfst du hoffen, warm und dampfend aus dem Ofen!\n - Gutschein für einen Abend zum Bratapfel-Backen 🍎",
"15": f"Die Weihnacht langsam Einzug hält,\nder erste Schnee vom Himmel fällt,\nGedanken tief in ihm versinken,\nlass uns gehn' einen Kaffee trinken!\n - Gutschein für einen Nachmittagskaffee in der Altstadt ☕️",
"16": f"Ein Glück, der See noch nicht erstarrt,\nPerfekt fpr eine Bötchenfahrt!\n - Gutschein für eine Bootsfahrt auf dem Zürisee 🛳",
"17": f"Was schaut denn da so frech ums Eck,\nnanu, das ist dein Öhrchen-Schleck! \n - Du bekommst gratis einen tollen Öhrchenschleck für beide Schuhu-Spitzöhrchen! 👂🏻",
"18": f"Was ist im Winter glatt ein Muss?\nNa klar, der Spekulatius!\n - Gutschein für ein kleines Spekulatiusdessert 🍰",
"19": f"Wenn Bubos sind mal ganz allein, da können sie ganz unbrav sein...\n - Gutschein für eine ganz zärtliche, persönliche Verführung von deiner Bubodame 😈",
"20": f"Bubo, pack dich ganz warm ein\ndenn bald wird es noch kälter sein!\n - Gutschein für einen Eiskaffe bei Sprüngli und dabei gaaanz warm kuscheln! 🍨",
"21": f"Die Kufen werden angezogen,\nwir kommen übers Eis geflogen!\n - Gutschein für Schlittschuhlaufen, Flügel in Flügel ⛸",
"22": f"Oh bald es wird schon wieder wärmer,\nauf geht's in die Gelateria di Berna!\n - Gutschein für eine dicke Portion Eis in der Gelateria di Berna 🍦",
"23": f"Kleiner Bubo, moki moki,\nich koch dir eine heiße Schoki,\ndamit der Hunger auch nicht weint\nmit einem Kekse sie vereint!\n - Gutschein für eine heiße Spezial-Schoki mit Keksi dazu! 🍪",
"24": f"Das lange Warten ist geschafft,\ngekostet hat es ganz viel Kraft,\nnun höre mich, wenn ich dir sage:\nIch wünsch dir tolle Weihnachtstage,\nmit Family und Freunden schön\nund Kekse auf dem Tische stehend\nund in der Ferne, doch so nah\nist immer jemand für dich da,\nder an dich denkt und dich so liebt\n Mein Bubo ich hab dich so gern! ❤️🎄"
}
self.handler = ConversationHandler(
entry_points=[CommandHandler(entry_string, self.entry_point)],
states={
ACTION: [
CallbackQueryHandler(self.kuesschen, pattern = "^kuss$"),
CallbackQueryHandler(self.tuerchen, pattern = "^tuer$"),
CallbackQueryHandler(self.pretty_picture, pattern="^picture$"),
CallbackQueryHandler(self.byebye, pattern ="^bye$")
# CallbackQueryHandler(self.new_list, pattern="^new$")
],
TUERCHEN_CHOICE: [
MessageHandler(filters.ALL, self.choose_tuerchen)
]
},
fallbacks=[],
)
self.current_model = None
async def entry_point(self, update: Update, context: CallbackContext):
await super().entry_point(update, context)
keyboard = [[InlineKeyboardButton("Bubo Küsschen", callback_data="kuss")], [InlineKeyboardButton("Türchen öffnen", callback_data="tuer")], [InlineKeyboardButton("Pretty Bubo Picture", callback_data="picture")]]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_sticker(sticker=STICKER_LOCATION/"stickerwhat.tgs")
await update.message.reply_text(text="Hallo, mein süßer Weihnachts-Bubo! Ich bin dein Adventskalender ^^. Was möchtest du tun?", reply_markup=reply_markup)
return ACTION
async def kuesschen(self, update: Update, context: CallbackContext):
query = update.callback_query
await query.answer()
keyboard = [[InlineKeyboardButton("Kalendertürchen öffnen", callback_data="tuer")], [InlineKeyboardButton("Noch ein Küsschen!", callback_data="kuss")], [InlineKeyboardButton("Pretty Bubo Picture", callback_data="picture")], [InlineKeyboardButton("Bis zum nächsten Mal!", callback_data="bye")]]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.effective_message.reply_sticker(sticker=STICKER_LOCATION/"stickerkiss.tgs")
await update.effective_message.reply_text(text="Mua!", reply_markup=reply_markup)
return ACTION
async def tuerchen(self, update: Update, context: CallbackContext):
query = update.callback_query
await query.answer()
await update.effective_message.reply_text(text="Welches Türchen möchtest du öffnen? Bitte gib die Zahl des Türchens ein:")
return TUERCHEN_CHOICE
async def pretty_picture(self, update: Update, context: CallbackContext):
query = update.callback_query
await query.answer()
keyboard = [[InlineKeyboardButton("Kalendertürchen öffnen", callback_data="tuer")], [InlineKeyboardButton("Bubo Küsschen", callback_data="kuss")], [InlineKeyboardButton("Noch ein Bild!", callback_data="picture")], [InlineKeyboardButton("Bis zum nächsten Mal!", callback_data="bye")]]
reply_markup = InlineKeyboardMarkup(keyboard)
picture_number = random.randint(1,31)
#print(picture_number-1)
await update.effective_message.reply_photo(photo=PICTURES[picture_number-1], caption="So ein cutes Foto!", reply_markup=reply_markup)
return ACTION
async def choose_tuerchen(self, update: Update, context: CallbackContext):
tuerchen = update.message.text
tuer_nummer = int(tuerchen)
#print(date.today().strftime("%d"+"%m"))
keyboard = [[InlineKeyboardButton("Anderes Türchen öffnen", callback_data="tuer")], [InlineKeyboardButton("Bubo Küsschen", callback_data="kuss")], [InlineKeyboardButton("Pretty Bubo Picture", callback_data="picture")], [InlineKeyboardButton("Bis zum nächsten Mal!", callback_data="bye")]]
reply_markup = InlineKeyboardMarkup(keyboard)
datum_tuer_heute = date(2023, 12, tuer_nummer)
if (date.today() - datum_tuer_heute).days >= 0:
await update.message.reply_document(document=GIF_LOCATION/ f"gif{tuer_nummer}.mp4", caption=f"Türchen für den {tuer_nummer}. Dezember: \n" + self.tuerchen_texte[tuerchen], reply_markup=reply_markup)
else:
await update.message.reply_sticker(sticker=STICKER_LOCATION/"stickerangry.tgs")
await update.message.reply_text(text="Hey, nicht schummeln! Dieses Türchen darfst du noch nicht sehen.", reply_markup=reply_markup)
return ACTION
async def byebye(self, update: Update, context: CallbackContext):
query = update.callback_query
await query.answer()
await update.effective_message.reply_sticker(sticker=STICKER_LOCATION/"stickerbye.tgs")
await update.effective_message.reply_text(text="Bye bye, Bubo! Hab dich ganz doll lieb! 😘")
return ConversationHandler.END

View File

@@ -2,28 +2,33 @@ import datetime
import os import os
from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, filters, CallbackQueryHandler from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, filters, CallbackQueryHandler
from telegram import InlineKeyboardButton, InlineKeyboardMarkup from telegram import InlineKeyboardButton, InlineKeyboardMarkup
ACTION_CHOICE, DATE_ENTRY, ADD_CONTENT = range(3) from telegram.constants import ParseMode
import models
ENTRY_OPTIONS, CONTENT_ENTRY, DAY_RATING = range(3)
BUTTON_COUNT = 5
from .basehandler import BaseHandler from .basehandler import BaseHandler
class JournalHandler(BaseHandler): class JournalHandler(BaseHandler):
def __init__(self, entry_string, models): def __init__(self, entry_string):
self.models = models
self.entry_string = entry_string self.entry_string = entry_string
self.handler = ConversationHandler( self.handler = ConversationHandler(
entry_points=[CommandHandler(entry_string, self.entry_point)], entry_points=[CommandHandler(entry_string, self.entry_point)],
states={ states={
ACTION_CHOICE: [ ENTRY_OPTIONS: [
CallbackQueryHandler(self.date_choice, pattern="today|yesterday"), CallbackQueryHandler(self.date_button, pattern=r"^\d{8}$"), # a serialized date
CallbackQueryHandler(self.date_custom, pattern="custom"), CallbackQueryHandler(self.date_custom, pattern=r"^\d{1,3}$"), # a ~ small delta, symbolizing a new range to show
CallbackQueryHandler(self.option_delete, pattern="delete") CallbackQueryHandler(self.option_delete, pattern="delete"),
],
DATE_ENTRY: [
MessageHandler(filters.ALL, self.date_entry), MessageHandler(filters.ALL, self.date_entry),
], ],
ADD_CONTENT: [ CONTENT_ENTRY: [
MessageHandler(filters.ALL, self.content_save), MessageHandler(filters.ALL, self.content_save),
] ],
DAY_RATING: [
CallbackQueryHandler(self.day_rating_save),
],
}, },
fallbacks=[], fallbacks=[],
) )
@@ -33,41 +38,41 @@ class JournalHandler(BaseHandler):
async def entry_point(self, update, context): async def entry_point(self, update, context):
await super().entry_point(update, context) await super().entry_point(update, context)
if os.getenv("DOCKERIZED", "false") == "true" and os.getenv("CHAT_ID") != str(update.message.chat_id): if models.IS_PRODUCTION and os.getenv("CHAT_ID") != str(update.message.chat_id):
await update.message.reply_text("You are not authorized to use this bot") await update.message.reply_text("You are not authorized to use this bot")
return ConversationHandler.END return ConversationHandler.END
dates = [(datetime.datetime.now() - datetime.timedelta(days = i)).date() for i in range(BUTTON_COUNT + 2)][::-1]
# since there are two buttons additional buttons, we need to have two more days
names = get_names(dates)
callbacks = [d.strftime("%d%m%Y") for d in dates]
options = [[ options = [
InlineKeyboardButton("Today", callback_data="today"), [InlineKeyboardButton(n, callback_data=c)] for n,c in zip(names[::-1], callbacks[::-1])
InlineKeyboardButton("Yesterday", callback_data="yesterday"), ] + [
InlineKeyboardButton("Custom date", callback_data="custom"), [
InlineKeyboardButton("<<", callback_data=BUTTON_COUNT + 2)
], ],
[ [
InlineKeyboardButton("Delete", callback_data="delete") InlineKeyboardButton("Delete", callback_data="delete")
] ]
] ]
keyboard = InlineKeyboardMarkup(options) keyboard = InlineKeyboardMarkup(options)
await update.message.reply_text("Please choose an option for the entry:", reply_markup=keyboard) await update.message.reply_text("Please choose a date \(or type it in the format _DDMMYYYY_\)", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN_V2)
return ACTION_CHOICE return ENTRY_OPTIONS
async def date_choice(self, update, context): async def date_button(self, update, context):
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
if query.data == "today": date = datetime.datetime.strptime(query.data, "%d%m%Y").date()
date = datetime.datetime.now().date()
elif query.data == "yesterday":
date = datetime.datetime.now().date() - datetime.timedelta(days=1)
else:
raise ValueError("Invalid date choice")
with self.models.db: with models.db:
self.current_model, new = self.models.JournalEntry.get_or_create( self.current_model, new = models.JournalEntry.get_or_create(
date = date date = date
) )
if new: if new:
count = self.models.JournalEntry.select().count() count = models.JournalEntry.select().count()
await query.edit_message_text( await query.edit_message_text(
text=f"Journal entry no. {count}. What happened on {self.current_model.date_pretty}?" text=f"Journal entry no. {count}. What happened on {self.current_model.date_pretty}?"
) )
@@ -75,15 +80,36 @@ class JournalHandler(BaseHandler):
await query.edit_message_text(text="An entry already exists for this date") await query.edit_message_text(text="An entry already exists for this date")
return ConversationHandler.END return ConversationHandler.END
return ADD_CONTENT return CONTENT_ENTRY
async def date_custom(self, update, context): async def date_custom(self, update, context):
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
await query.edit_message_text(text="Please enter the date in the format DDMMYYYY") delta = int(query.data)
return DATE_ENTRY
dates = [(datetime.datetime.now() - datetime.timedelta(days = i + delta)).date() for i in range(BUTTON_COUNT)][::-1]
names = get_names(dates)
callbacks = [d.strftime("%d%m%Y") for d in dates]
options = [
[
InlineKeyboardButton(">>", callback_data=delta - BUTTON_COUNT)
]
] + [
[InlineKeyboardButton(n, callback_data=c)] for n,c in zip(names[::-1], callbacks[::-1])
] + [
[
InlineKeyboardButton("<<", callback_data=delta + BUTTON_COUNT)
],
[
InlineKeyboardButton("Delete", callback_data="delete")
]
]
keyboard = InlineKeyboardMarkup(options)
await query.edit_message_text("Please choose a date \(or type it in the format _DDMMYYYY_\)", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard)
return ENTRY_OPTIONS
async def date_entry(self, update, context): async def date_entry(self, update, context):
date = update.message.text date = update.message.text
@@ -91,12 +117,12 @@ class JournalHandler(BaseHandler):
try: try:
date = datetime.datetime.strptime(date, "%d%m%Y").date() date = datetime.datetime.strptime(date, "%d%m%Y").date()
except ValueError: except ValueError:
await update.message.reply_text("Please enter the date in the format DDMMYYYY") await update.message.reply_text("Please enter the date in the format _DDMMYYYY_", parse_mode=ParseMode.MARKDOWN_V2)
return DATE_ENTRY return ENTRY_OPTIONS
if context.chat_data.get("delete", False): # if not set, delete was not chosen if context.chat_data.get("delete", False): # if not set, delete was not chosen
with self.models.db: with models.db:
self.current_model = self.models.JournalEntry.get_or_none( self.current_model = models.JournalEntry.get_or_none(
date = date date = date
) )
if self.current_model: if self.current_model:
@@ -106,23 +132,23 @@ class JournalHandler(BaseHandler):
context.chat_data["delete"] = False context.chat_data["delete"] = False
return ConversationHandler.END return ConversationHandler.END
else: else:
with self.models.db: with models.db:
self.current_model, new = self.models.JournalEntry.get_or_create( self.current_model, new = models.JournalEntry.get_or_create(
date = date date = date
) )
if not new: if not new:
await update.message.reply_text("An entry already exists for this date") await update.message.reply_text("An entry already exists for this date")
return ConversationHandler.END return ConversationHandler.END
else: else:
count = self.models.JournalEntry.select().count() count = models.JournalEntry.select().count()
await update.message.reply_text( await update.message.reply_text(
text=f"Journal entry no. {count}. What happened on {self.current_model.date_pretty}?" text=f"Journal entry no. {count}. What happened on {self.current_model.date_pretty}?"
) )
return ADD_CONTENT return CONTENT_ENTRY
async def content_save(self, update, context): async def content_save(self, update, context):
with self.models.db: with models.db:
self.current_model.author_id = update.message.from_user.id self.current_model.author_id = update.message.from_user.id
if update.message.text: if update.message.text:
@@ -141,20 +167,55 @@ class JournalHandler(BaseHandler):
self.current_model.save() self.current_model.save()
await update.message.reply_text(f"Saved entry ✅") options = [
[InlineKeyboardButton(models.RATING_MAPPING[idx], callback_data=idx) for idx in [1,2,3,4,5]]
]
await update.message.reply_text(f"Saved entry ✅. How was the day?", reply_markup=InlineKeyboardMarkup(options))
return DAY_RATING
async def day_rating_save(self, update, context):
query = update.callback_query
await query.answer()
rating = int(query.data)
with models.db:
self.current_model.rating = rating
self.current_model.save()
await query.edit_message_text(text="Rating saved ✅")
return ConversationHandler.END return ConversationHandler.END
async def option_delete(self, update, context): async def option_delete(self, update, context):
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
await query.edit_message_text(text="Please enter the date in the format DDMMYYYY") await query.edit_message_text(text="Please enter the date in the format _DDMMYYYY_", parse_mode=ParseMode.MARKDOWN_V2)
context.chat_data["delete"] = True context.chat_data["delete"] = True
return DATE_ENTRY return ENTRY_OPTIONS
async def delete_entry(self, update, context): async def delete_entry(self, update, context):
with self.models.db: with models.db:
self.current_model.delete_instance() self.current_model.delete_instance()
context.chat_data["delete"] = False context.chat_data["delete"] = False
await update.message.reply_text(text="Entry deleted ✅") await update.message.reply_text(text="Entry deleted ✅")
### HELPERS
def get_names(dates: list):
names = []
for d in dates:
suffix = ""
if models.JournalEntry.get_or_none(date = d):
suffix = ""
if d == datetime.datetime.now().date():
names.append("Today" + suffix)
elif d == datetime.datetime.now().date() - datetime.timedelta(days = 1):
names.append("Yesterday" + suffix)
else:
names.append(d.strftime("%d.%m.") + suffix)
return names

View File

@@ -15,8 +15,7 @@ from ..basehandler import BaseHandler
class ListHandler(BaseHandler): class ListHandler(BaseHandler):
"""Create and edit lists""" """Create and edit lists"""
def __init__(self, entry_string, models): def __init__(self, entry_string):
del models # not needed here, but part of the template
self.entry_string = entry_string self.entry_string = entry_string
set_db(PERSISTENCE_DIR / "lists.sqlite") set_db(PERSISTENCE_DIR / "lists.sqlite")

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from peewee import * from peewee import *
db = DatabaseProxy() db = DatabaseProxy()
class BaseModel(Model): class BaseModel(Model):
@@ -46,7 +46,8 @@ class ListEntryModel(BaseModel):
done = BooleanField(default=None, null=True) done = BooleanField(default=None, null=True)
def set_db(db_path): def set_db(db_path: Path):
db_path.parent.mkdir(parents=True, exist_ok=True)
db.initialize(SqliteDatabase(db_path)) db.initialize(SqliteDatabase(db_path))
with db: with db:
db.create_tables([ListModel, ListEntryModel], safe=True) db.create_tables([ListModel, ListEntryModel], safe=True)

View File

@@ -1,15 +1,16 @@
import os import os
from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, filters, CallbackQueryHandler, CallbackContext from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, filters, CallbackQueryHandler, CallbackContext
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update, InputMediaPhoto from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update, InputMediaPhoto
# ACTION_CHOICE, DATE_ENTRY, ADD_CONTENT = range(3) import models
from telegram.constants import ParseMode
MEMORY_CHOICE = range(1) MEMORY_CHOICE = range(1)
from .basehandler import BaseHandler from .basehandler import BaseHandler
class MemoryHandler(BaseHandler): class MemoryHandler(BaseHandler):
def __init__(self, entry_string, models): def __init__(self, entry_string):
self.models = models
self.entry_string = entry_string self.entry_string = entry_string
self.handler = ConversationHandler( self.handler = ConversationHandler(
entry_points=[CommandHandler(entry_string, self.entry_point, )], entry_points=[CommandHandler(entry_string, self.entry_point, )],
@@ -27,20 +28,20 @@ class MemoryHandler(BaseHandler):
async def entry_point(self, update: Update, context: CallbackContext): async def entry_point(self, update: Update, context: CallbackContext):
await super().entry_point(update, context) await super().entry_point(update, context)
if os.getenv("DOCKERIZED", "false") == "true" and os.getenv("CHAT_ID") != str(update.message.chat_id): if models.IS_PRODUCTION and os.getenv("CHAT_ID") != str(update.message.chat_id):
await update.message.reply_text("You are not authorized to use this bot") await update.message.reply_text("You are not authorized to use this bot")
return ConversationHandler.END return ConversationHandler.END
search_string = " ".join(context.args) search_string = " ".join(context.args)
if search_string == '~photo': if search_string == '~photo':
matching_models = self.models.JournalEntry.select().where(self.models.JournalEntry.media_path != "").order_by(self.models.JournalEntry.date) matching_models = models.JournalEntry.select().where(models.JournalEntry.media_path != "").order_by(models.JournalEntry.date)
else: # searching for text else: # searching for text
matching_models = self.models.JournalEntry.select().where( matching_models = models.JournalEntry.select().where(
self.models.JournalEntry.text.contains( models.JournalEntry.text.contains(
search_string search_string
) )
).order_by(self.models.JournalEntry.date) ).order_by(models.JournalEntry.date)
# exit if no memory matches the string # exit if no memory matches the string
@@ -68,20 +69,23 @@ class MemoryHandler(BaseHandler):
matching_models = context.chat_data["kept_matches"] matching_models = context.chat_data["kept_matches"]
chosen_match = matching_models[ind] chosen_match = matching_models[ind]
rating_string = f" ({models.RATING_MAPPING[chosen_match.rating]})" if chosen_match.rating else ""
message_text = f"On {chosen_match.date_pretty}{rating_string}, " \
f"{chosen_match.author} wrote: \n" \
f"{chosen_match.spoiler_text}"
if chosen_match.media_path: if chosen_match.media_path:
# context.bot.sendPhoto() # context.bot.sendPhoto()
await update.effective_message.reply_photo( await update.effective_message.reply_photo(
photo = chosen_match.media_path, photo = chosen_match.media_path,
caption= caption = message_text,
f"On {chosen_match.date_pretty}, " parse_mode=ParseMode.HTML
f"{chosen_match.author} wrote: \n"
f"{chosen_match.text}"
) )
else: else:
await query.edit_message_text( await query.edit_message_text(
f"On {chosen_match.date_pretty}, " message_text,
f"{chosen_match.author} wrote: \n" parse_mode=ParseMode.HTML
f"{chosen_match.text}"
) )
return ConversationHandler.END return ConversationHandler.END

View File

@@ -6,16 +6,18 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.constants import ParseMode from telegram.constants import ParseMode
import os import os
FIRST = 1 FIRST = 1
import models
from .basehandler import BaseHandler from .basehandler import BaseHandler
class StatusHandler(BaseHandler): class StatusHandler(BaseHandler):
"""Shows a short status of the program.""" """Shows a short status of the program."""
def __init__(self, entry_string, models): def __init__(self, entry_string):
self.start_time = datetime.datetime.now() self.start_time = datetime.datetime.now()
self.entry_string = entry_string self.entry_string = entry_string
self.models = models
self.handler = ConversationHandler( self.handler = ConversationHandler(
entry_points=[CommandHandler(self.entry_string, self.entry_point)], entry_points=[CommandHandler(self.entry_string, self.entry_point)],
states={ states={
@@ -37,7 +39,6 @@ class StatusHandler(BaseHandler):
reply_markup = InlineKeyboardMarkup(keyboard) reply_markup = InlineKeyboardMarkup(keyboard)
delta = str(datetime.datetime.now() - self.start_time) delta = str(datetime.datetime.now() - self.start_time)
message = "BeebBop, this is Norbit\n"
try: try:
ip = httpx.get('https://api.ipify.org').text ip = httpx.get('https://api.ipify.org').text
@@ -49,12 +50,15 @@ class StatusHandler(BaseHandler):
ip = "not fetchable" ip = "not fetchable"
local_ips = "not fetchable" local_ips = "not fetchable"
message += "Status: Running 🟢\n" message = f"""
message += f"Version: `{os.getenv('BOT_VERSION', 'dev')}`\n" BeebBop\!
message += f"Uptime: `{delta[:delta.rfind('.')]}`\n" Status: Running 🟢
message += f"IP \(public\): `{ip}`\n" Version: `{os.getenv('BOT_VERSION', 'dev')}` and`prod={models.IS_PRODUCTION}`
message += f"IP \(private\): `{local_ips}`\n" Uptime: `{delta[:delta.rfind('.')]}`
message += f"Chat ID: `{update.effective_chat.id}`\n" IP \(public\): `{ip}`
IP \(private\): `{local_ips}`
Chat ID: `{update.effective_chat.id}`
""".strip() # remove trailing whitespace
if update.message: if update.message:
await update.message.reply_text(message, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) await update.message.reply_text(message, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2)

View File

@@ -4,17 +4,17 @@ from telegram.error import BadRequest
import logging import logging
from datetime import time, timedelta, timezone, datetime, date from datetime import time, timedelta, timezone, datetime, date
from peewee import fn from peewee import fn
import models
CHAT_ID = os.getenv("CHAT_ID") CHAT_ID = os.getenv("CHAT_ID")
class SetChatPhotoJob(): class SetChatPhotoJob():
def __init__(self, models, bot: ExtBot, job_queue): def __init__(self, bot: ExtBot, job_queue):
self.models = models
self.bot = bot self.bot = bot
self.logger = logging.getLogger(self.__class__.__name__) self.logger = logging.getLogger(self.__class__.__name__)
if os.getenv("DOCKERIZED", "false") != "true": if not models.IS_PRODUCTION:
# when running locally, annoy the programmer every 60 seconds <3 # when running locally, annoy the programmer every 60 seconds <3
job_queue.run_repeating(self.callback_photo, interval=60) job_queue.run_repeating(self.callback_photo, interval=60)
else: else:
@@ -26,9 +26,9 @@ class SetChatPhotoJob():
async def callback_photo(self, context): async def callback_photo(self, context):
# last_seen of memory must be older than 10 days in past or None # last_seen of memory must be older than 10 days in past or None
with self.models.db: with models.db:
possible_photos = self.models.JournalEntry.select().where( possible_photos = models.JournalEntry.select().where(
self.models.JournalEntry.media_path != None models.JournalEntry.media_path != None
).order_by(fn.Random()) ).order_by(fn.Random())
try: try:

View File

@@ -0,0 +1,95 @@
import os
from telegram.ext import ExtBot
from telegram.constants import ParseMode
import logging
from datetime import time, timedelta, timezone, datetime, date
from peewee import fn
import models
from telegram.ext import JobQueue
RANKING_TEMPLATE = """
<b>Journal Leaderboard</b>
This week: 📈{week_leader_name} - {week_leader_count} 📉{week_last_name} - {week_last_count}
This month: 📈{month_leader_name} - {month_leader_count} 📉{month_last_name} - {month_last_count}
This year: 📈{year_leader_name} - {year_leader_count} 📉{year_last_name} - {year_last_count}
🏆 Leader: {leader_name}
"""
def get_author_ranking(since_days):
"""Returns the query for the top authors by counting their journal entries. An additional field for the count is added."""
cutoff_date = date.today() - timedelta(days=since_days)
with models.db:
return models.JournalEntry.select(
models.JournalEntry.author,
fn.Count(models.JournalEntry.id).alias('message_count')
).where(
models.JournalEntry.date >= cutoff_date
).group_by(
models.JournalEntry.author
).order_by(
fn.Count(models.JournalEntry.id).desc()
)
class SendLeaderboard():
def __init__(self, bot: ExtBot, job_queue: JobQueue):
self.bot = bot
self.logger = logging.getLogger(self.__class__.__name__)
if not models.IS_PRODUCTION:
# when running locally, just run once after 10 seconds
job_queue.run_once(self.callback_leaderboard, when=10)
else:
# set the message sending time; include UTC shift +2
sending_time = time(hour=12, minute=0, second=0, tzinfo=timezone(timedelta(hours=2)))
job_queue.run_daily(self.callback_leaderboard, when=sending_time, day=-1)
async def callback_leaderboard(self, context):
"""Send a weakly leaderboard to the chat."""
if date.today().weekday() != 1:
self.logger.info("Today is not Monday, skipping leaderboard.")
return
# get the top contributions of the past week, month and year:
ranking_week = get_author_ranking(7)
ranking_month = get_author_ranking(30)
ranking_year = get_author_ranking(365)
week_leader, week_last = ranking_week.first(n=2)
month_leader, month_last = ranking_month.first(n=2)
year_leader, year_last = ranking_year.first(n=2)
leader = year_leader
message_text = RANKING_TEMPLATE.format(
week_leader_name=week_leader.author,
week_leader_count=week_leader.message_count,
week_last_name=week_last.author,
week_last_count=week_last.message_count,
month_leader_name=month_leader.author,
month_leader_count=month_leader.message_count,
month_last_name=month_last.author,
month_last_count=month_last.message_count,
year_leader_name=year_leader.author,
year_leader_count=year_leader.message_count,
year_last_name=year_last.author,
year_last_count=year_last.message_count,
leader_name=leader.author
)
print(message_text)
chat_id = os.getenv("CHAT_ID")
await self.bot.send_message(
chat_id = chat_id,
text = message_text,
parse_mode=ParseMode.HTML
)

View File

@@ -1,17 +1,19 @@
from datetime import time, timedelta, timezone, datetime, date from datetime import time, timedelta, timezone, datetime, date
from telegram.constants import ParseMode
import os import os
from peewee import fn from peewee import fn
import logging import logging
import models
from telegram.ext import ExtBot
class RandomMemoryJob(): class RandomMemoryJob():
def __init__(self, models, bot, job_queue): def __init__(self, bot: ExtBot, job_queue):
self.models = models
self.bot = bot self.bot = bot
self.logger = logging.getLogger(self.__class__.__name__) self.logger = logging.getLogger(self.__class__.__name__)
if os.getenv("DOCKERIZED", "false") != "true": if not models.IS_PRODUCTION:
# when running locally, annoy the programmer every 60 seconds <3 # when running locally, annoy the programmer every 60 seconds <3
job_queue.run_repeating(self.callback_memory, interval=60) job_queue.run_repeating(self.callback_memory, interval=3600)
self.min_age = 0 # do not filter messages: show them all self.min_age = 0 # do not filter messages: show them all
else: else:
# set the message sending time; include UTC shift +2 # set the message sending time; include UTC shift +2
@@ -23,10 +25,10 @@ class RandomMemoryJob():
async def callback_memory(self, context): async def callback_memory(self, context):
# last_seen of memory must be older than 10 days in past or None # last_seen of memory must be older than 10 days in past or None
with self.models.db: with models.db:
possible_entries = self.models.JournalEntry.select().where( possible_entries = models.JournalEntry.select().where(
(self.models.JournalEntry.last_shown <= datetime.today().date() - timedelta(days=self.min_age)) | \ (models.JournalEntry.last_shown <= datetime.today().date() - timedelta(days=self.min_age)) | \
(self.models.JournalEntry.last_shown == None) (models.JournalEntry.last_shown == None)
).order_by(fn.Random()) ).order_by(fn.Random())
try: try:
@@ -41,21 +43,24 @@ class RandomMemoryJob():
chat_id = os.getenv("CHAT_ID") chat_id = os.getenv("CHAT_ID")
rating_string = f" ({models.RATING_MAPPING[chosen_entry.rating]})" if chosen_entry.rating else ""
message_text = f"On {chosen_entry.date_pretty}{rating_string}, " \
f"{chosen_entry.author} wrote: \n" \
f"{chosen_entry.spoiler_text}"
if chosen_entry.media_path: if chosen_entry.media_path:
await self.bot.send_photo( await self.bot.send_photo(
chat_id = chat_id, chat_id = chat_id,
photo = chosen_entry.media_path, photo = chosen_entry.media_path,
caption = caption = message_text,
f"On {chosen_entry.date_pretty}, " parse_mode=ParseMode.HTML
f"{chosen_entry.author} wrote: \n"
f"{chosen_entry.text}"
) )
else: else:
await self.bot.send_message( await self.bot.send_message(
chat_id = chat_id, chat_id = chat_id,
text = text = message_text,
f"On {chosen_entry.date_pretty}, " parse_mode=ParseMode.HTML
f"{chosen_entry.author} wrote: \n"
f"{chosen_entry.text}"
) )

View File

@@ -3,9 +3,9 @@ from telegram.ext import Application
import logging import logging
import models import models
from commands import journal, status, turtle, memory from commands import journal, status, turtle, memory, advent
from commands.list import list from commands.list import list
from cronjob import chat_photo, random_memory from cronjob import chat_photo, random_memory, leaderboard
logging.basicConfig( logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@@ -23,14 +23,16 @@ def main() -> None:
models.set_db(db_path) models.set_db(db_path)
application = Application.builder().token(token).build() application = Application.builder().token(token).build()
application.add_handler(journal.JournalHandler("journal", models).handler) application.add_handler(journal.JournalHandler("journal").handler)
application.add_handler(list.ListHandler("list", models).handler) application.add_handler(list.ListHandler("list").handler)
application.add_handler(status.StatusHandler("status", models).handler) application.add_handler(status.StatusHandler("status").handler)
application.add_handler(turtle.TurtleHandler().handler) application.add_handler(turtle.TurtleHandler().handler)
application.add_handler(memory.MemoryHandler("memory", models).handler) application.add_handler(memory.MemoryHandler("memory").handler)
application.add_handler(advent.AdventsHandler("advent").handler)
random_memory.RandomMemoryJob(models, application.bot, application.job_queue) random_memory.RandomMemoryJob(application.bot, application.job_queue)
chat_photo.SetChatPhotoJob(models, application.bot, application.job_queue) chat_photo.SetChatPhotoJob(application.bot, application.job_queue)
leaderboard.SendLeaderboard(application.bot, application.job_queue)
# Run the bot until the user presses Ctrl-C # Run the bot until the user presses Ctrl-C
application.run_polling() application.run_polling()

View File

@@ -1,7 +1,8 @@
from peewee import * from peewee import *
from pathlib import Path from pathlib import Path
import re
import os import os
import datetime import socket
ID_MAPPINGS = { ID_MAPPINGS = {
"Lia": 5603036217, "Lia": 5603036217,
@@ -9,9 +10,22 @@ ID_MAPPINGS = {
} }
ID_MAPPINGS_REV = dict((v, k) for k, v in ID_MAPPINGS.items()) ID_MAPPINGS_REV = dict((v, k) for k, v in ID_MAPPINGS.items())
RATING_MAPPING = {
1: "🙁",
2: "😐",
3: "🙂",
4: "😃",
5: "🥰"
}
MEDIA_DIR = Path(os.getenv("MEDIA_DIR")) MEDIA_DIR = Path(os.getenv("MEDIA_DIR"))
MEDIA_DIR.mkdir(parents=True, exist_ok=True) MEDIA_DIR.mkdir(parents=True, exist_ok=True)
# check if we are running on a cluster
IS_PRODUCTION = os.getenv('KUBERNETES_SERVICE_HOST') is not None
db = DatabaseProxy() db = DatabaseProxy()
class BaseModel(Model): class BaseModel(Model):
@@ -27,7 +41,7 @@ class JournalEntry(BaseModel):
text = TextField(null=True) text = TextField(null=True)
media_path = TextField(null=True) media_path = TextField(null=True)
last_shown = DateField(null=True) last_shown = DateField(null=True)
rating = IntegerField(null=True) # mapped by RATING_MAPPING
@property @property
def media(self): def media(self):
@@ -60,6 +74,25 @@ class JournalEntry(BaseModel):
except ValueError: #fck windows except ValueError: #fck windows
return self.date.strftime('%a, %d. %b %Y') return self.date.strftime('%a, %d. %b %Y')
@property
def spoiler_text(self) -> str:
"""Returns the text with all the frisky details hidden away"""
new_text = self.text.replace("<", "&lt;").replace(">", "&gt;").replace("&", "&amp;")
pattern = re.compile(
r"("
r"(((?<=(\.|\!|\?)\s)[A-Z])|(^[A-Z]))" # beginning of a sentence
r"([^\.\!\?])+" # any character being part of a sentence
r"((\:\))|😇|😈|[Ss]ex)" # the smiley
r"([^\.\!\?])*" # continuation of sentence
r"(\.|\!|\?|\,|$)" # end of the sentence
r")"
)
matches = pattern.findall(new_text)
for match in matches:
group_to_replace = match[0]
new_text = new_text.replace(group_to_replace, f"<tg-spoiler>{group_to_replace}</tg-spoiler>")
return new_text
def set_db(db_path): def set_db(db_path):
db.initialize(SqliteDatabase(db_path)) db.initialize(SqliteDatabase(db_path))

29
default.nix Normal file
View File

@@ -0,0 +1,29 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
name = "journal-bot-shell";
buildInputs = with pkgs; [
# python
python313
# setuptools as downloaded by uv seems to be broken on nixos
python313Packages.setuptools
uv
];
# fix library dependencies:
env.LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
# required by numpy and the likes
pkgs.stdenv.cc.cc.lib
pkgs.libz
];
# tell UV where to put the virtualenv:
# env.UV_PROJECT_ENVIRONMENT = ".cache/uv-venvs/thesis";
# install the python packages through uv:
shellHook = "
uv sync
source .venv/bin/activate
";
}

View File

@@ -17,7 +17,7 @@ spec:
spec: spec:
containers: containers:
- name: journal - name: journal
image: mollre/journal-bot:1.0.19 image: journal
envFrom: envFrom:
- secretRef: - secretRef:
name: journal-secret-env name: journal-secret-env
@@ -33,29 +33,3 @@ spec:
- name: journal-nfs - name: journal-nfs
persistentVolumeClaim: persistentVolumeClaim:
claimName: journal-data-nfs claimName: journal-data-nfs
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: "journal-data-nfs"
spec:
storageClassName: fast
capacity:
storage: "5Gi"
accessModes:
- ReadWriteOnce
nfs:
path: /export/kluster/journal-bot
server: 192.168.1.157
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: "journal-data-nfs"
spec:
storageClassName: "fast"
accessModes:
- ReadWriteOnce
resources:
requests:
storage: "5Gi"

View File

@@ -1,9 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization kind: Kustomization
resources: resources:
- ./namespace.yaml - ./namespace.yaml
- ./deployment.yaml - ./deployment.yaml
- ./sealedsecret.yaml - ./sealedsecret.yaml
- ./pvc.yaml
namespace: journal
images: images:
- name: mollre/journal-bot - name: journal
newTag: 1.0.53 newName: git.kluster.moll.re/remoll/journal-bot
newTag: 29d951427d6f3377e43767916cefb07e03e9eab8

View File

@@ -1,6 +1,4 @@
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
name: journal name: placeholder
labels:
name: journal

27
deployment/pvc.yaml Normal file
View File

@@ -0,0 +1,27 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: "journal-data-nfs"
spec:
storageClassName: ""
capacity:
storage: "5Gi"
accessModes:
- ReadWriteOnce
nfs:
path: /export/kluster/journal-bot
server: 192.168.1.157
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: "journal-data-nfs"
spec:
storageClassName: ""
accessModes:
- ReadWriteOnce
resources:
requests:
storage: "5Gi"
volumeName: journal-data-nfs

3
dev.env Normal file
View File

@@ -0,0 +1,3 @@
MEDIA_DIR="./.bot/media"
PERSISTENCE_DIR="./.bot/persistence"
DB_PATH="./.bot/db.sqlite"

11
pyproject.toml Normal file
View File

@@ -0,0 +1,11 @@
[project]
name = "journal-bot"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"anyio>=4.9.0",
"peewee>=3.18.2",
"python-telegram-bot[job-queue]>=22.3",
]

31
renovate.json5 Normal file
View File

@@ -0,0 +1,31 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"dependencyDashboard": true,
"regexManagers": [
{
"fileMatch": ["kustomization\\.yaml"],
"matchStrings": [
"- name:\\s*(?<depName>\\S+)\\s*\\n\\s*newName:\\s*(?<lookupName>\\S+)\\s*\\n\\s*newTag:\\s*(?<currentValue>[a-f0-9]{7,40})"
],
"datasourceTemplate": "docker",
"versioningTemplate": "loose",
"extractVersionTemplate": "{{ currentValue }}",
"lookupNameTemplate": "{{ lookupName }}"
},
],
"packageRules": [
// Fully automatically update the container version referenced in the deployment
{
"matchPackageNames": ["@kubernetes-sigs/kustomize"],
"automerge": true,
"automergeType": "branch",
"ignoreTests": true,
"commitMessagePrefix": "[skip ci]",
"registryUrls": [
"https://git.kluster.moll.re"
],
// Since the images use the commit sha as their tag, simply match any tag
"versioning": "regex:(.*)"
}
],
}

153
uv.lock generated Normal file
View File

@@ -0,0 +1,153 @@
version = 1
revision = 2
requires-python = ">=3.13"
[[package]]
name = "anyio"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
]
[[package]]
name = "apscheduler"
version = "3.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzlocal" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" },
]
[[package]]
name = "certifi"
version = "2025.7.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "journal-bot"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "anyio" },
{ name = "peewee" },
{ name = "python-telegram-bot", extra = ["job-queue"] },
]
[package.metadata]
requires-dist = [
{ name = "anyio", specifier = ">=4.9.0" },
{ name = "peewee", specifier = ">=3.18.2" },
{ name = "python-telegram-bot", extras = ["job-queue"], specifier = ">=22.3" },
]
[[package]]
name = "peewee"
version = "3.18.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/89/76f6f1b744c8608e0d416b588b9d63c2a500ff800065ae610f7c80f532d6/peewee-3.18.2.tar.gz", hash = "sha256:77a54263eb61aff2ea72f63d2eeb91b140c25c1884148e28e4c0f7c4f64996a0", size = 949220, upload-time = "2025-07-08T12:52:03.941Z" }
[[package]]
name = "python-telegram-bot"
version = "22.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/fc/0196e0d7ad247011a560788db204e0a28d76ab75b3d7c7131878f8fb5a06/python_telegram_bot-22.3.tar.gz", hash = "sha256:513d5ab9db96dcf25272dad0a726555e80edf60d09246a7d0d425b77115f5440", size = 1464513, upload-time = "2025-07-20T20:03:09.805Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/54/0955bd46a1e046169500e129c7883664b6675d580074d68823485e4d5de1/python_telegram_bot-22.3-py3-none-any.whl", hash = "sha256:88fab2d1652dbfd5379552e8b904d86173c524fdb9270d3a8685f599ffe0299f", size = 717115, upload-time = "2025-07-20T20:03:07.261Z" },
]
[package.optional-dependencies]
job-queue = [
{ name = "apscheduler" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "tzlocal"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]