This commit is contained in:
Timothy Jaeryang Baek
2026-04-19 22:13:47 +09:00
parent f0ec5ee08f
commit 98627e42b4
4 changed files with 445 additions and 2 deletions
+420
View File
@@ -2810,3 +2810,423 @@ async def delete_automation(
except Exception as e:
log.exception(f'delete_automation error: {e}')
return json.dumps({'error': str(e)})
# =============================================================================
# CALENDAR TOOLS
# =============================================================================
def _get_user_tz(user_dict: dict):
"""Get the user's timezone as a ZoneInfo, falling back to UTC."""
from zoneinfo import ZoneInfo
tz_name = None
if user_dict:
tz_name = user_dict.get('timezone')
if tz_name:
try:
return ZoneInfo(tz_name)
except Exception:
pass
return ZoneInfo('UTC')
def _dt_to_ns(dt_str: str, tz) -> int:
"""Convert a datetime string to nanoseconds since epoch, interpreting in the given timezone."""
from datetime import datetime
dt = datetime.fromisoformat(dt_str)
# If naive (no timezone info), localize to user's timezone
if dt.tzinfo is None:
dt = dt.replace(tzinfo=tz)
return int(dt.timestamp() * 1_000) * 1_000_000
def _ns_to_dt(ns: int, tz) -> str:
"""Convert nanoseconds since epoch to a datetime string in the given timezone."""
from datetime import datetime
seconds = ns / 1_000_000_000
dt = datetime.fromtimestamp(seconds, tz=tz)
return dt.strftime('%Y-%m-%d %H:%M')
def _event_to_dict(event, tz) -> dict:
"""Convert a calendar event model to a human-friendly dict with local timestamps."""
return {
'id': event.id,
'calendar_id': event.calendar_id,
'title': event.title,
'description': event.description or '',
'start': _ns_to_dt(event.start_at, tz),
'end': _ns_to_dt(event.end_at, tz) if event.end_at else None,
'all_day': event.all_day,
'location': event.location or '',
'color': event.color,
'is_cancelled': event.is_cancelled,
}
async def search_calendar_events(
query: Optional[str] = None,
start: Optional[str] = None,
end: Optional[str] = None,
count: int = 10,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Search calendar events by text and/or date range.
Returns matching events across all accessible calendars.
:param query: Search text to match against event title, description, or location (optional)
:param start: Only return events starting at or after this datetime, e.g. "2026-04-20 00:00" (optional)
:param end: Only return events starting before this datetime, e.g. "2026-04-27 00:00" (optional)
:param count: Maximum number of events to return (default: 10)
:return: JSON list of matching events with id, title, description, start, end, calendar_id, location
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
try:
from open_webui.models.calendar import CalendarEvents
user_id = __user__.get('id')
tz = _get_user_tz(__user__)
if isinstance(count, str):
try:
count = int(count)
except ValueError:
count = 10
if start or end:
# Date range query — use get_events_by_range
try:
start_ns = _dt_to_ns(start, tz) if start else 0
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid start datetime: {e}'})
try:
end_ns = _dt_to_ns(end, tz) if end else int(time.time() * 1_000) * 1_000_000 + 365 * 86400 * 1_000_000_000_000
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid end datetime: {e}'})
items = await CalendarEvents.get_events_by_range(
user_id=user_id,
start=start_ns,
end=end_ns,
)
# Apply text filter if query is also provided
if query:
q = query.lower()
items = [
e for e in items
if q in (e.title or '').lower()
or q in (e.description or '').lower()
or q in (e.location or '').lower()
]
events = [_event_to_dict(item, tz) for item in items[:count]]
return json.dumps(
{'events': events, 'total': len(items)},
ensure_ascii=False,
)
else:
# Text-only search
result = await CalendarEvents.search_events(
user_id=user_id,
query=query,
skip=0,
limit=count,
)
events = [_event_to_dict(item, tz) for item in result.items]
return json.dumps(
{'events': events, 'total': result.total},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'search_calendar_events error: {e}')
return json.dumps({'error': str(e)})
async def create_calendar_event(
title: str,
start: str,
end: Optional[str] = None,
description: Optional[str] = None,
calendar_id: Optional[str] = None,
all_day: bool = False,
location: Optional[str] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Create a new calendar event. If no calendar_id is provided, the event is
added to the user's default calendar.
:param title: Event title
:param start: Start datetime string in your local time (e.g. "2026-04-20 09:00" or "2026-04-20T09:00:00")
:param end: End datetime string in your local time (optional, omit for point-in-time events)
:param description: Event description (optional)
:param calendar_id: Target calendar ID (optional, uses default calendar if omitted)
:param all_day: Whether this is an all-day event (default: false)
:param location: Event location (optional)
:return: JSON with the created event details including id
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
try:
from open_webui.models.calendar import Calendars, CalendarEvents, CalendarEventForm
user_id = __user__.get('id')
# Resolve calendar_id: use provided, or fall back to default
if not calendar_id:
calendars = await Calendars.get_calendars_by_user(user_id)
default_cal = next((c for c in calendars if c.is_default), None)
if not default_cal and calendars:
default_cal = calendars[0]
if not default_cal:
return json.dumps({'error': 'No calendars found. Cannot create event.'})
calendar_id = default_cal.id
# Verify access
cal = await Calendars.get_calendar_by_id(calendar_id)
if not cal:
return json.dumps({'error': 'Calendar not found'})
if cal.user_id != user_id and __user__.get('role') != 'admin':
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
if not await AccessGrants.has_access(
user_id=user_id,
resource_type='calendar',
resource_id=cal.id,
permission='write',
user_group_ids=set(user_group_ids),
):
return json.dumps({'error': 'Access denied to this calendar'})
# Coerce boolean from LLM
if isinstance(all_day, str):
all_day = all_day.lower() in ('true', '1', 'yes')
# Convert datetime strings to nanoseconds using user's timezone
tz = _get_user_tz(__user__)
try:
start_ns = _dt_to_ns(start, tz)
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid start datetime: {e}. Use format like "2026-04-20 09:00"'})
end_ns = None
if end:
try:
end_ns = _dt_to_ns(end, tz)
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid end datetime: {e}. Use format like "2026-04-20 10:00"'})
elif not all_day:
# Default to 1 hour duration
end_ns = start_ns + 3_600_000_000_000
form = CalendarEventForm(
calendar_id=calendar_id,
title=title,
description=description,
start_at=start_ns,
end_at=end_ns,
all_day=all_day,
location=location,
)
event = await CalendarEvents.insert_new_event(user_id, form)
if not event:
return json.dumps({'error': 'Failed to create event'})
return json.dumps(
{
'status': 'success',
**_event_to_dict(event, tz),
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'create_calendar_event error: {e}')
return json.dumps({'error': str(e)})
async def update_calendar_event(
event_id: str,
title: Optional[str] = None,
description: Optional[str] = None,
start: Optional[str] = None,
end: Optional[str] = None,
all_day: Optional[bool] = None,
location: Optional[str] = None,
is_cancelled: Optional[bool] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Update an existing calendar event. Only provided fields are changed;
omitted fields stay the same.
:param event_id: The ID of the event to update
:param title: New event title (optional)
:param description: New event description (optional)
:param start: New start datetime string in your local time, e.g. "2026-04-20 09:00" (optional)
:param end: New end datetime string in your local time (optional)
:param all_day: Whether this is an all-day event (optional)
:param location: New event location (optional)
:param is_cancelled: Set to true to cancel the event (optional)
:return: JSON with the updated event details
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
try:
from open_webui.models.calendar import Calendars, CalendarEvents, CalendarEventUpdateForm
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
user_id = __user__.get('id')
event = await CalendarEvents.get_event_by_id(event_id)
if not event:
return json.dumps({'error': 'Event not found'})
# Check write access to the event's calendar
cal = await Calendars.get_calendar_by_id(event.calendar_id)
if cal and cal.user_id != user_id and __user__.get('role') != 'admin':
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
if not await AccessGrants.has_access(
user_id=user_id,
resource_type='calendar',
resource_id=cal.id,
permission='write',
user_group_ids=set(user_group_ids),
):
return json.dumps({'error': 'Access denied'})
# Coerce boolean strings from LLM
if isinstance(all_day, str):
all_day = all_day.lower() in ('true', '1', 'yes')
if isinstance(is_cancelled, str):
is_cancelled = is_cancelled.lower() in ('true', '1', 'yes')
# Convert datetime strings to nanoseconds using user's timezone
tz = _get_user_tz(__user__)
start_ns = None
if start is not None:
try:
start_ns = _dt_to_ns(start, tz)
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid start datetime: {e}'})
end_ns = None
if end is not None:
try:
end_ns = _dt_to_ns(end, tz)
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid end datetime: {e}'})
form = CalendarEventUpdateForm(
title=title,
description=description,
start_at=start_ns,
end_at=end_ns,
all_day=all_day,
location=location,
is_cancelled=is_cancelled,
)
updated = await CalendarEvents.update_event_by_id(event_id, form)
if not updated:
return json.dumps({'error': 'Failed to update event'})
return json.dumps(
{
'status': 'success',
**_event_to_dict(updated, tz),
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'update_calendar_event error: {e}')
return json.dumps({'error': str(e)})
async def delete_calendar_event(
event_id: str,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Delete a calendar event permanently.
:param event_id: The ID of the event to delete
:return: JSON confirming the event was deleted
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
try:
from open_webui.models.calendar import Calendars, CalendarEvents
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
user_id = __user__.get('id')
event = await CalendarEvents.get_event_by_id(event_id)
if not event:
return json.dumps({'error': 'Event not found'})
# Check write access
cal = await Calendars.get_calendar_by_id(event.calendar_id)
if cal and cal.user_id != user_id and __user__.get('role') != 'admin':
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
if not await AccessGrants.has_access(
user_id=user_id,
resource_type='calendar',
resource_id=cal.id,
permission='write',
user_group_ids=set(user_group_ids),
):
return json.dumps({'error': 'Access denied'})
title = event.title
result = await CalendarEvents.delete_event_by_id(event_id)
if not result:
return json.dumps({'error': 'Failed to delete event'})
return json.dumps(
{
'status': 'success',
'message': f'Event "{title}" deleted',
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'delete_calendar_event error: {e}')
return json.dumps({'error': str(e)})
+10
View File
@@ -93,6 +93,10 @@ from open_webui.tools.builtin import (
list_automations,
toggle_automation,
delete_automation,
search_calendar_events,
create_calendar_event,
update_calendar_event,
delete_calendar_event,
)
import copy
@@ -552,6 +556,12 @@ async def get_builtin_tools(
[create_automation, update_automation, list_automations, toggle_automation, delete_automation]
)
# Calendar tools - search/create/update/delete events
if is_builtin_tool_enabled('calendar'):
builtin_functions.extend(
[search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event]
)
for func in builtin_functions:
callable = await get_async_tool_function_and_apply_extra_params(
func,
@@ -3,6 +3,7 @@
import { toast } from 'svelte-sonner';
import Modal from '$lib/components/common/Modal.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
@@ -28,6 +29,7 @@
let allDay = false;
let location = '';
let loading = false;
let showDeleteConfirmDialog = false;
const NS = 1_000_000;
@@ -223,9 +225,9 @@
<div class="flex items-center gap-0.5 flex-1 min-w-0">
{#if event && !event.meta?.automation_id}
<button
class="px-3 py-1 text-xs text-gray-400 hover:text-red-500 transition"
class="px-3 py-1 text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition"
type="button"
on:click={deleteHandler}
on:click={() => (showDeleteConfirmDialog = true)}
disabled={loading}
>
{$i18n.t('Delete')}
@@ -258,3 +260,10 @@
</div>
</div>
</Modal>
<DeleteConfirmDialog
bind:show={showDeleteConfirmDialog}
title={$i18n.t('Delete Event')}
message={$i18n.t('This action cannot be undone. Do you wish to continue?')}
on:confirm={deleteHandler}
/>
@@ -50,6 +50,10 @@
automations: {
label: $i18n.t('Automations'),
description: $i18n.t('Create and manage scheduled automations')
},
calendar: {
label: $i18n.t('Calendar'),
description: $i18n.t('List calendars, search, create, update, and delete calendar events')
}
};