-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathgoogle_calendar.py
More file actions
258 lines (226 loc) · 11.2 KB
/
google_calendar.py
File metadata and controls
258 lines (226 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
"""Google Calendar — Check and create calendar events"""
SKILL_NAME = "google_calendar"
SKILL_TRIGGERS = [
# create — long/specific triggers first so they match before short ones
"put to my calendar", "put in my calendar", "put on my calendar",
"put it in my calendar", "put it on my calendar", "put this in my calendar",
"put an appointment", "put a meeting",
"add to my calendar", "add to calendar", "add an event", "add event",
"add appointment", "add a meeting", "add a reminder", "add it to my calendar",
"create event", "create a meeting", "schedule a meeting", "schedule meeting",
"schedule an appointment", "book a meeting", "book appointment",
"set a reminder", "set an appointment", "new event", "new appointment",
"remind me",
# read
"what's on my calendar", "what is on my calendar", "my schedule", "my events",
"meetings today", "what do i have today", "what do i have tomorrow", "am i free",
"next meeting", "check my calendar", "show my calendar", "calendar",
]
SKILL_DESCRIPTION = "Check and create Google Calendar events by voice"
import os, re, datetime, json
TOKEN_PATH = os.path.expanduser("~/.codec/google_token.json")
# ── Create intent detection ────────────────────────────────────────────────────
# Instead of exact phrases (brittle), detect VERB + NOUN intent.
# Any create-verb near a calendar-noun = create intent.
_CREATE_VERBS = ["create", "add", "put", "set", "make", "book", "schedule",
"insert", "register", "log", "record", "remind", "new"]
_CALENDAR_NOUNS = ["calendar", "event", "appointment", "meeting", "reminder",
"booking", "slot", "session"]
def _is_create_intent(low: str) -> bool:
has_verb = any(v in low for v in _CREATE_VERBS)
has_noun = any(n in low for n in _CALENDAR_NOUNS)
return has_verb and has_noun
# Keep CREATE_WORDS for _parse_title stripping (remove filler from title)
CREATE_WORDS = _CREATE_VERBS + _CALENDAR_NOUNS
def _get_service():
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
creds = Credentials.from_authorized_user_file(TOKEN_PATH)
if creds.expired and creds.refresh_token:
creds.refresh(Request())
with open(TOKEN_PATH, "w") as f:
f.write(creds.to_json())
return build("calendar", "v3", credentials=creds)
# ── Date/time parsing ──────────────────────────────────────────────────────────
def _parse_datetime(text: str):
"""
Extract a (start_dt, end_dt) pair from natural language.
Returns datetime objects in local time (naive).
"""
low = text.lower()
now = datetime.datetime.now()
today = now.date()
# ── Day ──
if "tomorrow" in low:
target_date = today + datetime.timedelta(days=1)
elif "today" in low:
target_date = today
else:
# Day-of-week
days = ["monday","tuesday","wednesday","thursday","friday","saturday","sunday"]
target_date = None
for i, d in enumerate(days):
if d in low:
current_wd = today.weekday()
delta = (i - current_wd) % 7
if delta == 0:
delta = 7 # next occurrence
target_date = today + datetime.timedelta(days=delta)
break
if target_date is None:
# Try DD/MM or month name
m = re.search(r'\b(\d{1,2})[/\-](\d{1,2})\b', low)
if m:
try:
target_date = datetime.date(now.year, int(m.group(2)), int(m.group(1)))
except ValueError:
pass
if target_date is None:
target_date = today + datetime.timedelta(days=1) # default tomorrow
# ── Time ──
hour, minute = 12, 0 # default noon
# Normalise a.m./p.m. → am/pm and "half past X" → for simpler regex below
low = re.sub(r'a\.m\.', 'am', low)
low = re.sub(r'p\.m\.', 'pm', low)
# Priority 1 — "HH:MM" or "HH.MM" e.g. "10:30", "10.30"
m = re.search(r'\b(\d{1,2})[:\.](\d{2})\s*(am|pm)?\b', low)
if m:
hour, minute = int(m.group(1)), int(m.group(2))
suffix = (m.group(3) or "").strip()
if suffix == "pm" and hour < 12:
hour += 12
elif suffix == "am" and hour == 12:
hour = 0
# Priority 2 — "HH MM am/pm" e.g. "10 30 am" (space-separated, needs am/pm anchor)
elif re.search(r'\b(\d{1,2})\s+(\d{2})\s*(am|pm)\b', low):
m = re.search(r'\b(\d{1,2})\s+(\d{2})\s*(am|pm)\b', low)
hour, minute = int(m.group(1)), int(m.group(2))
suffix = m.group(3)
if suffix == "pm" and hour < 12:
hour += 12
elif suffix == "am" and hour == 12:
hour = 0
# Priority 3 — "HH am/pm" or "HH o'clock" e.g. "10 am", "3pm"
elif re.search(r'\b(\d{1,2})\s*(am|pm|o\'?clock|oclock)\b', low):
m = re.search(r'\b(\d{1,2})\s*(am|pm|o\'?clock|oclock)\b', low)
hour = int(m.group(1))
# Only accept plausible hours (0-12 for am/pm, 0-23 for military)
if hour > 23:
hour = 12
suffix = m.group(2)
if suffix == "pm" and hour < 12:
hour += 12
elif suffix == "am" and hour == 12:
hour = 0
elif "clock" in suffix and 1 <= hour <= 7:
hour += 12 # 1-7 o'clock → afternoon default
# Priority 4 — natural words
else:
if "noon" in low:
hour, minute = 12, 0
elif "midnight" in low:
hour, minute = 0, 0
elif "morning" in low:
hour, minute = 9, 0
elif "afternoon" in low:
hour, minute = 14, 0
elif "evening" in low or "night" in low:
hour, minute = 19, 0
start_dt = datetime.datetime.combine(target_date, datetime.time(hour, minute))
end_dt = start_dt + datetime.timedelta(hours=1)
return start_dt, end_dt
def _parse_title(text: str) -> str:
"""
Extract event title by stripping trigger phrases, time/date words, and filler.
"""
low = text.lower()
# Remove action filler (verbs + connecting words, keep nouns as they may be the title)
for phrase in ["can you please", "can you", "could you", "please", "i want you to",
"i need you to", "i would like", "would you", "hey q",
"create an event", "create event", "add an event", "add event",
"add to my calendar", "add to calendar", "put to my calendar",
"put in my calendar", "put on my calendar", "put inside my calendar",
"put it in my calendar", "schedule a meeting", "schedule meeting",
"book a meeting", "book appointment", "set a reminder",
"set an appointment", "new event", "new appointment",
"on my calendar", "in my calendar", "to my calendar",
"my calendar", "to the calendar", "the calendar"]:
low = low.replace(phrase, " ")
# Normalise a.m./p.m.
low = re.sub(r'a\.m\.', 'am', low)
low = re.sub(r'p\.m\.', 'pm', low)
# Remove date/time patterns — order matters (longest first)
low = re.sub(r'\b\d{1,2}[:\.]?\d{2}\s*(am|pm)?\b', '', low) # "10:30 am", "10.30", "1030"
low = re.sub(r'\b\d{1,2}\s+\d{2}\s*(am|pm)\b', '', low) # "10 30 am"
low = re.sub(r'\b\d{1,2}\s*(am|pm|h|o\'?clock|oclock)\b', '', low) # "3pm", "10 am"
low = re.sub(r'\b\d{1,2}[/\-]\d{1,2}\b', '', low) # "29/03"
low = re.sub(r'\b\d+\b', '', low) # any remaining stray digits
low = re.sub(r'\b(tomorrow|today|tonight|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b', '', low)
low = re.sub(r'\b(noon|midnight|morning|afternoon|evening|night|at|on|for|the|a|an|please|can you|could you|i mean|yeah|okay|right)\b', '', low)
low = re.sub(r'[.\-]', ' ', low) # leftover punctuation
low = re.sub(r'\s+', ' ', low).strip()
# Capitalise nicely
title = low.title() if low else "Event"
return title or "Event"
# ── Main run ───────────────────────────────────────────────────────────────────
def run(task, app="", ctx=""):
try:
service = _get_service()
low = task.lower()
# ── CREATE path ──
if _is_create_intent(low):
start_dt, end_dt = _parse_datetime(task)
title = _parse_title(task)
# Determine timezone offset (local)
tz_offset = datetime.datetime.now(datetime.timezone.utc).astimezone().strftime("%z")
tz_str = tz_offset[:3] + ":" + tz_offset[3:] # "+01:00"
event_body = {
"summary": title,
"start": {"dateTime": start_dt.strftime("%Y-%m-%dT%H:%M:00") + tz_str},
"end": {"dateTime": end_dt.strftime("%Y-%m-%dT%H:%M:00") + tz_str},
}
created = service.events().insert(
calendarId="primary", body=event_body
).execute()
time_str = start_dt.strftime("%-d %B at %-I:%M %p").replace(" 0", " ").strip()
return (
f"Done. '{created.get('summary', title)}' added to your Google Calendar "
f"for {time_str}."
)
# ── READ path ──
now = datetime.datetime.utcnow()
if "tomorrow" in low:
start = (now + datetime.timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
end = start + datetime.timedelta(days=1)
label = "Tomorrow"
elif "this week" in low or "week" in low:
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + datetime.timedelta(days=7)
label = "This week"
else:
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + datetime.timedelta(days=1)
label = "Today"
events = service.events().list(
calendarId="primary",
timeMin=start.isoformat() + "Z",
timeMax=end.isoformat() + "Z",
maxResults=15,
singleEvents=True,
orderBy="startTime",
).execute().get("items", [])
if not events:
return f"No events {label.lower()}. Your calendar is clear."
lines = [f"{label}'s schedule — {len(events)} event(s):"]
for e in events:
s = e["start"].get("dateTime", e["start"].get("date", ""))
if "T" in s:
t = datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
time_str = t.strftime("%-I:%M %p")
else:
time_str = "All day"
lines.append(f" {time_str} — {e.get('summary', 'No title')}")
return "\n".join(lines)
except Exception as e:
return f"Calendar error: {e}"