This commit is contained in:
2026-04-05 00:43:23 +05:30
commit 8be37d3e92
425 changed files with 101853 additions and 0 deletions

View 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