This commit is contained in:
Timothy Jaeryang Baek
2026-04-19 23:38:58 +09:00
parent 29ee53aaa5
commit e5b5a17426
5 changed files with 244 additions and 22 deletions
+2 -2
View File
@@ -674,9 +674,9 @@ async def lifespan(app: FastAPI):
asyncio.create_task(periodic_usage_pool_cleanup())
asyncio.create_task(periodic_session_pool_cleanup())
from open_webui.utils.automations import automation_worker_loop
from open_webui.utils.automations import scheduler_worker_loop
asyncio.create_task(automation_worker_loop(app))
asyncio.create_task(scheduler_worker_loop(app))
if app.state.config.ENABLE_BASE_MODELS_CACHE:
try:
+51
View File
@@ -725,6 +725,57 @@ class CalendarEventTable:
await db.commit()
return await self._to_event_model(event, db=db)
async def get_upcoming_events(
self,
now_ns: int,
default_lookahead_ns: int,
db: Optional[AsyncSession] = None,
) -> list[tuple[CalendarEventModel, Optional[str]]]:
"""Events starting between now and now + lookahead, for alert processing.
Per-event lookahead is read from meta.alert_minutes (falls back to
default_lookahead_ns). Returns (event, user_timezone) pairs.
"""
from open_webui.models.users import User as UserRow
# Use the maximum possible lookahead (60 min) to cast a wide net;
# per-event filtering happens in Python after fetching.
max_lookahead_ns = max(default_lookahead_ns, 60 * 60 * 1_000_000_000)
upper = now_ns + max_lookahead_ns
async with get_async_db_context(db) as db:
result = await db.execute(
select(CalendarEvent, UserRow.timezone)
.outerjoin(UserRow, UserRow.id == CalendarEvent.user_id)
.filter(
CalendarEvent.is_cancelled == False,
CalendarEvent.start_at >= now_ns,
CalendarEvent.start_at <= upper,
)
)
rows = result.all()
events = []
for event, tz in rows:
model = CalendarEventModel.model_validate(event)
# Determine per-event alert window
alert_minutes = None
if model.meta and 'alert_minutes' in model.meta:
alert_minutes = model.meta['alert_minutes']
if alert_minutes is not None:
if alert_minutes < 0:
# alert_minutes < 0 means "no alert"
continue
event_lookahead_ns = alert_minutes * 60 * 1_000_000_000
else:
event_lookahead_ns = default_lookahead_ns
if model.start_at <= now_ns + event_lookahead_ns:
events.append((model, tz))
return events
async def delete_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
+135 -18
View File
@@ -1,11 +1,16 @@
"""
Automation utilities.
Automation utilities and unified scheduler.
RRULE helpers, worker loop, and execution logic.
RRULE helpers, scheduler worker loop, and execution logic.
Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
The scheduler_worker_loop handles all time-based background work:
- Automation execution (claim_due → execute)
- Calendar event alerts (upcoming events → socket + webhook notifications)
Environment:
AUTOMATION_POLL_INTERVAL – seconds between polls (default: 10)
SCHEDULER_POLL_INTERVAL – seconds between polls (default: 10)
CALENDAR_ALERT_LOOKAHEAD_MINUTES – default alert window (default: 5)
"""
import asyncio
@@ -31,7 +36,8 @@ from open_webui.internal.db import get_async_db
log = logging.getLogger(__name__)
AUTOMATION_POLL_INTERVAL = int(os.getenv('AUTOMATION_POLL_INTERVAL', '10'))
SCHEDULER_POLL_INTERVAL = int(os.getenv('SCHEDULER_POLL_INTERVAL', os.getenv('AUTOMATION_POLL_INTERVAL', '10')))
CALENDAR_ALERT_LOOKAHEAD_MINUTES = int(os.getenv('CALENDAR_ALERT_LOOKAHEAD_MINUTES', '10'))
####################
@@ -117,30 +123,49 @@ def rrule_interval_seconds(s: str) -> Optional[int]:
############################
# Keep the old name as an alias so any stale imports still work.
async def automation_worker_loop(app) -> None:
"""Poll for due automations, claim, fire-and-forget execute.
"""Deprecated alias — use scheduler_worker_loop."""
await scheduler_worker_loop(app)
async def scheduler_worker_loop(app) -> None:
"""Unified background scheduler for all time-based work.
Handles:
1. Automation execution (ENABLE_AUTOMATIONS)
2. Calendar event alerts (ENABLE_CALENDAR)
Runs on every instance. Poll interval is configurable via
AUTOMATION_POLL_INTERVAL env var (default: 10 seconds).
SCHEDULER_POLL_INTERVAL env var (default: 10 seconds).
"""
log.info(f'Automation worker started (poll interval: {AUTOMATION_POLL_INTERVAL}s)')
log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)')
while True:
try:
if not getattr(app.state.config, 'ENABLE_AUTOMATIONS', False):
await asyncio.sleep(AUTOMATION_POLL_INTERVAL)
continue
# ── Automations ──
if getattr(app.state.config, 'ENABLE_AUTOMATIONS', False):
try:
async with get_async_db() as db:
batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db)
if batch:
log.info(f'Claimed {len(batch)} due automation(s)')
for automation in batch:
asyncio.create_task(execute_automation(app, automation))
except Exception:
log.exception('Scheduler: automation error')
# ── Calendar Alerts ──
if getattr(app.state.config, 'ENABLE_CALENDAR', False):
try:
await _check_calendar_alerts(app)
except Exception:
log.exception('Scheduler: calendar alert error')
async with get_async_db() as db:
batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db)
if batch:
log.info(f'Claimed {len(batch)} due automation(s)')
for automation in batch:
asyncio.create_task(execute_automation(app, automation))
except Exception:
log.exception('Automation worker error')
log.exception('Scheduler worker error')
# Jitter to spread load across instances
await asyncio.sleep(AUTOMATION_POLL_INTERVAL + random.uniform(0, 2))
await asyncio.sleep(SCHEDULER_POLL_INTERVAL + random.uniform(0, 2))
##########################
@@ -433,6 +458,98 @@ async def execute_automation(app, automation: AutomationModel) -> None:
####################
async def _check_calendar_alerts(app) -> None:
"""Check for upcoming calendar events and send alert notifications.
De-duplication is DB-backed via meta.alerted_at — survives restarts
and works across multiple instances.
"""
from open_webui.models.calendar import CalendarEvents, CalendarEventUpdateForm
from open_webui.socket.main import sio
now_ns = int(time.time_ns())
default_lookahead_ns = CALENDAR_ALERT_LOOKAHEAD_MINUTES * 60 * 1_000_000_000
async with get_async_db() as db:
upcoming = await CalendarEvents.get_upcoming_events(now_ns, default_lookahead_ns, db=db)
if not upcoming:
return
for event, user_tz in upcoming:
# Skip if already alerted for this start time
if event.meta and event.meta.get('alerted_at'):
continue
# Compute minutes until event starts
minutes_until = max(0, int((event.start_at - now_ns) / (60 * 1_000_000_000)))
alert_data = {
'event_id': event.id,
'title': event.title,
'description': event.description or '',
'start_at': event.start_at,
'minutes_until': minutes_until,
'calendar_id': event.calendar_id,
'location': event.location or '',
}
await sio.emit(
'events',
{
'data': {
'type': 'calendar:alert',
'data': alert_data,
},
},
room=f'user:{event.user_id}',
)
# Mark as alerted in DB so it survives restarts / multi-instance
try:
await CalendarEvents.update_event_by_id(
event.id,
CalendarEventUpdateForm(meta={'alerted_at': now_ns}),
)
except Exception:
log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True)
# Send webhook notification if user has one configured
try:
webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI')
enable_user_webhooks = getattr(app.state.config, 'ENABLE_USER_WEBHOOKS', False)
if enable_user_webhooks:
user = await Users.get_user_by_id(event.user_id)
if user and user.settings:
webhook_url = (
user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
if isinstance(user.settings, dict)
else getattr(getattr(user.settings, 'ui', None), 'get', lambda *a: None)(
'notifications', {}
).get('webhook_url', None)
if hasattr(user.settings, 'ui')
else None
)
if webhook_url:
from open_webui.utils.webhook import post_webhook
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
await post_webhook(
webui_name,
webhook_url,
f'{event.title} — starting {time_str}',
{
'action': 'calendar_alert',
'title': event.title,
'minutes_until': minutes_until,
'event_id': event.id,
},
)
except Exception:
log.debug(f'Failed to send webhook for calendar alert {event.id}', exc_info=True)
async def _record_run(
automation_id: str,
status: str,
@@ -32,6 +32,7 @@
let endTime = '';
let allDay = false;
let location = '';
let alertMinutes: number = 10;
let loading = false;
let showDeleteConfirmDialog = false;
@@ -60,6 +61,7 @@
endTime = event.end_at ? nsToTimeStr(event.end_at) : '';
allDay = event.all_day;
location = event.location || '';
alertMinutes = event.meta?.alert_minutes ?? 10;
} else {
title = '';
description = '';
@@ -80,6 +82,7 @@
}
allDay = false;
location = '';
alertMinutes = 10;
}
}
@@ -104,7 +107,8 @@
start_at: startNs,
end_at: endNs,
all_day: allDay,
location: location.trim() || undefined
location: location.trim() || undefined,
meta: { alert_minutes: alertMinutes }
});
if (result) {
toast.success($i18n.t('Event updated'));
@@ -119,7 +123,8 @@
start_at: startNs,
end_at: endNs,
all_day: allDay,
location: location.trim() || undefined
location: location.trim() || undefined,
meta: { alert_minutes: alertMinutes }
};
const result = await createCalendarEvent(localStorage.token, form);
if (result) {
@@ -212,6 +217,23 @@
/>
</div>
<!-- Reminder -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Reminder')}</div>
<select
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
bind:value={alertMinutes}
>
<option value={-1}>{$i18n.t('None')}</option>
<option value={0}>{$i18n.t('At time of event')}</option>
<option value={5}>{$i18n.t('5 minutes before')}</option>
<option value={10}>{$i18n.t('10 minutes before')}</option>
<option value={15}>{$i18n.t('15 minutes before')}</option>
<option value={30}>{$i18n.t('30 minutes before')}</option>
<option value={60}>{$i18n.t('1 hour before')}</option>
</select>
</div>
<!-- Description -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
+32
View File
@@ -439,6 +439,38 @@
const type = event?.data?.type ?? null;
const data = event?.data?.data ?? null;
// Calendar alerts are not chat-scoped — handle before chat_id checks
if (type === 'calendar:alert' && data) {
const timeStr =
data.minutes_until <= 0
? $i18n.t('Starting now')
: data.minutes_until === 1
? $i18n.t('Starting in 1 minute')
: $i18n.t('Starting in {{count}} minutes', { count: data.minutes_until });
toast.custom(NotificationToast, {
componentProps: {
onClick: () => {
goto('/calendar');
},
title: data.title,
content: timeStr
},
duration: 30000,
unstyled: true
});
if ($isLastActiveTab) {
if ($settings?.notificationEnabled ?? false) {
new Notification(`${data.title} • Open WebUI`, {
body: timeStr,
icon: `${WEBUI_BASE_URL}/static/favicon.png`
});
}
}
return;
}
if ((event.chat_id !== $chatId && !$temporaryChatEnabled) || isFocused) {
if (type === 'chat:completion') {
const { done, content, title } = data;