AI Meeting Scheduler Bot

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: