From 73690afd8f477bcc27f5c246b58c7accd3a1b3b9 Mon Sep 17 00:00:00 2001 From: Egor Deev Date: Mon, 5 Oct 2026 19:28:43 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=20=D0=BF=D0=BE=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=87=D0=B5=D0=BC=D1=83=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8E,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B,=20C?= =?UTF-8?q?I,=20Docker=20=D0=B8=20=D0=BB=D0=B8=D1=86=D0=B5=D0=BD=D0=B7?= =?UTF-8?q?=D0=B8=D1=8F=20MIT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тесты разбора календаря (события на сегодня, повторения, UNTIL, проверка ссылки, текст уведомления). Образ по тегу v* публикуется в GHCR и dcr.deev.su. --- .env.example | 2 + .github/workflows/ci.yml | 23 ++++++++++ .github/workflows/docker.yml | 42 ++++++++++++++++++ .gitignore | 6 +++ Dockerfile | 20 +++++++++ LICENSE | 21 +++++++++ compose.yaml | 15 +++++++ requirements-dev.txt | 3 ++ requirements.txt | 23 +++------- tests/conftest.py | 4 ++ tests/test_script.py | 84 ++++++++++++++++++++++++++++++++++++ 11 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 compose.yaml create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_script.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7fcca50 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Токен бота от @BotFather +BOT_TOKEN=123456:your-token diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f4fb84e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -r requirements-dev.txt + - run: ruff check --select E9,F code tests + - run: pytest -q + - name: Бот импортируется + working-directory: code + env: + BOT_TOKEN: "123456:TEST" + run: python -c "import bot" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..72d79da --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,42 @@ +name: Docker + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/login-action@v3 + with: + registry: dcr.deev.su + username: ${{ secrets.ZOT_USERNAME }} + password: ${{ secrets.ZOT_PASSWORD }} + - id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/edeev/y.calendarkin + dcr.deev.su/edeev/y.calendarkin + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc4f60b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ +db/*.db +data/icals/*.ics diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..57fcd7c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY code/ code/ +COPY data/photo_edit_alarm.jpg data/ +RUN useradd --create-home --uid 1000 app \ + && mkdir -p data/icals db \ + && chown -R app:app /app +USER app +VOLUME ["/app/data/icals", "/app/db"] + +# пути к данным в коде — относительно папки code/ (../data, ../db) +WORKDIR /app/code +CMD ["python", "bot.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1051c59 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Egor Deev + +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/compose.yaml b/compose.yaml new file mode 100644 index 0000000..2bf2177 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,15 @@ +services: + bot: + build: . + image: ghcr.io/edeev/y.calendarkin:latest + env_file: .env + environment: + TZ: Europe/Moscow + volumes: + - icals:/app/data/icals + - db:/app/db + restart: unless-stopped + +volumes: + icals: + db: diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b065da0 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.4.2 +ruff==0.14.0 diff --git a/requirements.txt b/requirements.txt index fc76e05..a7e0587 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,5 @@ -# Core bot framework -aiogram>=3.0.0 - -# Calendar processing -icalendar>=5.0.0 -python-dateutil>=2.8.0 - -# Timezone support -pytz>=2023.3 - -# HTTP client for downloading calendar files -wget>=3.2 - -# Database (built-in with Python) -# sqlite3 - included in Python standard library - -# Additional dependencies for date/time handling -typing-extensions>=4.0.0 +aiogram==3.23.0 +aiohttp==3.13.2 +icalendar==6.3.2 +python-dateutil==2.9.0.post0 +pytz==2025.2 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2f77833 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code")) diff --git a/tests/test_script.py b/tests/test_script.py new file mode 100644 index 0000000..13b4df7 --- /dev/null +++ b/tests/test_script.py @@ -0,0 +1,84 @@ +from datetime import datetime, timedelta + +import pytz + +import script + +TZ = pytz.timezone("Europe/Moscow") + + +def make_ics(events): + body = "\r\n".join(events) + return ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" + body + "\r\nEND:VCALENDAR\r\n").encode() + + +def vevent(uid, start, end, summary, extra=""): + return (f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTART;TZID=Europe/Moscow:{start:%Y%m%dT%H%M%S}\r\n" + f"DTEND;TZID=Europe/Moscow:{end:%Y%m%dT%H%M%S}\r\nSUMMARY:{summary}\r\n{extra}END:VEVENT") + + +def test_parse_ical_url_accepts_only_yandex_export(): + ok = "https://calendar.yandex.ru/export/ics.xml?private_token=abc&tz_id=Asia/Krasnoyarsk" + assert script.parse_ical_url(ok) == "Asia/Krasnoyarsk" + assert script.parse_ical_url("https://calendar.yandex.ru/export/ics.xml?private_token=abc") == "Europe/Moscow" + assert script.parse_ical_url("https://calendar.yandex.ru/x?tz_id=Not/AZone") == "Europe/Moscow" + for bad in ("http://calendar.yandex.ru/export/ics.xml", "https://127.0.0.1/ics", "file:///etc/passwd", + "https://calendar.yandex.ru.evil.com/ics", "https://example.com/?tz_id=Europe/Moscow"): + assert script.parse_ical_url(bad) is None, bad + + +def test_text_ical_today_events_and_recurrence(tmp_path, monkeypatch): + monkeypatch.setattr(script, "ICAL_DIR", str(tmp_path)) + now = datetime.now(TZ).replace(tzinfo=None, second=0, microsecond=0) + today = now.replace(hour=10, minute=0) + + events = [ + vevent("once", today, today + timedelta(hours=1), "Разовое <важное> & срочное", + "DESCRIPTION:Описание\r\nORGANIZER:mailto:boss@example.com\r\n"), + vevent("weekly", today.replace(hour=12) - timedelta(weeks=3), + today.replace(hour=13) - timedelta(weeks=3), "Еженедельное", "RRULE:FREQ=WEEKLY\r\n"), + vevent("tomorrow", today + timedelta(days=1), today + timedelta(days=1, hours=1), "Завтра"), + ] + (tmp_path / "1.ics").write_bytes(make_ics(events)) + + found = {e[3]["name"]: e for e in script.text_ical(1, TZ)} + assert set(found) == {"Разовое <важное> & срочное", "Еженедельное"} + assert found["Еженедельное"][1].hour == 12 + + text = script.message_form(1, found["Разовое <важное> & срочное"][3]) + assert "<важное> & срочное" in text + assert "boss@example.com" in text and "mailto:" not in text + assert "10:00 — 11:00" in text and str(now.year) not in text + + +def test_message_form_hides_empty_fields(): + event = {"name": "Встреча", "desc": "", "org": "", "all_day": False, + "datetime": [datetime(2026, 1, 1, 9, 0), datetime(2026, 1, 1, 9, 30)]} + text = script.message_form(0, event) + assert "Описание" not in text and "Организатор" not in text + assert "09:00 — 09:30" in text + + +def test_missing_calendar_file_gives_no_events(tmp_path, monkeypatch): + monkeypatch.setattr(script, "ICAL_DIR", str(tmp_path)) + assert script.text_ical(42, TZ) == [] + + +def test_until_not_in_utc(tmp_path, monkeypatch): + # повторяющиеся события с UNTIL местным временем или датой раньше роняли разбор календаря + monkeypatch.setattr(script, "ICAL_DIR", str(tmp_path)) + now = datetime.now(TZ).replace(tzinfo=None, second=0, microsecond=0) + first = now.replace(hour=9, minute=0) - timedelta(weeks=5) + events = [ + vevent("ended-local", first, first + timedelta(hours=1), "Закончилось", + f"RRULE:FREQ=WEEKLY;UNTIL={first + timedelta(weeks=2):%Y%m%dT%H%M%S}\r\n"), + vevent("ended-date", first, first + timedelta(hours=1), "Закончилось датой", + f"RRULE:FREQ=WEEKLY;UNTIL={first + timedelta(weeks=2):%Y%m%d}\r\n"), + vevent("running", first, first + timedelta(hours=1), "Идёт", + f"RRULE:FREQ=WEEKLY;UNTIL={now + timedelta(weeks=4):%Y%m%dT%H%M%S}\r\n"), + ] + (tmp_path / "2.ics").write_bytes(make_ics(events)) + + names = {e[3]["name"] for e in script.text_ical(2, TZ)} + assert names == {"Идёт"} + assert script.until_utc("20260101", TZ) == "20260101T205959Z"