Skip to content

Commit

Permalink
feat: minerva should refresh calendar (#33)
Browse files Browse the repository at this point in the history
## How does this PR impact the user?

Minerva will have up-to-date calendar info.

Closes #19.

## Description

Ditto.

## Limitations

N/A

## Checklist

- [x] my PR is focused and contains one wholistic change
- [ ] I have added screenshots or screen recordings to show the changes
  • Loading branch information
yurijmikhalevich authored Jan 4, 2025
1 parent 3534663 commit c0455dc
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 21 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ OPENAI_MODEL=gpt-4o-2024-08-06
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
CALENDAR_ICS_URL=
CALENDAR_REFETCH_INTERVAL_MIN=15
1 change: 1 addition & 0 deletions minerva/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
TELEGRAM_CHAT_ID = int(TELEGRAM_CHAT_ID_STR) if TELEGRAM_CHAT_ID_STR is not None else None

CALENDAR_ICS_URL = os.getenv("CALENDAR_ICS_URL")
CALENDAR_REFRESH_INTERVAL_MIN = int(os.getenv("CALENDAR_REFRESH_INTERVAL_MIN", 15))

AI_NAME = "Minerva"
6 changes: 3 additions & 3 deletions minerva/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,9 @@ def __init__(

def format(self, message_history: MessageHistory) -> str:
return (
f"{self.prompt}\n\n"
f"Current datetime in UTC is {datetime.now().astimezone(timezone.utc)}\n\n"
f"CONVERSATION HISTORY:\n\n{message_history}"
f"{self.prompt}\n\n"
f"Current datetime in UTC is {datetime.now().astimezone(timezone.utc)}\n\n"
f"CONVERSATION HISTORY:\n\n{message_history}"
)

def __str__(self):
Expand Down
15 changes: 15 additions & 0 deletions minerva/tools/calendar.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import asyncio
from datetime import datetime, timedelta, timezone
from typing import NamedTuple, Optional
import icalendar
import recurring_ical_events
import httpx
from minerva.config import CALENDAR_REFRESH_INTERVAL_MIN


class Event(NamedTuple):
Expand Down Expand Up @@ -60,6 +62,19 @@ def __init__(self, calendar_url: str):
calendar_request.raise_for_status()
ics_content = calendar_request.text
self.cal = parse_ics(ics_content)
self._refetch_task = asyncio.create_task(self._refetch_calendar_loop(calendar_url))

async def _refetch_calendar_loop(self, calendar_url):
while True:
try:
async with httpx.AsyncClient() as client:
calendar_request = await client.get(calendar_url)
calendar_request.raise_for_status()
ics_content = calendar_request.text
self.cal = parse_ics(ics_content)
except httpx.RequestError as e:
print(f"An error occurred while fetching the calendar: {e}")
await asyncio.sleep(CALENDAR_REFRESH_INTERVAL_MIN * 60)

async def query(self, next_days: int) -> str:
"""Query the event calendar for the next `next_days` days.
Expand Down
50 changes: 50 additions & 0 deletions tests/fixtures/test-calendar-updated.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:Test calendar
X-WR-TIMEZONE:Asia/Dubai
BEGIN:VTIMEZONE
TZID:Asia/Dubai
X-LIC-LOCATION:Asia/Dubai
BEGIN:STANDARD
TZOFFSETFROM:+0400
TZOFFSETTO:+0400
TZNAME:GMT+4
DTSTART:19700101T000000
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART:20240904T084500Z
DTEND:20240905T020000Z
DTSTAMP:20240902T074210Z
UID:[email protected]
X-GOOGLE-CONFERENCE:https://meet.google.com/broken-link-1
CREATED:20240902T074020Z
DESCRIPTION:‏الانضمام من خلال Google Meet: https://meet.google.com/broken-link
-1\n\n‏تعرَّف على مزيد من المعلومات حول Meet على الرابط: https://support.
google.com/a/users/answer/9282720.
LAST-MODIFIED:20240902T074020Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Test multi-day
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
DTSTART:20240902T100000Z
DTEND:20240902T110000Z
DTSTAMP:20240902T074210Z
UID:[email protected]
X-GOOGLE-CONFERENCE:https://meet.google.com/broken-link-2
CREATED:20240902T073940Z
DESCRIPTION:Test description.\n\nMultiline.\n\n‏الانضمام من خلال Google Mee
t: https://meet.google.com/broken-link-2\n\n‏تعرَّف على مزيد من المعلومات حو
ل Meet على الرابط: https://support.google.com/a/users/answer/9282720.
LAST-MODIFIED:20240902T074027Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Test event 1
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
96 changes: 78 additions & 18 deletions tests/tools/calendar_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from asyncio import sleep
import pytest
from datetime import datetime, timezone
from os import path
Expand All @@ -9,24 +10,55 @@
from http.server import HTTPServer, SimpleHTTPRequestHandler
from threading import Thread

DEFAULT_ICS_PATH = path.join(path.dirname(__file__), "..", "fixtures", "test-calendar.ics")

def with_http_file_server(fn_async):
async def wrapper(*args, **kwargs):
# using port 0 to get a random free port
server_address = ("", 0)
httpd = HTTPServer(server_address, SimpleHTTPRequestHandler)
server_thread = Thread(target=httpd.serve_forever)
try:
server_thread.start()
await fn_async(*args, **kwargs, httpd=httpd)
finally:
httpd.shutdown()
server_thread.join()
return wrapper

def getMockCalendarRequestHandler(ics_paths: list[str]):
# because http server recreates the request handler every time, we use this
# request handler fabric to keep track of the variables which we want to share
# between multiple requests
ics_paths = ics_paths or [DEFAULT_ICS_PATH]
requests_served_count = 0

class MockCalendarRequestHandler(SimpleHTTPRequestHandler):
def do_GET(self):
nonlocal requests_served_count

ics_path = ics_paths[requests_served_count if requests_served_count < len(ics_paths) else -1]

with open(ics_path, 'rb') as f:
ics_content = f.read()

self.send_response(200)
self.send_header("Content-type", "text/calendar")
self.end_headers()
self.wfile.write(ics_content)

requests_served_count += 1

return MockCalendarRequestHandler


def with_http_file_server(ics_paths: list[str] = None):
def decorator(fn_async):
async def wrapper(*args, **kwargs):
# using port 0 to get a random free port
server_address = ("", 0)
MockCalendarRequestHandler = getMockCalendarRequestHandler(ics_paths)
httpd = HTTPServer(server_address, MockCalendarRequestHandler)
server_thread = Thread(target=httpd.serve_forever)
try:
server_thread.start()
await fn_async(*args, **kwargs, httpd=httpd)
finally:
httpd.shutdown()
server_thread.join()
return wrapper
return decorator


def test_query_ics():
ics_path = path.join(path.dirname(__file__), "..", "fixtures", "test-calendar.ics")
ics_path = DEFAULT_ICS_PATH
with open(ics_path) as f:
ics = f.read()
cal = parse_ics(ics)
Expand Down Expand Up @@ -67,7 +99,7 @@ def test_query_ics():

@freeze_time("2024-09-15")
@pytest.mark.asyncio
@with_http_file_server
@with_http_file_server()
async def test_calendar_tool(httpd: HTTPServer):
calendar_tool = CalendarTool(
f"http://localhost:{httpd.server_port}/tests/fixtures/test-calendar.ics")
Expand All @@ -80,7 +112,7 @@ async def test_calendar_tool(httpd: HTTPServer):

@freeze_time("2024-09-15")
@pytest.mark.asyncio
@with_http_file_server
@with_http_file_server()
async def test_calendar_tool_no_events(httpd: HTTPServer):
calendar_tool = CalendarTool(
f"http://localhost:{httpd.server_port}/tests/fixtures/test-calendar.ics")
Expand All @@ -91,7 +123,7 @@ async def test_calendar_tool_no_events(httpd: HTTPServer):

@freeze_time("2024-09-15")
@pytest.mark.asyncio
@with_http_file_server
@with_http_file_server()
async def test_calendar_tool_crashes_if_zero_days(httpd: HTTPServer):
calendar_tool = CalendarTool(
f"http://localhost:{httpd.server_port}/tests/fixtures/test-calendar.ics")
Expand All @@ -105,7 +137,7 @@ async def test_calendar_tool_crashes_if_zero_days(httpd: HTTPServer):

@freeze_time("2024-09-15")
@pytest.mark.asyncio
@with_http_file_server
@with_http_file_server()
async def test_calendar_tool_crashes_if_too_many_days(httpd: HTTPServer):
calendar_tool = CalendarTool(
f"http://localhost:{httpd.server_port}/tests/fixtures/test-calendar.ics")
Expand All @@ -115,3 +147,31 @@ async def test_calendar_tool_crashes_if_too_many_days(httpd: HTTPServer):
assert False
except ValueError as e:
assert str(e) == "next_days must be at most 366"


@freeze_time("2024-09-15", as_arg=True, tick=True)
@pytest.mark.asyncio
@with_http_file_server([
DEFAULT_ICS_PATH,
DEFAULT_ICS_PATH,
path.join(path.dirname(__file__), "..", "fixtures", "test-calendar-updated.ics"),
])
async def test_calendar_tool_refetches_calendar(frozen_time, httpd: HTTPServer):
calendar_tool = CalendarTool(
f"http://localhost:{httpd.server_port}/tests/fixtures/test-calendar.ics")
# let the async refresh loop initialize
await sleep(0.1)

events = await calendar_tool.query(14)

assert events == """Event: Test repeated (2024-09-19 07:00:00+00:00 - 2024-09-19 08:00:00+00:00)
Description: And description
Video call: https://meet.google.com/broken-link-3"""

frozen_time.tick(60 * 20)
# let the async refresh loop refetch the calendar
await sleep(0.1)

events = await calendar_tool.query(14)

assert events == """No events found"""

0 comments on commit c0455dc

Please sign in to comment.