mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 20:51:49 +00:00
init
This commit is contained in:
279
negot8/backend/tools/google_calendar.py
Normal file
279
negot8/backend/tools/google_calendar.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Google Calendar Tool for negoT8.
|
||||
|
||||
Allows the scheduling negotiation agent to query a user's real Google Calendar
|
||||
when they haven't specified available times in their message.
|
||||
|
||||
Flow:
|
||||
1. User runs /connectcalendar in Telegram.
|
||||
2. Bot sends the OAuth URL (get_oauth_url).
|
||||
3. User authorises in browser → Google redirects to /api/auth/google/callback.
|
||||
4. exchange_code() stores the token in the DB.
|
||||
5. get_free_slots() is called automatically by SchedulingFeature.get_context()
|
||||
whenever a scheduling negotiation starts without explicit times.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import database as db
|
||||
from config import (
|
||||
GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET,
|
||||
GOOGLE_REDIRECT_URI,
|
||||
GOOGLE_CALENDAR_SCOPES,
|
||||
)
|
||||
|
||||
# ── Module-level PKCE verifier store ──────────────────────────────────────
|
||||
# Maps telegram_id → code_verifier string generated during get_oauth_url().
|
||||
# Entries are cleaned up after a successful or failed exchange.
|
||||
_pending_verifiers: dict[int, str] = {}
|
||||
|
||||
|
||||
def _build_flow():
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
return Flow.from_client_config(
|
||||
{
|
||||
"web": {
|
||||
"client_id": GOOGLE_CLIENT_ID,
|
||||
"client_secret": GOOGLE_CLIENT_SECRET,
|
||||
"redirect_uris": [GOOGLE_REDIRECT_URI],
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
}
|
||||
},
|
||||
scopes=GOOGLE_CALENDAR_SCOPES,
|
||||
redirect_uri=GOOGLE_REDIRECT_URI,
|
||||
)
|
||||
|
||||
|
||||
class GoogleCalendarTool:
|
||||
name = "google_calendar"
|
||||
|
||||
# ── OAuth helpers ────────────────────────────────────────────────────────
|
||||
|
||||
async def is_connected(self, user_id: int) -> bool:
|
||||
"""Check whether the user has a stored OAuth token."""
|
||||
token_json = await db.get_calendar_token(user_id)
|
||||
return token_json is not None
|
||||
|
||||
async def get_oauth_url(self, user_id: int) -> str:
|
||||
"""
|
||||
Build the Google OAuth2 authorisation URL manually — no PKCE.
|
||||
Building it by hand avoids the library silently injecting a
|
||||
code_challenge that we can't recover during token exchange.
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
params = {
|
||||
"client_id": GOOGLE_CLIENT_ID,
|
||||
"redirect_uri": GOOGLE_REDIRECT_URI,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(GOOGLE_CALENDAR_SCOPES),
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"state": str(user_id),
|
||||
}
|
||||
return "https://accounts.google.com/o/oauth2/auth?" + urlencode(params)
|
||||
|
||||
async def exchange_code(self, user_id: int, code: str) -> bool:
|
||||
"""
|
||||
Exchange the OAuth `code` (received in the callback) for credentials
|
||||
and persist them in the DB. Returns True on success.
|
||||
|
||||
Uses the stored PKCE code_verifier (if any) so Google doesn't reject
|
||||
the exchange with 'Missing code verifier'.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# ── Manual token exchange — avoids PKCE state mismatch ──────────
|
||||
# We post directly to Google's token endpoint so we're not
|
||||
# dependent on a Flow instance having the right code_verifier.
|
||||
verifier = _pending_verifiers.pop(user_id, None)
|
||||
|
||||
post_data: dict = {
|
||||
"code": code,
|
||||
"client_id": GOOGLE_CLIENT_ID,
|
||||
"client_secret": GOOGLE_CLIENT_SECRET,
|
||||
"redirect_uri": GOOGLE_REDIRECT_URI,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
if verifier:
|
||||
post_data["code_verifier"] = verifier
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data=post_data,
|
||||
timeout=15.0,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
print(f"[GoogleCalendar] token exchange HTTP {resp.status_code}: {resp.text}")
|
||||
return False
|
||||
|
||||
token_data = resp.json()
|
||||
|
||||
# Build a Credentials-compatible JSON that google-auth can reload
|
||||
import datetime as _dt
|
||||
expires_in = token_data.get("expires_in", 3600)
|
||||
expiry = (
|
||||
_dt.datetime.utcnow() + _dt.timedelta(seconds=expires_in)
|
||||
).isoformat() + "Z"
|
||||
|
||||
creds_json = json.dumps({
|
||||
"token": token_data["access_token"],
|
||||
"refresh_token": token_data.get("refresh_token"),
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": GOOGLE_CLIENT_ID,
|
||||
"client_secret": GOOGLE_CLIENT_SECRET,
|
||||
"scopes": GOOGLE_CALENDAR_SCOPES,
|
||||
"expiry": expiry,
|
||||
})
|
||||
|
||||
await db.save_calendar_token(user_id, creds_json)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_pending_verifiers.pop(user_id, None) # clean up on failure
|
||||
print(f"[GoogleCalendar] exchange_code failed for user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
# ── Main tool: free slot discovery ──────────────────────────────────────
|
||||
|
||||
async def get_free_slots(
|
||||
self,
|
||||
user_id: int,
|
||||
days_ahead: int = 7,
|
||||
duration_minutes: int = 60,
|
||||
timezone_str: str = "Asia/Kolkata",
|
||||
) -> list[str]:
|
||||
"""
|
||||
Return up to 6 free time slots for the user over the next `days_ahead`
|
||||
days, each at least `duration_minutes` long.
|
||||
|
||||
Slots are within business hours (9 AM – 7 PM local time) and exclude
|
||||
any existing calendar events (busy intervals from the freeBusy API).
|
||||
|
||||
Returns [] if the user hasn't connected their calendar, or on any
|
||||
API / network error — never raises so negotiations always continue.
|
||||
"""
|
||||
token_json = await db.get_calendar_token(user_id)
|
||||
if not token_json:
|
||||
return [] # user hasn't connected calendar
|
||||
|
||||
try:
|
||||
# Run the synchronous Google API calls in a thread pool so we
|
||||
# don't block the async event loop.
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
self._sync_get_free_slots,
|
||||
token_json,
|
||||
user_id,
|
||||
days_ahead,
|
||||
duration_minutes,
|
||||
timezone_str,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[GoogleCalendar] get_free_slots failed for user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
def _sync_get_free_slots(
|
||||
self,
|
||||
token_json: str,
|
||||
user_id: int,
|
||||
days_ahead: int,
|
||||
duration_minutes: int,
|
||||
timezone_str: str,
|
||||
) -> list[str]:
|
||||
"""Synchronous implementation (runs in executor)."""
|
||||
import asyncio
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
# ── Load & refresh credentials ───────────────────────────────────
|
||||
creds = Credentials.from_authorized_user_info(
|
||||
json.loads(token_json), GOOGLE_CALENDAR_SCOPES
|
||||
)
|
||||
if creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
# Persist refreshed token (fire-and-forget via a new event loop)
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(
|
||||
db.save_calendar_token(user_id, creds.to_json())
|
||||
)
|
||||
loop.close()
|
||||
except Exception:
|
||||
pass # non-critical
|
||||
|
||||
service = build("calendar", "v3", credentials=creds, cache_discovery=False)
|
||||
|
||||
tz = ZoneInfo(timezone_str)
|
||||
now = datetime.now(tz)
|
||||
# Start from the next whole hour
|
||||
query_start = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
||||
query_end = query_start + timedelta(days=days_ahead)
|
||||
|
||||
time_min = query_start.astimezone(timezone.utc).isoformat()
|
||||
time_max = query_end.astimezone(timezone.utc).isoformat()
|
||||
|
||||
# ── freeBusy query ───────────────────────────────────────────────
|
||||
body = {
|
||||
"timeMin": time_min,
|
||||
"timeMax": time_max,
|
||||
"timeZone": timezone_str,
|
||||
"items": [{"id": "primary"}],
|
||||
}
|
||||
result = service.freebusy().query(body=body).execute()
|
||||
busy_intervals = result.get("calendars", {}).get("primary", {}).get("busy", [])
|
||||
|
||||
# Convert busy intervals to (start, end) datetime pairs
|
||||
busy = []
|
||||
for interval in busy_intervals:
|
||||
b_start = datetime.fromisoformat(interval["start"]).astimezone(tz)
|
||||
b_end = datetime.fromisoformat(interval["end"]).astimezone(tz)
|
||||
busy.append((b_start, b_end))
|
||||
|
||||
# ── Find free slots in 9 AM – 7 PM business hours ───────────────
|
||||
slot_duration = timedelta(minutes=duration_minutes)
|
||||
free_slots: list[str] = []
|
||||
|
||||
cursor = query_start
|
||||
while cursor < query_end and len(free_slots) < 6:
|
||||
# Jump to business hours start if before 9 AM
|
||||
day_start = cursor.replace(hour=9, minute=0, second=0, microsecond=0)
|
||||
day_end = cursor.replace(hour=19, minute=0, second=0, microsecond=0)
|
||||
|
||||
if cursor < day_start:
|
||||
cursor = day_start
|
||||
if cursor >= day_end:
|
||||
# Move to next day 9 AM
|
||||
cursor = (cursor + timedelta(days=1)).replace(
|
||||
hour=9, minute=0, second=0, microsecond=0
|
||||
)
|
||||
continue
|
||||
|
||||
slot_end = cursor + slot_duration
|
||||
if slot_end > day_end:
|
||||
cursor = (cursor + timedelta(days=1)).replace(
|
||||
hour=9, minute=0, second=0, microsecond=0
|
||||
)
|
||||
continue
|
||||
|
||||
# Check for conflict with any busy interval
|
||||
conflict = any(
|
||||
not (slot_end <= b[0] or cursor >= b[1]) for b in busy
|
||||
)
|
||||
if not conflict:
|
||||
label = cursor.strftime("%a %b %-d %-I:%M") + "–" + slot_end.strftime("%-I:%M %p")
|
||||
free_slots.append(label)
|
||||
cursor = slot_end # advance by one slot (non-overlapping)
|
||||
else:
|
||||
cursor += timedelta(minutes=30) # try next 30-min block
|
||||
|
||||
return free_slots
|
||||
Reference in New Issue
Block a user