From b449c3b74c8234248690ae7ed0f5c88f3d7658db Mon Sep 17 00:00:00 2001 From: WSHAPER <42714629+WSHAPER@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:39:52 +0200 Subject: [PATCH 1/4] fix(tools): schedule calendar events in the user's timezone, not the server's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Time-related tools treated the timezone parameter as optional and silently dropped it when the model omitted it, so "tomorrow at noon" resolved against server-local or UTC time. The system prompt tried to compensate by asking the model to discover the user's timezone through a find_details_of_current_user tool call first — that costs an extra agent round trip and still leaves the tools unvalidated, so any model that skipped the discovery step produced events at the wrong wall-clock time. Making the timezone a required, pytz-validated parameter on every tool that interprets a date moves the failure to the tool boundary, where a missing or invalid IANA name returns an actionable error to the model instead of silently scheduling six hours off. Injecting the requesting user's actual timezone (OCS /cloud/user) into the system prompt gives the model the correct value up front, keeping the tool-based lookup only as a fallback for profiles without a timezone set. Naive datetime.utcnow() in the free-slot search was replaced with timezone-aware UTC because mixing naive and aware comparisons produced wrong free/busy windows at DST boundaries. Verified end to end against a CEST server with the user's timezone set to America/New_York: the system prompt logs the current time in NY and "tomorrow at noon" is stored as DTSTART:20261007T160000Z (12:00 EDT). Assisted-by: opencode:zai/glm-5.3 Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- ex_app/lib/agent.py | 31 +++++++++++++--- ex_app/lib/all_tools/assignments.py | 36 ++++++++++++++----- ex_app/lib/all_tools/calendar.py | 40 +++++++++++++-------- ex_app/lib/all_tools/lib/freebusy_finder.py | 4 +-- 4 files changed, 82 insertions(+), 29 deletions(-) diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index 779b108b..22888564 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -5,7 +5,7 @@ import random import string from collections.abc import Awaitable, Callable -from datetime import date +from datetime import date, datetime from time import monotonic from typing import Any, cast @@ -14,6 +14,8 @@ from nc_py_api import AsyncNextcloudApp from nc_py_api.ex_app import persistent_storage +import pytz + from ex_app.lib.all_tools.nextcloud_links import get_absolute_base_url from ex_app.lib.all_tools.skills import list_skills_metadata from ex_app.lib.graph import AgentState, get_graph @@ -112,6 +114,15 @@ async def react( model.bind_nextcloud(nc) model.multimodal = multimodal + tz_name = user_tz = None + try: + user = (await nc.ocs("GET", "/ocs/v2.php/cloud/user")) or {} + tz_name = user.get("timezone") or None + if tz_name: + user_tz = pytz.timezone(tz_name) + except Exception: + tz_name = user_tz = None + safe_tools, dangerous_tools = await get_tools(nc) tools = dangerous_tools + safe_tools @@ -130,12 +141,18 @@ async def call_model( state: AgentState, config: RunnableConfig, ): - current_date = date.today().strftime("%Y-%m-%d") + if user_tz is not None: + now = datetime.now(user_tz) + current_date = now.strftime("%Y-%m-%d") + current_time = f" The current time is {now.strftime('%H:%M')} in the user's timezone {tz_name}." + else: + current_date = date.today().strftime("%Y-%m-%d") + current_time = "" system_prompt_text = """ You are a helpful AI assistant with access to tools, please respond to the user's query to the best of your ability, using the provided tools if necessary. If no tool is needed to provide a correct answer, do not use one. If you used a tool, you still need to convey its output to the user. Use the same language for your answers as the user used in their message. -Today is {CURRENT_DATE}. +Today is {CURRENT_DATE}.{CURRENT_TIME} Intuit the language the user is using (there is no tool for this, you will need to guess). Reply in the language intuited. Do not output the language you intuited. Only use tools if you cannot answer the user without them. If you get a link as a tool output, always add the link to your response. @@ -152,7 +169,11 @@ async def call_model( system_prompt_text += "Use the find_person_in_contacts tool to find a person's email address and location.\n" if tool_enabled("find_person_in_users"): system_prompt_text += "Use the find_person_in_users tool to find a person's userId and user details.\n" - if tool_enabled("find_details_of_current_user"): + if tz_name is not None: + system_prompt_text += ( + f"The user's timezone is {tz_name}. Use it for the timezone parameter of calendar/task tools.\n" + ) + elif tool_enabled("find_details_of_current_user"): system_prompt_text += "Use the find_details_of_current_user tool to find the current user's location and timezone.\n" if tool_enabled("list_mails"): system_prompt_text += "Always check for the mail account id before requesting a folder list.\n" @@ -189,7 +210,7 @@ async def call_model( # this is similar to customizing the create_react_agent with state_modifier, but is a lot more flexible system_prompt = SystemMessage( - system_prompt_text.replace("{CURRENT_DATE}", current_date) + system_prompt_text.replace("{CURRENT_DATE}", current_date).replace("{CURRENT_TIME}", current_time) ) response = await bound_model.ainvoke([system_prompt] + state["messages"], config) diff --git a/ex_app/lib/all_tools/assignments.py b/ex_app/lib/all_tools/assignments.py index c7e36ffd..13c54e75 100644 --- a/ex_app/lib/all_tools/assignments.py +++ b/ex_app/lib/all_tools/assignments.py @@ -9,11 +9,27 @@ from ex_app.lib.all_tools.lib.decorator import dangerous_tool, safe_tool +def _require_timezone(timezone): + if timezone is None or not timezone.strip(): + raise ValueError("timezone is REQUIRED and must be a valid IANA timezone name, e.g. 'America/New_York'") + try: + return pytz.timezone(timezone).zone + except pytz.UnknownTimeZoneError: + raise ValueError(f"Invalid timezone '{timezone}'. Must be a valid IANA name, e.g. 'America/New_York'") from None + + +def _starts_at_timestamp(starts_at, timezone): + parsed = datetime.datetime.fromisoformat(starts_at.replace("Z", "+00:00")) + if parsed.tzinfo is None or parsed.utcoffset() is None: + parsed = pytz.timezone(timezone).localize(parsed) + return int(parsed.timestamp()) + + async def get_tools(nc: AsyncNextcloudApp): @tool @dangerous_tool - async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, timezone: str|None = None, starts_at: None|str = None): + async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, timezone: str, starts_at: None|str = None): """ Create a Scheduled Task for the assistant that will be carried out autonomously. The user will still have to approve sensitive actions. @@ -24,16 +40,18 @@ async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, t :param prompt: The instructions for the agent carrying out the Scheduled Task (Do not tell it to create a scheduled task here, as this is the instruction that runs as the scheduled task already. So instead of "Create a scheduled task to do X", just pass "Do X" here) :param recurrence_rule: An RRule compliant with RFC 5545 that defines the recurrence rule for the Scheduled Task. For example "FREQ=DAILY;INTERVAL=1" to run the Scheduled Task every day, an empty string as the recurrence_rule means the task does not repeat. :param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately. Make sure to use the user's timezone for this, obtainable with find_details_of_current_user - :param timezone: Timezone (e.g., 'America/New_York') defaults to the user's current time zone + :param timezone: Timezone (e.g., 'America/New_York'). REQUIRED. Must be a valid IANA timezone name; used to + interpret starts_at. :return: """ + tz = _require_timezone(timezone) await nc.ocs('POST', f'/ocs/v2.php/apps/assistant/assignments', json={ 'title': title, 'prompt': prompt, 'recurrence': recurrence_rule, - 'startsAt': int(datetime.datetime.fromisoformat(starts_at.replace('Z', '+00:00')).timestamp()) if starts_at is not None else datetime.datetime.now(datetime.UTC).timestamp(), - 'timezone': pytz.timezone(timezone).zone if timezone is not None else None + 'startsAt': _starts_at_timestamp(starts_at, tz) if starts_at is not None else datetime.datetime.now(datetime.UTC).timestamp(), + 'timezone': tz }) return True @@ -50,22 +68,24 @@ async def list_scheduled_tasks(): @tool @dangerous_tool - async def update_scheduled_task(id: int, prompt: None|str = None, recurrence_rule: None|str = None, timezone: str|None = None, starts_at: None|str = None): + async def update_scheduled_task(id: int, timezone: str, prompt: None|str = None, recurrence_rule: None|str = None, starts_at: None|str = None): """ Update a assistant Scheduled Task :param id: The ID of the Scheduled Task to update, you can obtain this from the list_scheduled_tasks tool :param prompt: The instructions for the AI carrying out the Scheduled Task. Pass `None` to leave this unchanged. (Do not tell the other AI to create a scheduled task here, as this is the instruction that runs as the scheduled task already. So instead of "Create a scheduled task to do X", just pass "Do X" here) :param recurrence_rule: An RRule compliant with RFC 5545 that defines the recurrence rule for the Scheduled Task. For example "FREQ=DAILY;INTERVAL=1" to run the Scheduled Task every day. An empty string means, it does not repeat. Pass `None` to leave this unchanged. - :param timezone A timezone for the scheduled task, set to None to leave this as is. + :param timezone: Timezone (e.g., 'America/New_York'). REQUIRED. Must be a valid IANA timezone name; used to + interpret starts_at. :param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately. Pass `None` to leave this unchanged. :return: """ + tz = _require_timezone(timezone) return await nc.ocs('PATCH', f'/ocs/v2.php/apps/assistant/assignments/{id}', json={ 'prompt': prompt, 'recurrence': recurrence_rule, - 'startsAt': int(datetime.datetime.fromisoformat(starts_at.replace('Z', '+00:00')).timestamp()) if starts_at is not None else None, - 'timezone': pytz.timezone(timezone).zone if timezone is not None else None + 'startsAt': _starts_at_timestamp(starts_at, tz) if starts_at is not None else None, + 'timezone': tz }) @tool diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index 4170131f..bcdfe5d0 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -17,6 +17,15 @@ from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, round_to_nearest_half_hour +def _require_timezone(timezone): + if timezone is None or not timezone.strip(): + raise ValueError("timezone is REQUIRED and must be a valid IANA timezone name, e.g. 'America/New_York'") + try: + pytz.timezone(timezone) + except pytz.UnknownTimeZoneError: + raise ValueError(f"Invalid timezone '{timezone}'. Must be a valid IANA name, e.g. 'America/New_York'") from None + + async def get_tools(nc: AsyncNextcloudApp): ncSync = NextcloudApp() ncSync.set_user(await nc.user) @@ -35,7 +44,7 @@ async def list_calendars(): """ return await asyncio.to_thread(list_calendars_sync) - def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str]): + def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: str): # Parse date and times start_date = datetime.strptime(start_date, "%Y-%m-%d") end_date = datetime.strptime(end_date, "%Y-%m-%d") @@ -52,7 +61,7 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ end_datetime = end_date # Set timezone - if timezone != None: + if timezone is not None: tz = pytz.timezone(timezone) start_datetime = tz.localize(start_datetime) end_datetime = tz.localize(end_datetime) @@ -101,7 +110,7 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ @tool @dangerous_tool - async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str]): + async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: str): """ Crete a new event or meeting in a calendar. Omit start_time and end_time parameters to create an all-day event. :param calendar_name: The name of the calendar to add the event to @@ -113,9 +122,10 @@ async def schedule_event(calendar_name: str, title: str, description: str, start :param start_time: the start time in the following form: HH:MM AM/PM e.g. '3:00 PM' :param end_time: the start time in the following form: HH:MM AM/PM e.g. '4:00 PM' :param location: The location of the event - :param timezone: Timezone (e.g., 'America/New_York'). + :param timezone: Timezone (e.g., 'America/New_York'). REQUIRED. Must be a valid IANA timezone name. :return: bool """ + _require_timezone(timezone) await asyncio.to_thread(schedule_event_sync, calendar_name, title, description, start_date, end_date, attendees, start_time, end_time, location, timezone) return True @@ -198,7 +208,7 @@ async def find_free_time_slot_in_calendar(participants: list[str], slot_duration return available_slots def add_task_sync(calendar_name: str, title: str, description: str, due_date: Optional[str], - due_time: Optional[str], timezone: Optional[str]): + due_time: Optional[str], timezone: str): description_with_ai_note = f"{description}\n\n---\n\nThis task was scheduled by Nextcloud AI Assistant." # Create task @@ -220,7 +230,7 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op due_datetime = due_date # Set timezone - if timezone != None: + if timezone is not None: tz = pytz.timezone(timezone) due_datetime = tz.localize(due_datetime) @@ -238,7 +248,7 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op @tool @dangerous_tool - async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: Optional[str]): + async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: str): """ Crete a new task in a calendar. :param calendar_name: The name of the calendar to add the task to @@ -246,9 +256,10 @@ async def add_task(calendar_name: str, title: str, description: str, due_date: O :param description: The description of the task :param due_date: the due date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01' :param due_time: the due time in the following form: HH:MM AM/PM e.g. '3:00 PM' - :param timezone: Timezone (e.g., 'America/New_York'). Is required if there is a specified due date. + :param timezone: Timezone (e.g., 'America/New_York'). REQUIRED. Must be a valid IANA timezone name. :return: bool """ + _require_timezone(timezone) await asyncio.to_thread(add_task_sync, calendar_name, title, description, due_date, due_time, timezone) @@ -344,7 +355,7 @@ async def complete_task(calendar_name: str, task_uid: str): """ return await asyncio.to_thread(complete_task_sync, calendar_name, task_uid) - def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone_str: Optional[str] = None, priority: Optional[int] = None): + def update_task_sync(calendar_name: str, task_uid: str, timezone: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None): principal = ncSync.cal.principal() calendars = principal.calendars() calendar = {cal.name: cal for cal in calendars}[calendar_name] @@ -373,8 +384,8 @@ def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = N else: due_datetime = parsed_date - if timezone_str: - tz = pytz.timezone(timezone_str) + if timezone: + tz = pytz.timezone(timezone) due_datetime = tz.localize(due_datetime) ics_todo.due = due_datetime @@ -390,7 +401,7 @@ def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = N @tool @dangerous_tool - async def update_task(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone: Optional[str] = None, priority: Optional[int] = None): + async def update_task(calendar_name: str, task_uid: str, timezone: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None): """ Update an existing task :param calendar_name: The name of the calendar containing the task (obtainable via list_calendars) @@ -399,11 +410,12 @@ async def update_task(calendar_name: str, task_uid: str, title: Optional[str] = :param description: New description for the task :param due_date: New due date in the form: YYYY-MM-DD e.g. '2024-12-01' :param due_time: New due time in the form: HH:MM AM/PM e.g. '3:00 PM' - :param timezone: Timezone (e.g., 'America/New_York') + :param timezone: Timezone (e.g., 'America/New_York'). REQUIRED. Must be a valid IANA timezone name. :param priority: Priority from 0 (undefined) to 9 (lowest), where 1 is highest priority :return: bool indicating success """ - return await asyncio.to_thread(update_task_sync, calendar_name, task_uid, title, description, due_date, due_time, timezone, priority) + _require_timezone(timezone) + return await asyncio.to_thread(update_task_sync, calendar_name, task_uid, timezone, title, description, due_date, due_time, priority) def delete_task_sync(calendar_name: str, task_uid: str): principal = ncSync.cal.principal() diff --git a/ex_app/lib/all_tools/lib/freebusy_finder.py b/ex_app/lib/all_tools/lib/freebusy_finder.py index d65f3f16..a07b3890 100644 --- a/ex_app/lib/all_tools/lib/freebusy_finder.py +++ b/ex_app/lib/all_tools/lib/freebusy_finder.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone def find_available_slots(start_time, end_time, busy_intervals, slot_duration=timedelta(hours=1), max_slots=3): """ @@ -59,7 +59,7 @@ def round_to_nearest_half_hour(dt=None): :return: A rounded datetime object """ if dt is None: - dt = datetime.utcnow() + dt = datetime.now(timezone.utc) # Get minutes and determine rounding minutes = dt.minute From 5b0730abe9ff35ecd4b6a025500c2efe2e70f849 Mon Sep 17 00:00:00 2001 From: WSHAPER <42714629+WSHAPER@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:37:52 +0200 Subject: [PATCH 2/4] fix(tools): preserve the event timezone as TZID instead of serializing to UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schedule_event built VEVENTs with the ics library, which serializes any aware datetime to UTC. The stored event kept the right instant but lost the timezone it was created in: edits across DST boundaries and anyone reading the raw ICS lost the anchor (issue #231, same class as #3 — correct data, wrong localization contract). Building the event with icalendar, already a dependency, keeps DTSTART/DTEND as DTSTART;TZID=… exactly as supplied. The organizer's email is optional in the user profile, so the mailto value must tolerate None the way the previous Organizer constructor did. Verified end to end: 'tomorrow at noon' from an America/New_York profile on a CEST server stores as DTSTART;TZID=America/New_York:…T120000. Assisted-by: opencode:zai/glm-5.3 Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- ex_app/lib/all_tools/calendar.py | 39 ++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index bcdfe5d0..a0834e3a 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -2,12 +2,14 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import asyncio import time +import uuid from datetime import datetime, timezone, timedelta from typing import Optional from niquests import ConnectionError, Timeout import pytz -from ics import Calendar, Event, Attendee, Organizer, Todo +from icalendar import Event as ICalendarEvent, vCalAddress, vText +from ics import Calendar, Todo from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp, NextcloudApp import xml.etree.ElementTree as ET @@ -68,17 +70,23 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ description_with_ai_note = f"{description}\n\n---\n\nThis event was scheduled by Nextcloud AI Assistant." - # Create event - c = Calendar() - e = Event() - e.name = title - e.begin = start_datetime - e.end = end_datetime - e.description = description_with_ai_note - e.location = location - if attendees is not None: - for attendee in attendees: - e.add_attendee(Attendee(common_name=attendee, email=attendee, partstat='NEEDS-ACTION', role='REQ-PARTICIPANT', cutype='INDIVIDUAL')) + # Create event (icalendar keeps the event's timezone as a TZID; the ics library would serialize it to UTC) + e = ICalendarEvent() + e.add('summary', title) + e.add('dtstart', start_datetime) + e.add('dtend', end_datetime) + e.add('description', description_with_ai_note) + if location: + e.add('location', location) + e.add('uid', str(uuid.uuid4()) + '@context-agent') + e.add('dtstamp', datetime.now(pytz.UTC)) + for attendee in attendees or []: + a = vCalAddress('mailto:' + attendee) + a.params['CN'] = vText(attendee) + a.params['PARTSTAT'] = vText('NEEDS-ACTION') + a.params['ROLE'] = vText('REQ-PARTICIPANT') + a.params['CUTYPE'] = vText('INDIVIDUAL') + e.add('attendee', a) # let's check who we are... i = 0 @@ -98,15 +106,16 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ raise Exception('Error fetching current user information') # ...and set the organizer - e.organizer = Organizer(common_name=json['displayname'], email=json['email']) + organizer = vCalAddress('mailto:' + (json['email'] or '')) + organizer.params['CN'] = vText(json['displayname']) + e.add('organizer', organizer) # Add event to calendar - c.events.add(e) principal = ncSync.cal.principal() calendars = principal.calendars() calendar = {cal.name: cal for cal in calendars}[calendar_name] - calendar.add_event(str(c)) + calendar.add_event(e.to_ical().decode()) @tool @dangerous_tool From ee5df89e3dcb71f05d2665647adf27d023f7fa96 Mon Sep 17 00:00:00 2001 From: WSHAPER <42714629+WSHAPER@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:16:23 +0200 Subject: [PATCH 3/4] refactor(tools): default the timezone to the user's profile instead of requiring it Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- ex_app/lib/all_tools/assignments.py | 24 +++++++++----- ex_app/lib/all_tools/calendar.py | 51 +++++++++++++++++------------ 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/ex_app/lib/all_tools/assignments.py b/ex_app/lib/all_tools/assignments.py index 13c54e75..e6e4fa53 100644 --- a/ex_app/lib/all_tools/assignments.py +++ b/ex_app/lib/all_tools/assignments.py @@ -9,9 +9,17 @@ from ex_app.lib.all_tools.lib.decorator import dangerous_tool, safe_tool +async def _user_timezone(nc): + try: + user = (await nc.ocs('GET', '/ocs/v2.php/cloud/user')) or {} + return user.get('timezone') or None + except Exception: + return None + + def _require_timezone(timezone): if timezone is None or not timezone.strip(): - raise ValueError("timezone is REQUIRED and must be a valid IANA timezone name, e.g. 'America/New_York'") + raise ValueError("No timezone given and the user's profile has none set; provide a valid IANA timezone name, e.g. 'America/New_York'") try: return pytz.timezone(timezone).zone except pytz.UnknownTimeZoneError: @@ -29,7 +37,7 @@ async def get_tools(nc: AsyncNextcloudApp): @tool @dangerous_tool - async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, timezone: str, starts_at: None|str = None): + async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, timezone: str|None = None, starts_at: None|str = None): """ Create a Scheduled Task for the assistant that will be carried out autonomously. The user will still have to approve sensitive actions. @@ -40,12 +48,11 @@ async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, t :param prompt: The instructions for the agent carrying out the Scheduled Task (Do not tell it to create a scheduled task here, as this is the instruction that runs as the scheduled task already. So instead of "Create a scheduled task to do X", just pass "Do X" here) :param recurrence_rule: An RRule compliant with RFC 5545 that defines the recurrence rule for the Scheduled Task. For example "FREQ=DAILY;INTERVAL=1" to run the Scheduled Task every day, an empty string as the recurrence_rule means the task does not repeat. :param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately. Make sure to use the user's timezone for this, obtainable with find_details_of_current_user - :param timezone: Timezone (e.g., 'America/New_York'). REQUIRED. Must be a valid IANA timezone name; used to - interpret starts_at. + :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone; used to interpret starts_at. Must be a valid IANA timezone name when supplied. :return: """ - tz = _require_timezone(timezone) + tz = _require_timezone(timezone or await _user_timezone(nc)) await nc.ocs('POST', f'/ocs/v2.php/apps/assistant/assignments', json={ 'title': title, 'prompt': prompt, @@ -68,19 +75,18 @@ async def list_scheduled_tasks(): @tool @dangerous_tool - async def update_scheduled_task(id: int, timezone: str, prompt: None|str = None, recurrence_rule: None|str = None, starts_at: None|str = None): + async def update_scheduled_task(id: int, prompt: None|str = None, recurrence_rule: None|str = None, timezone: str|None = None, starts_at: None|str = None): """ Update a assistant Scheduled Task :param id: The ID of the Scheduled Task to update, you can obtain this from the list_scheduled_tasks tool :param prompt: The instructions for the AI carrying out the Scheduled Task. Pass `None` to leave this unchanged. (Do not tell the other AI to create a scheduled task here, as this is the instruction that runs as the scheduled task already. So instead of "Create a scheduled task to do X", just pass "Do X" here) :param recurrence_rule: An RRule compliant with RFC 5545 that defines the recurrence rule for the Scheduled Task. For example "FREQ=DAILY;INTERVAL=1" to run the Scheduled Task every day. An empty string means, it does not repeat. Pass `None` to leave this unchanged. - :param timezone: Timezone (e.g., 'America/New_York'). REQUIRED. Must be a valid IANA timezone name; used to - interpret starts_at. + :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone; used to interpret starts_at. Must be a valid IANA timezone name when supplied. :param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately. Pass `None` to leave this unchanged. :return: """ - tz = _require_timezone(timezone) + tz = _require_timezone(timezone or await _user_timezone(nc)) return await nc.ocs('PATCH', f'/ocs/v2.php/apps/assistant/assignments/{id}', json={ 'prompt': prompt, 'recurrence': recurrence_rule, diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index a0834e3a..cc2412ee 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -19,9 +19,17 @@ from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, round_to_nearest_half_hour +def _user_timezone(nc): + try: + user = nc.ocs('GET', '/ocs/v2.php/cloud/user') or {} + return user.get('timezone') or None + except Exception: + return None + + def _require_timezone(timezone): if timezone is None or not timezone.strip(): - raise ValueError("timezone is REQUIRED and must be a valid IANA timezone name, e.g. 'America/New_York'") + raise ValueError("No timezone given and the user's profile has none set; provide a valid IANA timezone name, e.g. 'America/New_York'") try: pytz.timezone(timezone) except pytz.UnknownTimeZoneError: @@ -46,7 +54,7 @@ async def list_calendars(): """ return await asyncio.to_thread(list_calendars_sync) - def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: str): + def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str] = None): # Parse date and times start_date = datetime.strptime(start_date, "%Y-%m-%d") end_date = datetime.strptime(end_date, "%Y-%m-%d") @@ -62,11 +70,12 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ start_datetime = start_date end_datetime = end_date - # Set timezone - if timezone is not None: - tz = pytz.timezone(timezone) - start_datetime = tz.localize(start_datetime) - end_datetime = tz.localize(end_datetime) + # Set timezone (defaults to the user's profile timezone) + timezone = timezone or _user_timezone(ncSync) + _require_timezone(timezone) + tz = pytz.timezone(timezone) + start_datetime = tz.localize(start_datetime) + end_datetime = tz.localize(end_datetime) description_with_ai_note = f"{description}\n\n---\n\nThis event was scheduled by Nextcloud AI Assistant." @@ -119,7 +128,7 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ @tool @dangerous_tool - async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: str): + async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str] = None): """ Crete a new event or meeting in a calendar. Omit start_time and end_time parameters to create an all-day event. :param calendar_name: The name of the calendar to add the event to @@ -131,7 +140,7 @@ async def schedule_event(calendar_name: str, title: str, description: str, start :param start_time: the start time in the following form: HH:MM AM/PM e.g. '3:00 PM' :param end_time: the start time in the following form: HH:MM AM/PM e.g. '4:00 PM' :param location: The location of the event - :param timezone: Timezone (e.g., 'America/New_York'). REQUIRED. Must be a valid IANA timezone name. + :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. :return: bool """ _require_timezone(timezone) @@ -217,7 +226,7 @@ async def find_free_time_slot_in_calendar(participants: list[str], slot_duration return available_slots def add_task_sync(calendar_name: str, title: str, description: str, due_date: Optional[str], - due_time: Optional[str], timezone: str): + due_time: Optional[str], timezone: Optional[str] = None): description_with_ai_note = f"{description}\n\n---\n\nThis task was scheduled by Nextcloud AI Assistant." # Create task @@ -238,10 +247,10 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op else: due_datetime = due_date - # Set timezone - if timezone is not None: - tz = pytz.timezone(timezone) - due_datetime = tz.localize(due_datetime) + # Set timezone (defaults to the user's profile timezone) + timezone = timezone or _user_timezone(ncSync) + _require_timezone(timezone) + due_datetime = pytz.timezone(timezone).localize(due_datetime) t.due = due_datetime @@ -257,7 +266,7 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op @tool @dangerous_tool - async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: str): + async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: Optional[str] = None): """ Crete a new task in a calendar. :param calendar_name: The name of the calendar to add the task to @@ -265,7 +274,7 @@ async def add_task(calendar_name: str, title: str, description: str, due_date: O :param description: The description of the task :param due_date: the due date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01' :param due_time: the due time in the following form: HH:MM AM/PM e.g. '3:00 PM' - :param timezone: Timezone (e.g., 'America/New_York'). REQUIRED. Must be a valid IANA timezone name. + :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. :return: bool """ _require_timezone(timezone) @@ -364,7 +373,7 @@ async def complete_task(calendar_name: str, task_uid: str): """ return await asyncio.to_thread(complete_task_sync, calendar_name, task_uid) - def update_task_sync(calendar_name: str, task_uid: str, timezone: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None): + def update_task_sync(calendar_name: str, task_uid: str, timezone: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None): principal = ncSync.cal.principal() calendars = principal.calendars() calendar = {cal.name: cal for cal in calendars}[calendar_name] @@ -393,9 +402,9 @@ def update_task_sync(calendar_name: str, task_uid: str, timezone: str, title: Op else: due_datetime = parsed_date - if timezone: - tz = pytz.timezone(timezone) - due_datetime = tz.localize(due_datetime) + timezone = timezone or _user_timezone(ncSync) + _require_timezone(timezone) + due_datetime = pytz.timezone(timezone).localize(due_datetime) ics_todo.due = due_datetime @@ -410,7 +419,7 @@ def update_task_sync(calendar_name: str, task_uid: str, timezone: str, title: Op @tool @dangerous_tool - async def update_task(calendar_name: str, task_uid: str, timezone: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None): + async def update_task(calendar_name: str, task_uid: str, timezone: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None): """ Update an existing task :param calendar_name: The name of the calendar containing the task (obtainable via list_calendars) From 1e1445a9746218dccb76b7bdc17480115c919192 Mon Sep 17 00:00:00 2001 From: WSHAPER <42714629+WSHAPER@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:36:44 +0200 Subject: [PATCH 4/4] refactor(tools): shared tz utils, explicit optional defaults, fix wrapper-level tz check Review round 2: the tz helpers move to all_tools/lib/tz.py (get_timezone, user_timezone, starts_at_timestamp) so calendar and assignments share one implementation, with type hints. Optional parameters carry explicit None defaults everywhere the agent can omit them. The async wrappers for schedule_event/add_task/update_task called the validation on the raw parameter, before the profile-timezone fallback in the sync bodies, so an omitted timezone errored even when the profile had one; the wrappers now only delegate. The scheduled-task starts_at docstring drops the stale find_details_of_current_user hint since the timezone defaults to the profile. Assisted-by: opencode:zai/glm-5.3 Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- ex_app/lib/all_tools/assignments.py | 35 ++++----------------- ex_app/lib/all_tools/calendar.py | 49 ++++++++--------------------- ex_app/lib/all_tools/lib/tz.py | 37 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 65 deletions(-) create mode 100644 ex_app/lib/all_tools/lib/tz.py diff --git a/ex_app/lib/all_tools/assignments.py b/ex_app/lib/all_tools/assignments.py index e6e4fa53..ee8826ba 100644 --- a/ex_app/lib/all_tools/assignments.py +++ b/ex_app/lib/all_tools/assignments.py @@ -7,30 +7,7 @@ from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.decorator import dangerous_tool, safe_tool - - -async def _user_timezone(nc): - try: - user = (await nc.ocs('GET', '/ocs/v2.php/cloud/user')) or {} - return user.get('timezone') or None - except Exception: - return None - - -def _require_timezone(timezone): - if timezone is None or not timezone.strip(): - raise ValueError("No timezone given and the user's profile has none set; provide a valid IANA timezone name, e.g. 'America/New_York'") - try: - return pytz.timezone(timezone).zone - except pytz.UnknownTimeZoneError: - raise ValueError(f"Invalid timezone '{timezone}'. Must be a valid IANA name, e.g. 'America/New_York'") from None - - -def _starts_at_timestamp(starts_at, timezone): - parsed = datetime.datetime.fromisoformat(starts_at.replace("Z", "+00:00")) - if parsed.tzinfo is None or parsed.utcoffset() is None: - parsed = pytz.timezone(timezone).localize(parsed) - return int(parsed.timestamp()) +from ex_app.lib.all_tools.lib.tz import get_timezone, starts_at_timestamp, user_timezone_async async def get_tools(nc: AsyncNextcloudApp): @@ -47,17 +24,17 @@ async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, t :param title: A title for the Scheduled Task, e.g. "Transcribe audio files" -- This is only for the user's reference and has no effect on the execution of the Scheduled Task. :param prompt: The instructions for the agent carrying out the Scheduled Task (Do not tell it to create a scheduled task here, as this is the instruction that runs as the scheduled task already. So instead of "Create a scheduled task to do X", just pass "Do X" here) :param recurrence_rule: An RRule compliant with RFC 5545 that defines the recurrence rule for the Scheduled Task. For example "FREQ=DAILY;INTERVAL=1" to run the Scheduled Task every day, an empty string as the recurrence_rule means the task does not repeat. - :param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately. Make sure to use the user's timezone for this, obtainable with find_details_of_current_user + :param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately. :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone; used to interpret starts_at. Must be a valid IANA timezone name when supplied. :return: """ - tz = _require_timezone(timezone or await _user_timezone(nc)) + tz = get_timezone(timezone or await user_timezone_async(nc)) await nc.ocs('POST', f'/ocs/v2.php/apps/assistant/assignments', json={ 'title': title, 'prompt': prompt, 'recurrence': recurrence_rule, - 'startsAt': _starts_at_timestamp(starts_at, tz) if starts_at is not None else datetime.datetime.now(datetime.UTC).timestamp(), + 'startsAt': starts_at_timestamp(starts_at, tz) if starts_at is not None else datetime.datetime.now(datetime.UTC).timestamp(), 'timezone': tz }) @@ -86,11 +63,11 @@ async def update_scheduled_task(id: int, prompt: None|str = None, recurrence_rul :return: """ - tz = _require_timezone(timezone or await _user_timezone(nc)) + tz = get_timezone(timezone or await user_timezone_async(nc)) return await nc.ocs('PATCH', f'/ocs/v2.php/apps/assistant/assignments/{id}', json={ 'prompt': prompt, 'recurrence': recurrence_rule, - 'startsAt': _starts_at_timestamp(starts_at, tz) if starts_at is not None else None, + 'startsAt': starts_at_timestamp(starts_at, tz) if starts_at is not None else None, 'timezone': tz }) diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index cc2412ee..2d974d0e 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -16,26 +16,10 @@ import vobject from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.tz import get_timezone, user_timezone from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, round_to_nearest_half_hour -def _user_timezone(nc): - try: - user = nc.ocs('GET', '/ocs/v2.php/cloud/user') or {} - return user.get('timezone') or None - except Exception: - return None - - -def _require_timezone(timezone): - if timezone is None or not timezone.strip(): - raise ValueError("No timezone given and the user's profile has none set; provide a valid IANA timezone name, e.g. 'America/New_York'") - try: - pytz.timezone(timezone) - except pytz.UnknownTimeZoneError: - raise ValueError(f"Invalid timezone '{timezone}'. Must be a valid IANA name, e.g. 'America/New_York'") from None - - async def get_tools(nc: AsyncNextcloudApp): ncSync = NextcloudApp() ncSync.set_user(await nc.user) @@ -54,7 +38,7 @@ async def list_calendars(): """ return await asyncio.to_thread(list_calendars_sync) - def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str] = None): + def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, location: Optional[str] = None, timezone: Optional[str] = None): # Parse date and times start_date = datetime.strptime(start_date, "%Y-%m-%d") end_date = datetime.strptime(end_date, "%Y-%m-%d") @@ -71,9 +55,8 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ end_datetime = end_date # Set timezone (defaults to the user's profile timezone) - timezone = timezone or _user_timezone(ncSync) - _require_timezone(timezone) - tz = pytz.timezone(timezone) + timezone = timezone or user_timezone(ncSync) + tz = pytz.timezone(get_timezone(timezone)) start_datetime = tz.localize(start_datetime) end_datetime = tz.localize(end_datetime) @@ -128,7 +111,7 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ @tool @dangerous_tool - async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str] = None): + async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, location: Optional[str] = None, timezone: Optional[str] = None): """ Crete a new event or meeting in a calendar. Omit start_time and end_time parameters to create an all-day event. :param calendar_name: The name of the calendar to add the event to @@ -143,7 +126,6 @@ async def schedule_event(calendar_name: str, title: str, description: str, start :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. :return: bool """ - _require_timezone(timezone) await asyncio.to_thread(schedule_event_sync, calendar_name, title, description, start_date, end_date, attendees, start_time, end_time, location, timezone) return True @@ -225,8 +207,8 @@ async def find_free_time_slot_in_calendar(participants: list[str], slot_duration available_slots = await asyncio.to_thread(find_free_time_slot_in_calendar_sync, participants, slot_duration, start_time, end_time) return available_slots - def add_task_sync(calendar_name: str, title: str, description: str, due_date: Optional[str], - due_time: Optional[str], timezone: Optional[str] = None): + def add_task_sync(calendar_name: str, title: str, description: str, due_date: Optional[str] = None, + due_time: Optional[str] = None, timezone: Optional[str] = None): description_with_ai_note = f"{description}\n\n---\n\nThis task was scheduled by Nextcloud AI Assistant." # Create task @@ -248,9 +230,8 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op due_datetime = due_date # Set timezone (defaults to the user's profile timezone) - timezone = timezone or _user_timezone(ncSync) - _require_timezone(timezone) - due_datetime = pytz.timezone(timezone).localize(due_datetime) + timezone = timezone or user_timezone(ncSync) + due_datetime = pytz.timezone(get_timezone(timezone)).localize(due_datetime) t.due = due_datetime @@ -266,7 +247,7 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op @tool @dangerous_tool - async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: Optional[str] = None): + async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone: Optional[str] = None): """ Crete a new task in a calendar. :param calendar_name: The name of the calendar to add the task to @@ -277,8 +258,6 @@ async def add_task(calendar_name: str, title: str, description: str, due_date: O :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. :return: bool """ - _require_timezone(timezone) - await asyncio.to_thread(add_task_sync, calendar_name, title, description, due_date, due_time, timezone) return True @@ -402,9 +381,8 @@ def update_task_sync(calendar_name: str, task_uid: str, timezone: Optional[str] else: due_datetime = parsed_date - timezone = timezone or _user_timezone(ncSync) - _require_timezone(timezone) - due_datetime = pytz.timezone(timezone).localize(due_datetime) + timezone = timezone or user_timezone(ncSync) + due_datetime = pytz.timezone(get_timezone(timezone)).localize(due_datetime) ics_todo.due = due_datetime @@ -428,11 +406,10 @@ async def update_task(calendar_name: str, task_uid: str, timezone: Optional[str] :param description: New description for the task :param due_date: New due date in the form: YYYY-MM-DD e.g. '2024-12-01' :param due_time: New due time in the form: HH:MM AM/PM e.g. '3:00 PM' - :param timezone: Timezone (e.g., 'America/New_York'). REQUIRED. Must be a valid IANA timezone name. + :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. :param priority: Priority from 0 (undefined) to 9 (lowest), where 1 is highest priority :return: bool indicating success """ - _require_timezone(timezone) return await asyncio.to_thread(update_task_sync, calendar_name, task_uid, timezone, title, description, due_date, due_time, priority) def delete_task_sync(calendar_name: str, task_uid: str): diff --git a/ex_app/lib/all_tools/lib/tz.py b/ex_app/lib/all_tools/lib/tz.py new file mode 100644 index 00000000..8edd5e47 --- /dev/null +++ b/ex_app/lib/all_tools/lib/tz.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +from datetime import datetime + +import pytz + + +def get_timezone(timezone: str | None) -> str: + if timezone is None or not timezone.strip(): + raise ValueError("No timezone given and the user's profile has none set; provide a valid IANA timezone name, e.g. 'America/New_York'") + try: + return pytz.timezone(timezone).zone + except pytz.UnknownTimeZoneError: + raise ValueError(f"Invalid timezone '{timezone}'. Must be a valid IANA name, e.g. 'America/New_York'") from None + + +def user_timezone(nc) -> str | None: + try: + user = nc.ocs('GET', '/ocs/v2.php/cloud/user') or {} + return user.get('timezone') or None + except Exception: + return None + + +async def user_timezone_async(nc) -> str | None: + try: + user = (await nc.ocs('GET', '/ocs/v2.php/cloud/user')) or {} + return user.get('timezone') or None + except Exception: + return None + + +def starts_at_timestamp(starts_at: str, timezone: str) -> int: + parsed = datetime.fromisoformat(starts_at.replace("Z", "+00:00")) + if parsed.tzinfo is None or parsed.utcoffset() is None: + parsed = pytz.timezone(timezone).localize(parsed) + return int(parsed.timestamp())