import os
import json
from datetime import datetime, timedelta, time as dtime
from dateutil import parser as dateparse
import pytz
import re
from flask import Flask, redirect, url_for, session, request, render_template_string, flash
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build
import nltk
nltk.download("punkt") # ensure tokens are available
# ---------- Config ----------
CLIENT_SECRETS_FILE = "credentials.json" # downloaded from Google Cloud
SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
TOKEN_FOLDER = "tokens"
if not os.path.exists(TOKEN_FOLDER):
os.makedirs(TOKEN_FOLDER)
# Flask config
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET", "dev-secret") # change in prod
# Make sure redirect URI in Cloud Console matches this
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" # only for local dev
# ---------- Helpers ----------
def token_path_for_email(email):
safe = email.replace("@", "_at_").replace(".", "_dot_")
return os.path.join(TOKEN_FOLDER, f"token_{safe}.json")
def save_credentials(creds: Credentials, email: str):
p = token_path_for_email(email)
with open(p, "w") as f:
f.write(creds.to_json())
def load_credentials(email: str):
p = token_path_for_email(email)
if not os.path.exists(p):
return None
with open(p, "r") as f:
data = json.load(f)
return Credentials.from_authorized_user_info(data, SCOPES)
def create_flow(state=None):
return Flow.from_client_secrets_file(
CLIENT_SECRETS_FILE,
scopes=SCOPES,
redirect_uri=url_for("oauth2callback", _external=True)
)
# ---------- NLP for simple preference parsing ----------
def parse_natural_preferences(text):
"""
Very lightweight preference extraction:
- looks for 'morning', 'afternoon', 'evening', 'tomorrow', 'next week', 'this week'
- returns bias window (start_hour, end_hour) and date-range hints
"""
text = text.lower()
prefs = {"hours": None, "date_hint": None}
if re.search(r"\bmorn(ing)?\b", text):
prefs["hours"] = (8, 12)
elif re.search(r"\bafternoon\b", text):
prefs["hours"] = (13, 17)
elif re.search(r"\bevening\b", text):
prefs["hours"] = (17, 21)
# dates
if "tomorrow" in text:
prefs["date_hint"] = ("tomorrow", 1)
elif "next week" in text:
prefs["date_hint"] = ("next_week", 7)
elif "this week" in text:
prefs["date_hint"] = ("this_week", 0)
# specific dates (try parsing)
found_dates = re.findall(r"\b(?:on\s)?([A-Za-z]{3,9}\s+\d{1,2}(?:st|nd|rd|th)?(?:,\s*\d{4})?)\b", text)
if found_dates:
# take first parseable date
try:
d = dateparse.parse(found_dates[0])
prefs["explicit_date"] = d.date().isoformat()
except Exception:
pass
return prefs
# ---------- Availability logic ----------
def query_freebusy(service, calendar_ids, start_dt, end_dt, timezone="UTC"):
body = {
"timeMin": start_dt.isoformat(),
"timeMax": end_dt.isoformat(),
"items": [{"id": cid} for cid in calendar_ids]
}
resp = service.freebusy().query(body=body).execute()
busy = {}
for cal_id, cal_data in resp["calendars"].items():
busy[cal_id] = cal_data.get("busy", [])
return busy
def invert_busy_to_free(busy_intervals, start_dt, end_dt, min_slot_minutes=30):
"""
Given busy intervals (list of {"start": iso, "end": iso"}), return free intervals between start_dt and end_dt.
"""
tz = pytz.UTC
# Merge busy and compute free windows
intervals = []
for b in busy_intervals:
s = dateparse.parse(b["start"]).astimezone(tz)
e = dateparse.parse(b["end"]).astimezone(tz)
intervals.append((s, e))
# sort and merge overlaps
intervals.sort()
merged = []
for s,e in intervals:
if not merged:
merged.append([s,e])
else:
if s <= merged[-1][1]:
if e > merged[-1][1]:
merged[-1][1] = e
else:
merged.append([s,e])
free_windows = []
cur = start_dt
for s,e in merged:
if s > cur:
if (s - cur).total_seconds() / 60 >= min_slot_minutes:
free_windows.append((cur, s))
if e > cur:
cur = e
if end_dt > cur:
if (end_dt - cur).total_seconds() / 60 >= min_slot_minutes:
free_windows.append((cur, end_dt))
return free_windows
def intersect_free_lists(list_of_free_lists, meeting_duration_minutes=30):
"""
Each free list is a list of (start, end) windows. We want intersections across calendars and then break into slots of meeting_duration_minutes.
Very simple sweep approach.
"""
# flatten all interval endpoints with tags
events = []
for free_list in list_of_free_lists:
for s,e in free_list:
events.append((s, 1))
events.append((e, -1))
# sort by time
events.sort()
needed = len(list_of_free_lists)
cur_count = 0
last_time = None
intersections = []
for t, delta in events:
prev = cur_count
cur_count += delta
if prev < needed and cur_count == needed:
# interval started
last_time = t
elif prev == needed and cur_count < needed and last_time is not None:
# interval ended at t
intersections.append((last_time, t))
last_time = None
# Break intersections into meeting_duration-sized slots
slots = []
for s,e in intersections:
start = s
while start + timedelta(minutes=meeting_duration_minutes) <= e:
slot_end = start + timedelta(minutes=meeting_duration_minutes)
slots.append((start, slot_end))
start = start + timedelta(minutes=meeting_duration_minutes) # non-overlapping contiguous slots
return slots
# ---------- Flask routes ----------
INDEX_HTML = """
<!doctype html>
<title>AI Meeting Scheduler Bot</title>
<h2>AI Meeting Scheduler Bot — Demo</h2>
<p>1) Authorize yourself (and any other calendar accounts you own) via Google OAuth.</p>
<p>2) Add participant calendar emails (must have given access or be your own authorized accounts).</p>
<form action="/suggest" method="post">
<label>Participant emails (comma separated):</label><br>
<input type="text" name="emails" size="60"><br><br>
<label>Meeting duration (minutes):</label>
<input type="number" name="duration" value="30"><br><br>
<label>Search days ahead (default 7):</label>
<input type="number" name="days" value="7"><br><br>
<label>Optional email/preference text (paste):</label><br>
<textarea name="pref" rows="4" cols="80"></textarea><br><br>
<button type="submit">Suggest slots</button>
</form>
<hr>
<p>To authorize a calendar, go to <a href="/authorize">/authorize</a>, sign in and allow calendar access. The app will remember your token locally.</p>
"""
@app.route("/")
def index():
return render_template_string(INDEX_HTML)
@app.route("/authorize")
def authorize():
# start OAuth flow - will ask user for email after consent
flow = create_flow()
auth_url, state = flow.authorization_url(prompt="consent", access_type="offline", include_granted_scopes="true")
session["flow_state"] = state
return redirect(auth_url)
@app.route("/oauth2callback")
def oauth2callback():
state = session.get("flow_state", None)
flow = create_flow(state=state)
flow.fetch_token(authorization_response=request.url)
creds = flow.credentials
# get email of the authenticated user via token info
service = build("oauth2", "v2", credentials=creds)
try:
info = service.userinfo().get().execute()
email = info.get("email")
except Exception:
# fallback: ask user to input an identifier; but for demo we assume success
email = creds.token_uri or "unknown"
# save credentials
save_credentials(creds, email)
return f"Authorized for {email}. You can now close this tab and return to the app (Home)."
@app.route("/suggest", methods=["POST"])
def suggest():
emails_raw = request.form.get("emails", "")
duration = int(request.form.get("duration", "30"))
days = int(request.form.get("days", "7"))
pref_text = request.form.get("pref", "")
# parse emails
emails = [e.strip() for e in emails_raw.split(",") if e.strip()]
if not emails:
return "Please provide at least one participant email (your authorized account or someone who shared calendar)."
# load credentials for each email (must have tokens saved)
creds_for = {}
for e in emails:
creds = load_credentials(e)
if creds is None:
return f"No token found for {e}. Please authorize that account (visit /authorize and sign in with that email)."
creds_for[e] = creds
# timezone & date range (use UTC for simplicity, better: detect user's tz)
tz = pytz.UTC
now = datetime.now(tz)
start_dt = now + timedelta(hours=1) # start searching from +1 hour
end_dt = now + timedelta(days=days)
# parse preferences
prefs = parse_natural_preferences(pref_text)
# adjust hours if prefs provided
if prefs.get("hours"):
pref_start_hour, pref_end_hour = prefs["hours"]
else:
pref_start_hour, pref_end_hour = 9, 17 # default business hours
# Build calendar service for freebusy queries: we can reuse the first user's creds to call freebusy for multiple calendars
# But Google freebusy requires a service with credentials that have access to calendars queried.
# We'll use each user's own service to fetch busy; however freebusy can accept many items in single query if the caller has access.
# For demo: call freebusy per account, query that account's own calendar id primary.
list_free_lists = []
for e, creds in creds_for.items():
service = build("calendar", "v3", credentials=creds)
# use 'primary' for that account
cal_id = "primary"
# Query busy for that calendar
body = {
"timeMin": start_dt.isoformat(),
"timeMax": end_dt.isoformat(),
"items": [{"id": cal_id}]
}
resp = service.freebusy().query(body=body).execute()
busy = resp["calendars"][cal_id].get("busy", [])
free = invert_busy_to_free(busy, start_dt, end_dt, min_slot_minutes=duration)
# apply daily hours restriction: cut free windows to business hours or prefs
filtered_free = []
for s,e in free:
# slice s..e into days and keep only time within pref hours
ptr = s
while ptr < e:
day_end = (ptr.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1))
seg_end = min(e, day_end)
# define allowed window for this day in tz
allowed_start = ptr.replace(hour=pref_start_hour, minute=0, second=0, microsecond=0)
allowed_end = ptr.replace(hour=pref_end_hour, minute=0, second=0, microsecond=0)
# clamp allowed_start to ptr/day start/...
window_start = max(ptr, allowed_start)
window_end = min(seg_end, allowed_end)
if window_end > window_start and (window_end - window_start).total_seconds()/60 >= duration:
filtered_free.append((window_start, window_end))
ptr = seg_end
list_free_lists.append(filtered_free)
# intersect free lists
candidate_slots = intersect_free_lists(list_free_lists, meeting_duration_minutes=duration)
# Format result: show top 20 slots
candidate_slots = sorted(candidate_slots)[:20]
# Render simple HTML response
out = "<h2>Suggested Meeting Slots (UTC)</h2><ol>"
for s,e in candidate_slots:
out += f"<li>{s.isoformat()} → {e.isoformat()}</li>"
out += "</ol>"
if not candidate_slots:
out += "<p><b>No common slots found in that range & preferences. Try increasing days or changing hours.</b></p>"
out += '<p><a href="/">Back</a></p>'
return out
if __name__ == "__main__":
app.run(debug=True)
No comments:
Post a Comment