lollmsbot 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,717 @@
1
+ """
2
+ Calendar tool for LollmsBot.
3
+
4
+ This module provides the CalendarTool class for managing calendar events
5
+ with in-memory storage and optional ICS file export/import support.
6
+ All datetime operations are timezone-aware.
7
+ """
8
+
9
+ import asyncio
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from lollmsbot.agent import Tool, ToolResult, ToolError
17
+
18
+
19
+ @dataclass
20
+ class Event:
21
+ """Represents a calendar event.
22
+
23
+ Attributes:
24
+ id: Unique identifier for the event (UUID string).
25
+ title: Event title/summary.
26
+ start: Start datetime (timezone-aware).
27
+ end: End datetime (timezone-aware).
28
+ description: Optional event description.
29
+ created_at: Timestamp when the event was created.
30
+ updated_at: Timestamp when the event was last updated.
31
+ """
32
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
33
+ title: str = ""
34
+ start: datetime = field(default_factory=datetime.now)
35
+ end: datetime = field(default_factory=datetime.now)
36
+ description: str = ""
37
+ created_at: datetime = field(default_factory=datetime.now)
38
+ updated_at: datetime = field(default_factory=datetime.now)
39
+
40
+ def __post_init__(self) -> None:
41
+ """Ensure datetime fields are timezone-aware."""
42
+ if self.start.tzinfo is None:
43
+ from datetime import timezone
44
+ self.start = self.start.replace(tzinfo=timezone.utc)
45
+ if self.end.tzinfo is None:
46
+ from datetime import timezone
47
+ self.end = self.end.replace(tzinfo=timezone.utc)
48
+ if self.created_at.tzinfo is None:
49
+ from datetime import timezone
50
+ self.created_at = self.created_at.replace(tzinfo=timezone.utc)
51
+ if self.updated_at.tzinfo is None:
52
+ from datetime import timezone
53
+ self.updated_at = self.updated_at.replace(tzinfo=timezone.utc)
54
+
55
+ def to_dict(self) -> Dict[str, Any]:
56
+ """Convert event to dictionary representation.
57
+
58
+ Returns:
59
+ Dictionary with event data.
60
+ """
61
+ return {
62
+ "id": self.id,
63
+ "title": self.title,
64
+ "start": self.start.isoformat(),
65
+ "end": self.end.isoformat(),
66
+ "description": self.description,
67
+ "created_at": self.created_at.isoformat(),
68
+ "updated_at": self.updated_at.isoformat(),
69
+ }
70
+
71
+ @classmethod
72
+ def from_dict(cls, data: Dict[str, Any]) -> "Event":
73
+ """Create event from dictionary representation.
74
+
75
+ Args:
76
+ data: Dictionary with event data.
77
+
78
+ Returns:
79
+ New Event instance.
80
+ """
81
+ def parse_datetime(value: Any) -> datetime:
82
+ if isinstance(value, str):
83
+ return datetime.fromisoformat(value)
84
+ return value
85
+
86
+ return cls(
87
+ id=data.get("id", str(uuid.uuid4())),
88
+ title=data.get("title", ""),
89
+ start=parse_datetime(data.get("start", datetime.now())),
90
+ end=parse_datetime(data.get("end", datetime.now())),
91
+ description=data.get("description", ""),
92
+ created_at=parse_datetime(data.get("created_at", datetime.now())),
93
+ updated_at=parse_datetime(data.get("updated_at", datetime.now())),
94
+ )
95
+
96
+ def to_ics(self) -> str:
97
+ """Convert event to ICS format string.
98
+
99
+ Returns:
100
+ ICS VEVENT component as string.
101
+ """
102
+ from datetime import timezone
103
+
104
+ def format_datetime(dt: datetime) -> str:
105
+ """Format datetime for ICS (UTC or local with Z suffix)."""
106
+ utc_dt = dt.astimezone(timezone.utc)
107
+ return utc_dt.strftime("%Y%m%dT%H%M%SZ")
108
+
109
+ ics_lines = [
110
+ "BEGIN:VEVENT",
111
+ f"UID:{self.id}",
112
+ f"SUMMARY:{self.title}",
113
+ f"DTSTART:{format_datetime(self.start)}",
114
+ f"DTEND:{format_datetime(self.end)}",
115
+ ]
116
+
117
+ if self.description:
118
+ # Escape special characters in description
119
+ escaped_desc = self.description.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
120
+ ics_lines.append(f"DESCRIPTION:{escaped_desc}")
121
+
122
+ ics_lines.append(f"DTSTAMP:{format_datetime(datetime.now(timezone.utc))}")
123
+ ics_lines.append("END:VEVENT")
124
+
125
+ return "\r\n".join(ics_lines)
126
+
127
+ @classmethod
128
+ def from_ics(cls, ics_data: str) -> "Event":
129
+ """Parse event from ICS format string.
130
+
131
+ Args:
132
+ ics_data: ICS VEVENT component as string.
133
+
134
+ Returns:
135
+ New Event instance.
136
+ """
137
+ from datetime import timezone
138
+
139
+ lines = ics_data.replace("\r\n", "\n").split("\n")
140
+ data: Dict[str, str] = {}
141
+
142
+ for line in lines:
143
+ if ":" in line and not line.startswith("BEGIN:") and not line.startswith("END:"):
144
+ key, value = line.split(":", 1)
145
+ # Handle property parameters (e.g., DTSTART;TZID=...)
146
+ if ";" in key:
147
+ key = key.split(";")[0]
148
+ data[key] = value
149
+
150
+ def parse_ics_datetime(value: str) -> datetime:
151
+ """Parse ICS datetime format."""
152
+ value = value.strip()
153
+ if value.endswith("Z"):
154
+ # UTC datetime
155
+ return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
156
+ elif "T" in value:
157
+ # Local datetime (assume UTC if no tz specified)
158
+ dt = datetime.strptime(value, "%Y%m%dT%H%M%S")
159
+ return dt.replace(tzinfo=timezone.utc)
160
+ else:
161
+ # Date only
162
+ dt = datetime.strptime(value, "%Y%m%d")
163
+ return dt.replace(tzinfo=timezone.utc)
164
+
165
+ # Unescape description
166
+ description = data.get("DESCRIPTION", "")
167
+ description = description.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
168
+
169
+ return cls(
170
+ id=data.get("UID", str(uuid.uuid4())),
171
+ title=data.get("SUMMARY", ""),
172
+ start=parse_ics_datetime(data.get("DTSTART", "")),
173
+ end=parse_ics_datetime(data.get("DTEND", "")),
174
+ description=description,
175
+ )
176
+
177
+
178
+ class CalendarTool(Tool):
179
+ """Tool for managing calendar events with in-memory storage.
180
+
181
+ This tool provides CRUD operations for calendar events with optional
182
+ ICS file import/export support. All datetime operations are timezone-aware.
183
+
184
+ Attributes:
185
+ name: Unique identifier for the tool.
186
+ description: Human-readable description of what the tool does.
187
+ parameters: JSON Schema describing expected parameters for each method.
188
+ events: In-memory storage of events indexed by ID.
189
+ ics_file_path: Optional path for ICS file persistence.
190
+ """
191
+
192
+ name: str = "calendar"
193
+ description: str = (
194
+ "Manage calendar events including listing, adding, and deleting events. "
195
+ "Supports filtering by date range and optional ICS file export/import. "
196
+ "All datetimes are handled with timezone awareness."
197
+ )
198
+
199
+ parameters: dict[str, Any] = {
200
+ "type": "object",
201
+ "properties": {
202
+ "operation": {
203
+ "type": "string",
204
+ "enum": ["get_events", "add_event", "delete_event", "export_ics", "import_ics"],
205
+ "description": "The calendar operation to perform",
206
+ },
207
+ "start": {
208
+ "type": "string",
209
+ "description": "Start datetime in ISO format (required for get_events, add_event)",
210
+ },
211
+ "end": {
212
+ "type": "string",
213
+ "description": "End datetime in ISO format (required for get_events, add_event)",
214
+ },
215
+ "title": {
216
+ "type": "string",
217
+ "description": "Event title (required for add_event)",
218
+ },
219
+ "description": {
220
+ "type": "string",
221
+ "description": "Event description (optional for add_event)",
222
+ },
223
+ "event_id": {
224
+ "type": "string",
225
+ "description": "Event ID (required for delete_event)",
226
+ },
227
+ "ics_path": {
228
+ "type": "string",
229
+ "description": "Path to ICS file (required for export_ics, import_ics)",
230
+ },
231
+ },
232
+ "required": ["operation"],
233
+ }
234
+
235
+ def __init__(
236
+ self,
237
+ ics_file_path: Optional[str] = None,
238
+ default_timezone: str = "UTC",
239
+ ) -> None:
240
+ """Initialize the CalendarTool.
241
+
242
+ Args:
243
+ ics_file_path: Optional path for ICS file persistence.
244
+ default_timezone: Default timezone for datetime operations.
245
+ """
246
+ self.events: Dict[str, Event] = {}
247
+ self.ics_file_path: Optional[Path] = Path(ics_file_path) if ics_file_path else None
248
+ self.default_timezone: str = default_timezone
249
+
250
+ # Load existing events from ICS if available
251
+ if self.ics_file_path and self.ics_file_path.exists():
252
+ asyncio.create_task(self._load_from_ics_async())
253
+
254
+ def _get_timezone(self, tz_name: Optional[str] = None) -> Any:
255
+ """Get timezone object by name.
256
+
257
+ Args:
258
+ tz_name: Timezone name (e.g., 'UTC', 'America/New_York').
259
+ Defaults to default_timezone if not specified.
260
+
261
+ Returns:
262
+ Timezone object.
263
+ """
264
+ import zoneinfo
265
+
266
+ tz = tz_name or self.default_timezone
267
+ try:
268
+ return zoneinfo.ZoneInfo(tz)
269
+ except zoneinfo.ZoneInfoNotFoundError:
270
+ # Fallback to UTC if timezone not found
271
+ return zoneinfo.ZoneInfo("UTC")
272
+
273
+ def _parse_datetime(self, value: str | datetime) -> datetime:
274
+ """Parse datetime from string or return datetime object.
275
+
276
+ Args:
277
+ value: ISO format string or datetime object.
278
+
279
+ Returns:
280
+ Timezone-aware datetime object.
281
+ """
282
+ if isinstance(value, datetime):
283
+ if value.tzinfo is None:
284
+ from datetime import timezone
285
+ return value.replace(tzinfo=timezone.utc)
286
+ return value
287
+
288
+ # Try ISO format parsing
289
+ try:
290
+ dt = datetime.fromisoformat(value)
291
+ if dt.tzinfo is None:
292
+ from datetime import timezone
293
+ dt = dt.replace(tzinfo=timezone.utc)
294
+ return dt
295
+ except ValueError:
296
+ # Try common formats
297
+ formats = [
298
+ "%Y-%m-%d %H:%M:%S",
299
+ "%Y-%m-%d",
300
+ "%d/%m/%Y %H:%M:%S",
301
+ "%d/%m/%Y",
302
+ ]
303
+ for fmt in formats:
304
+ try:
305
+ dt = datetime.strptime(value, fmt)
306
+ from datetime import timezone
307
+ return dt.replace(tzinfo=timezone.utc)
308
+ except ValueError:
309
+ continue
310
+
311
+ raise ValueError(f"Unable to parse datetime: {value}")
312
+
313
+ async def get_events(
314
+ self,
315
+ start: Optional[str | datetime] = None,
316
+ end: Optional[str | datetime] = None,
317
+ ) -> ToolResult:
318
+ """Get events within a date range.
319
+
320
+ Args:
321
+ start: Start of date range (inclusive). If None, no lower bound.
322
+ end: End of date range (inclusive). If None, no upper bound.
323
+
324
+ Returns:
325
+ ToolResult with list of matching events.
326
+ """
327
+ try:
328
+ parsed_start: Optional[datetime] = None
329
+ parsed_end: Optional[datetime] = None
330
+
331
+ if start:
332
+ parsed_start = self._parse_datetime(start)
333
+ if end:
334
+ parsed_end = self._parse_datetime(end)
335
+
336
+ matching_events: List[Event] = []
337
+
338
+ for event in self.events.values():
339
+ # Check if event overlaps with range
340
+ event_in_range = True
341
+
342
+ if parsed_start and event.end < parsed_start:
343
+ event_in_range = False
344
+ if parsed_end and event.start > parsed_end:
345
+ event_in_range = False
346
+
347
+ if event_in_range:
348
+ matching_events.append(event)
349
+
350
+ # Sort by start time
351
+ matching_events.sort(key=lambda e: e.start)
352
+
353
+ return ToolResult(
354
+ success=True,
355
+ output={
356
+ "count": len(matching_events),
357
+ "events": [e.to_dict() for e in matching_events],
358
+ },
359
+ error=None,
360
+ )
361
+
362
+ except ValueError as exc:
363
+ return ToolResult(
364
+ success=False,
365
+ output=None,
366
+ error=f"Invalid datetime format: {str(exc)}",
367
+ )
368
+ except Exception as exc:
369
+ return ToolResult(
370
+ success=False,
371
+ output=None,
372
+ error=f"Error retrieving events: {str(exc)}",
373
+ )
374
+
375
+ async def add_event(
376
+ self,
377
+ title: str,
378
+ start: str | datetime,
379
+ end: str | datetime,
380
+ description: str = "",
381
+ ) -> ToolResult:
382
+ """Add a new calendar event.
383
+
384
+ Args:
385
+ title: Event title/summary.
386
+ start: Event start datetime.
387
+ end: Event end datetime.
388
+ description: Optional event description.
389
+
390
+ Returns:
391
+ ToolResult with the created event data.
392
+ """
393
+ try:
394
+ if not title:
395
+ return ToolResult(
396
+ success=False,
397
+ output=None,
398
+ error="Event title is required",
399
+ )
400
+
401
+ parsed_start = self._parse_datetime(start)
402
+ parsed_end = self._parse_datetime(end)
403
+
404
+ if parsed_end <= parsed_start:
405
+ return ToolResult(
406
+ success=False,
407
+ output=None,
408
+ error="End time must be after start time",
409
+ )
410
+
411
+ event = Event(
412
+ title=title,
413
+ start=parsed_start,
414
+ end=parsed_end,
415
+ description=description,
416
+ )
417
+
418
+ self.events[event.id] = event
419
+
420
+ # Persist to ICS if path is configured
421
+ if self.ics_file_path:
422
+ await self._save_to_ics_async()
423
+
424
+ return ToolResult(
425
+ success=True,
426
+ output={
427
+ "message": f"Event '{title}' created successfully",
428
+ "event": event.to_dict(),
429
+ },
430
+ error=None,
431
+ )
432
+
433
+ except ValueError as exc:
434
+ return ToolResult(
435
+ success=False,
436
+ output=None,
437
+ error=f"Invalid datetime format: {str(exc)}",
438
+ )
439
+ except Exception as exc:
440
+ return ToolResult(
441
+ success=False,
442
+ output=None,
443
+ error=f"Error creating event: {str(exc)}",
444
+ )
445
+
446
+ async def delete_event(self, event_id: str) -> ToolResult:
447
+ """Delete a calendar event by ID.
448
+
449
+ Args:
450
+ event_id: UUID of the event to delete.
451
+
452
+ Returns:
453
+ ToolResult indicating success or failure.
454
+ """
455
+ try:
456
+ if event_id not in self.events:
457
+ return ToolResult(
458
+ success=False,
459
+ output=None,
460
+ error=f"Event with ID '{event_id}' not found",
461
+ )
462
+
463
+ event = self.events.pop(event_id)
464
+
465
+ # Persist to ICS if path is configured
466
+ if self.ics_file_path:
467
+ await self._save_to_ics_async()
468
+
469
+ return ToolResult(
470
+ success=True,
471
+ output={
472
+ "message": f"Event '{event.title}' deleted successfully",
473
+ "deleted_event": event.to_dict(),
474
+ },
475
+ error=None,
476
+ )
477
+
478
+ except Exception as exc:
479
+ return ToolResult(
480
+ success=False,
481
+ output=None,
482
+ error=f"Error deleting event: {str(exc)}",
483
+ )
484
+
485
+ async def export_ics(self, path: Optional[str] = None) -> ToolResult:
486
+ """Export all events to ICS file.
487
+
488
+ Args:
489
+ path: Output file path. Uses configured ics_file_path if not specified.
490
+
491
+ Returns:
492
+ ToolResult with export status and file path.
493
+ """
494
+ try:
495
+ output_path = Path(path) if path else self.ics_file_path
496
+
497
+ if not output_path:
498
+ return ToolResult(
499
+ success=False,
500
+ output=None,
501
+ error="No output path specified. Provide path parameter or configure ics_file_path.",
502
+ )
503
+
504
+ # Ensure parent directory exists
505
+ output_path.parent.mkdir(parents=True, exist_ok=True)
506
+
507
+ # Build ICS content
508
+ ics_lines = [
509
+ "BEGIN:VCALENDAR",
510
+ "VERSION:2.0",
511
+ "PRODID:-//LollmsBot//Calendar Tool//EN",
512
+ "CALSCALE:GREGORIAN",
513
+ "METHOD:PUBLISH",
514
+ ]
515
+
516
+ for event in sorted(self.events.values(), key=lambda e: e.start):
517
+ ics_lines.append(event.to_ics())
518
+
519
+ ics_lines.append("END:VCALENDAR")
520
+
521
+ # Write file
522
+ ics_content = "\r\n".join(ics_lines)
523
+
524
+ loop = asyncio.get_event_loop()
525
+ await loop.run_in_executor(
526
+ None,
527
+ lambda: output_path.write_text(ics_content, encoding="utf-8"),
528
+ )
529
+
530
+ return ToolResult(
531
+ success=True,
532
+ output={
533
+ "message": f"Exported {len(self.events)} events to {output_path}",
534
+ "path": str(output_path),
535
+ "event_count": len(self.events),
536
+ },
537
+ error=None,
538
+ )
539
+
540
+ except Exception as exc:
541
+ return ToolResult(
542
+ success=False,
543
+ output=None,
544
+ error=f"Error exporting ICS file: {str(exc)}",
545
+ )
546
+
547
+ async def import_ics(self, path: Optional[str] = None) -> ToolResult:
548
+ """Import events from ICS file.
549
+
550
+ Args:
551
+ path: Input file path. Uses configured ics_file_path if not specified.
552
+
553
+ Returns:
554
+ ToolResult with import status and count of imported events.
555
+ """
556
+ try:
557
+ input_path = Path(path) if path else self.ics_file_path
558
+
559
+ if not input_path:
560
+ return ToolResult(
561
+ success=False,
562
+ output=None,
563
+ error="No input path specified. Provide path parameter or configure ics_file_path.",
564
+ )
565
+
566
+ if not input_path.exists():
567
+ return ToolResult(
568
+ success=False,
569
+ output=None,
570
+ error=f"ICS file not found: {input_path}",
571
+ )
572
+
573
+ # Read file
574
+ loop = asyncio.get_event_loop()
575
+ ics_content = await loop.run_in_executor(
576
+ None,
577
+ lambda: input_path.read_text(encoding="utf-8"),
578
+ )
579
+
580
+ # Parse VEVENT blocks
581
+ imported_count = 0
582
+ in_event = False
583
+ event_lines: List[str] = []
584
+
585
+ for line in ics_content.replace("\r\n", "\n").split("\n"):
586
+ if line.startswith("BEGIN:VEVENT"):
587
+ in_event = True
588
+ event_lines = [line]
589
+ elif line.startswith("END:VEVENT"):
590
+ in_event = False
591
+ event_lines.append(line)
592
+ try:
593
+ event = Event.from_ics("\n".join(event_lines))
594
+ self.events[event.id] = event
595
+ imported_count += 1
596
+ except Exception:
597
+ # Skip malformed events
598
+ pass
599
+ elif in_event:
600
+ event_lines.append(line)
601
+
602
+ return ToolResult(
603
+ success=True,
604
+ output={
605
+ "message": f"Imported {imported_count} events from {input_path}",
606
+ "path": str(input_path),
607
+ "imported_count": imported_count,
608
+ "total_events": len(self.events),
609
+ },
610
+ error=None,
611
+ )
612
+
613
+ except Exception as exc:
614
+ return ToolResult(
615
+ success=False,
616
+ output=None,
617
+ error=f"Error importing ICS file: {str(exc)}",
618
+ )
619
+
620
+ async def _save_to_ics_async(self) -> None:
621
+ """Persist events to ICS file asynchronously."""
622
+ if self.ics_file_path:
623
+ await self.export_ics(str(self.ics_file_path))
624
+
625
+ async def _load_from_ics_async(self) -> None:
626
+ """Load events from ICS file asynchronously."""
627
+ if self.ics_file_path and self.ics_file_path.exists():
628
+ await self.import_ics(str(self.ics_file_path))
629
+
630
+ async def execute(self, **params: Any) -> ToolResult:
631
+ """Execute a calendar operation based on parameters.
632
+
633
+ This is the main entry point for the Tool base class. It dispatches
634
+ to the appropriate method based on the 'operation' parameter.
635
+
636
+ Args:
637
+ **params: Parameters must include:
638
+ - operation: One of 'get_events', 'add_event', 'delete_event',
639
+ 'export_ics', 'import_ics'
640
+ - Additional parameters depend on the operation
641
+
642
+ Returns:
643
+ ToolResult from the executed operation.
644
+
645
+ Raises:
646
+ ToolError: If the operation is unknown or parameters are invalid.
647
+ """
648
+ operation = params.get("operation")
649
+
650
+ if not operation:
651
+ return ToolResult(
652
+ success=False,
653
+ output=None,
654
+ error="Missing required parameter: 'operation'",
655
+ )
656
+
657
+ # Dispatch to appropriate method
658
+ if operation == "get_events":
659
+ return await self.get_events(
660
+ start=params.get("start"),
661
+ end=params.get("end"),
662
+ )
663
+
664
+ elif operation == "add_event":
665
+ title = params.get("title")
666
+ start = params.get("start")
667
+ end = params.get("end")
668
+
669
+ if not title:
670
+ return ToolResult(
671
+ success=False,
672
+ output=None,
673
+ error="Missing required parameter: 'title'",
674
+ )
675
+ if not start:
676
+ return ToolResult(
677
+ success=False,
678
+ output=None,
679
+ error="Missing required parameter: 'start'",
680
+ )
681
+ if not end:
682
+ return ToolResult(
683
+ success=False,
684
+ output=None,
685
+ error="Missing required parameter: 'end'",
686
+ )
687
+
688
+ return await self.add_event(
689
+ title=title,
690
+ start=start,
691
+ end=end,
692
+ description=params.get("description", ""),
693
+ )
694
+
695
+ elif operation == "delete_event":
696
+ event_id = params.get("event_id")
697
+ if not event_id:
698
+ return ToolResult(
699
+ success=False,
700
+ output=None,
701
+ error="Missing required parameter: 'event_id'",
702
+ )
703
+ return await self.delete_event(event_id)
704
+
705
+ elif operation == "export_ics":
706
+ return await self.export_ics(params.get("ics_path"))
707
+
708
+ elif operation == "import_ics":
709
+ return await self.import_ics(params.get("ics_path"))
710
+
711
+ else:
712
+ return ToolResult(
713
+ success=False,
714
+ output=None,
715
+ error=f"Unknown operation: '{operation}'. "
716
+ f"Valid operations are: get_events, add_event, delete_event, export_ics, import_ics",
717
+ )