Initial commit
commit
a14b02ad3c
@ -0,0 +1,6 @@
|
|||||||
|
GOOGLE_CLIENT_SECRETS_FILE=credentials.json
|
||||||
|
GOOGLE_TOKEN_FILE=token.json
|
||||||
|
AGENT_API_KEY=change-me
|
||||||
|
GMAIL_SCAN_INTERVAL_MINUTES=5
|
||||||
|
GMAIL_QUERY=in:inbox -label:AgentProcessed newer_than:7d
|
||||||
|
LOG_LEVEL=INFO
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
credentials.json
|
||||||
|
token.json
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
# Personal Gmail + Calendar Agent
|
||||||
|
|
||||||
|
This project runs a small local API service that:
|
||||||
|
|
||||||
|
- scans new Gmail inbox messages
|
||||||
|
- moves LinkedIn emails to a `LinkedIn` label/folder
|
||||||
|
- moves advertising emails to an `Advertising` label/folder
|
||||||
|
- exposes a secure availability endpoint powered by Google Calendar free/busy
|
||||||
|
|
||||||
|
## 1) Prerequisites
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- A Google account
|
||||||
|
- A Google Cloud project with:
|
||||||
|
- Gmail API enabled
|
||||||
|
- Google Calendar API enabled
|
||||||
|
- OAuth consent configured
|
||||||
|
- OAuth Client ID of type **Desktop app**
|
||||||
|
|
||||||
|
## 2) Google OAuth setup
|
||||||
|
|
||||||
|
1. In Google Cloud Console, create a desktop OAuth client.
|
||||||
|
2. Download the client JSON file.
|
||||||
|
3. Save it in this project as `credentials.json`.
|
||||||
|
|
||||||
|
The first run opens a browser window for consent and creates `token.json`.
|
||||||
|
|
||||||
|
## 3) Install and configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and set:
|
||||||
|
|
||||||
|
- `AGENT_API_KEY` to a strong secret for agent-to-agent calls
|
||||||
|
- optional scan frequency and Gmail query
|
||||||
|
|
||||||
|
## 4) Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
At startup, the scheduler runs every `GMAIL_SCAN_INTERVAL_MINUTES`.
|
||||||
|
|
||||||
|
## 5) API usage
|
||||||
|
|
||||||
|
### Health check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Gmail scan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://127.0.0.1:8000/scan?max_results=100" \
|
||||||
|
-H "X-API-Key: your-secret"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Availability for other AI agents
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://127.0.0.1:8000/availability" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: your-secret" \
|
||||||
|
-d '{
|
||||||
|
"start": "2026-03-09T09:00:00+01:00",
|
||||||
|
"end": "2026-03-09T10:00:00+01:00",
|
||||||
|
"calendar_ids": ["primary"]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
If `available` is `true`, there are no busy slots in that range.
|
||||||
|
|
||||||
|
## Classification behavior
|
||||||
|
|
||||||
|
- LinkedIn detection: sender or subject contains `linkedin` (LinkedIn has priority).
|
||||||
|
- Advertising detection: Gmail promotion category, `List-Unsubscribe`, `Precedence: bulk/list/junk`, common promo keywords, and marketing sender hints.
|
||||||
|
- Every scanned message gets an `AgentProcessed` label to avoid reprocessing loops.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gmail "folders" are labels. This agent creates:
|
||||||
|
- `LinkedIn`
|
||||||
|
- `Advertising`
|
||||||
|
- `AgentProcessed`
|
||||||
|
- Messages classified as LinkedIn/Advertising are removed from `INBOX` (moved out of inbox).
|
||||||
@ -0,0 +1 @@
|
|||||||
|
# Personal agent package
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AvailabilityResult:
|
||||||
|
start: str
|
||||||
|
end: str
|
||||||
|
available: bool
|
||||||
|
busy_slots: list[dict[str, str]]
|
||||||
|
checked_calendars: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarAvailabilityAgent:
|
||||||
|
def __init__(self, calendar_service: Any) -> None:
|
||||||
|
self.calendar_service = calendar_service
|
||||||
|
|
||||||
|
def get_availability(
|
||||||
|
self, start: str, end: str, calendar_ids: list[str] | None = None
|
||||||
|
) -> AvailabilityResult:
|
||||||
|
start_dt = _parse_iso_datetime(start)
|
||||||
|
end_dt = _parse_iso_datetime(end)
|
||||||
|
|
||||||
|
if end_dt <= start_dt:
|
||||||
|
raise ValueError("end must be after start.")
|
||||||
|
|
||||||
|
calendars = calendar_ids or ["primary"]
|
||||||
|
query_body = {
|
||||||
|
"timeMin": start_dt.isoformat(),
|
||||||
|
"timeMax": end_dt.isoformat(),
|
||||||
|
"items": [{"id": calendar_id} for calendar_id in calendars],
|
||||||
|
}
|
||||||
|
|
||||||
|
freebusy = self.calendar_service.freebusy().query(body=query_body).execute()
|
||||||
|
calendars_payload = freebusy.get("calendars", {})
|
||||||
|
|
||||||
|
busy_slots: list[dict[str, str]] = []
|
||||||
|
for calendar_id, data in calendars_payload.items():
|
||||||
|
for busy_slot in data.get("busy", []):
|
||||||
|
busy_slots.append(
|
||||||
|
{
|
||||||
|
"calendar_id": calendar_id,
|
||||||
|
"start": busy_slot["start"],
|
||||||
|
"end": busy_slot["end"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return AvailabilityResult(
|
||||||
|
start=start_dt.isoformat(),
|
||||||
|
end=end_dt.isoformat(),
|
||||||
|
available=len(busy_slots) == 0,
|
||||||
|
busy_slots=busy_slots,
|
||||||
|
checked_calendars=calendars,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_datetime(value: str) -> datetime:
|
||||||
|
normalized = value.strip()
|
||||||
|
if normalized.endswith("Z"):
|
||||||
|
normalized = normalized[:-1] + "+00:00"
|
||||||
|
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("datetime must include a timezone offset, for example +01:00.")
|
||||||
|
return parsed
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
GOOGLE_SCOPES = (
|
||||||
|
"https://www.googleapis.com/auth/gmail.modify",
|
||||||
|
"https://www.googleapis.com/auth/calendar.readonly",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
google_client_secrets_file: str
|
||||||
|
google_token_file: str
|
||||||
|
gmail_scan_interval_minutes: int
|
||||||
|
gmail_query: str
|
||||||
|
agent_api_key: str
|
||||||
|
log_level: str
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings(
|
||||||
|
google_client_secrets_file=os.getenv("GOOGLE_CLIENT_SECRETS_FILE", "credentials.json"),
|
||||||
|
google_token_file=os.getenv("GOOGLE_TOKEN_FILE", "token.json"),
|
||||||
|
gmail_scan_interval_minutes=int(os.getenv("GMAIL_SCAN_INTERVAL_MINUTES", "5")),
|
||||||
|
gmail_query=os.getenv(
|
||||||
|
"GMAIL_QUERY", "in:inbox -label:AgentProcessed newer_than:7d"
|
||||||
|
),
|
||||||
|
agent_api_key=os.getenv("AGENT_API_KEY", ""),
|
||||||
|
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
||||||
|
)
|
||||||
@ -0,0 +1,214 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from email.utils import parseaddr
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
METADATA_HEADERS = [
|
||||||
|
"From",
|
||||||
|
"Subject",
|
||||||
|
"List-Unsubscribe",
|
||||||
|
"Precedence",
|
||||||
|
]
|
||||||
|
|
||||||
|
AD_SUBJECT_KEYWORDS = {
|
||||||
|
"discount",
|
||||||
|
"offer",
|
||||||
|
"sale",
|
||||||
|
"promo",
|
||||||
|
"newsletter",
|
||||||
|
"deal",
|
||||||
|
"save",
|
||||||
|
"coupon",
|
||||||
|
"special offer",
|
||||||
|
"limited time",
|
||||||
|
}
|
||||||
|
|
||||||
|
AD_SENDER_HINTS = {
|
||||||
|
"newsletter",
|
||||||
|
"marketing",
|
||||||
|
"offers",
|
||||||
|
"promotions",
|
||||||
|
"deals",
|
||||||
|
"no-reply",
|
||||||
|
"noreply",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger = logging.getLogger("personal-agent.gmail")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScanResult:
|
||||||
|
scanned: int
|
||||||
|
linkedin: int
|
||||||
|
advertising: int
|
||||||
|
skipped: int
|
||||||
|
failed: int
|
||||||
|
|
||||||
|
|
||||||
|
class GmailTriageAgent:
|
||||||
|
def __init__(self, gmail_service: Any, query: str) -> None:
|
||||||
|
self.gmail_service = gmail_service
|
||||||
|
self.query = query
|
||||||
|
|
||||||
|
def ensure_labels(self) -> dict[str, str]:
|
||||||
|
labels_response = (
|
||||||
|
self.gmail_service.users().labels().list(userId="me").execute()
|
||||||
|
)
|
||||||
|
labels = labels_response.get("labels", [])
|
||||||
|
label_by_name = {label["name"]: label["id"] for label in labels}
|
||||||
|
|
||||||
|
for required_name in ("LinkedIn", "Advertising", "AgentProcessed"):
|
||||||
|
if required_name not in label_by_name:
|
||||||
|
created = (
|
||||||
|
self.gmail_service.users()
|
||||||
|
.labels()
|
||||||
|
.create(
|
||||||
|
userId="me",
|
||||||
|
body={
|
||||||
|
"name": required_name,
|
||||||
|
"labelListVisibility": "labelShow",
|
||||||
|
"messageListVisibility": "show",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
label_by_name[required_name] = created["id"]
|
||||||
|
|
||||||
|
return label_by_name
|
||||||
|
|
||||||
|
def scan_and_route_messages(self, max_results: int = 100) -> ScanResult:
|
||||||
|
label_by_name = self.ensure_labels()
|
||||||
|
inbox_messages = (
|
||||||
|
self.gmail_service.users()
|
||||||
|
.messages()
|
||||||
|
.list(userId="me", q=self.query, maxResults=max_results)
|
||||||
|
.execute()
|
||||||
|
.get("messages", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
linkedin = 0
|
||||||
|
advertising = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for message in inbox_messages:
|
||||||
|
outcome = self._route_message(message["id"], label_by_name)
|
||||||
|
if outcome == "linkedin":
|
||||||
|
linkedin += 1
|
||||||
|
elif outcome == "advertising":
|
||||||
|
advertising += 1
|
||||||
|
elif outcome == "skipped":
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return ScanResult(
|
||||||
|
scanned=len(inbox_messages),
|
||||||
|
linkedin=linkedin,
|
||||||
|
advertising=advertising,
|
||||||
|
skipped=skipped,
|
||||||
|
failed=failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _route_message(self, message_id: str, label_by_name: dict[str, str]) -> str:
|
||||||
|
try:
|
||||||
|
message = (
|
||||||
|
self.gmail_service.users()
|
||||||
|
.messages()
|
||||||
|
.get(
|
||||||
|
userId="me",
|
||||||
|
id=message_id,
|
||||||
|
format="metadata",
|
||||||
|
metadataHeaders=METADATA_HEADERS,
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
headers = {
|
||||||
|
h["name"].lower(): h["value"]
|
||||||
|
for h in message.get("payload", {}).get("headers", [])
|
||||||
|
}
|
||||||
|
label_ids = set(message.get("labelIds", []))
|
||||||
|
|
||||||
|
sender = headers.get("from", "")
|
||||||
|
subject = headers.get("subject", "")
|
||||||
|
|
||||||
|
should_linkedin = self._is_linkedin_email(sender=sender, subject=subject)
|
||||||
|
should_advertising = self._is_advertising_email(
|
||||||
|
sender=sender,
|
||||||
|
subject=subject,
|
||||||
|
list_unsubscribe=headers.get("list-unsubscribe", ""),
|
||||||
|
precedence=headers.get("precedence", ""),
|
||||||
|
message_label_ids=label_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
add_labels = [label_by_name["AgentProcessed"]]
|
||||||
|
remove_labels = []
|
||||||
|
|
||||||
|
if should_linkedin:
|
||||||
|
add_labels.insert(0, label_by_name["LinkedIn"])
|
||||||
|
remove_labels.append("INBOX")
|
||||||
|
outcome = "linkedin"
|
||||||
|
elif should_advertising:
|
||||||
|
add_labels.insert(0, label_by_name["Advertising"])
|
||||||
|
remove_labels.append("INBOX")
|
||||||
|
outcome = "advertising"
|
||||||
|
else:
|
||||||
|
outcome = "skipped"
|
||||||
|
|
||||||
|
(
|
||||||
|
self.gmail_service.users()
|
||||||
|
.messages()
|
||||||
|
.modify(
|
||||||
|
userId="me",
|
||||||
|
id=message_id,
|
||||||
|
body={
|
||||||
|
"addLabelIds": add_labels,
|
||||||
|
"removeLabelIds": remove_labels,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
|
||||||
|
return outcome
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to route message %s", message_id)
|
||||||
|
return "failed"
|
||||||
|
|
||||||
|
def _is_linkedin_email(self, sender: str, subject: str) -> bool:
|
||||||
|
sender_lower = sender.lower()
|
||||||
|
subject_lower = subject.lower()
|
||||||
|
|
||||||
|
if "linkedin" in sender_lower or "linkedin" in subject_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
parsed_address = parseaddr(sender)[1].lower()
|
||||||
|
return parsed_address.endswith("@linkedin.com")
|
||||||
|
|
||||||
|
def _is_advertising_email(
|
||||||
|
self,
|
||||||
|
sender: str,
|
||||||
|
subject: str,
|
||||||
|
list_unsubscribe: str,
|
||||||
|
precedence: str,
|
||||||
|
message_label_ids: set[str],
|
||||||
|
) -> bool:
|
||||||
|
sender_lower = sender.lower()
|
||||||
|
subject_lower = subject.lower()
|
||||||
|
precedence_lower = precedence.lower()
|
||||||
|
|
||||||
|
if "CATEGORY_PROMOTIONS" in message_label_ids:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if list_unsubscribe.strip():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if precedence_lower in {"bulk", "list", "junk"}:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if any(keyword in subject_lower for keyword in AD_SUBJECT_KEYWORDS):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return any(hint in sender_lower for hint in AD_SENDER_HINTS)
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
|
from app.config import GOOGLE_SCOPES, Settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_google_credentials(settings: Settings) -> Credentials:
|
||||||
|
creds = None
|
||||||
|
|
||||||
|
if os.path.exists(settings.google_token_file):
|
||||||
|
creds = Credentials.from_authorized_user_file(
|
||||||
|
settings.google_token_file, GOOGLE_SCOPES
|
||||||
|
)
|
||||||
|
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
else:
|
||||||
|
if not os.path.exists(settings.google_client_secrets_file):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Missing OAuth client file at {settings.google_client_secrets_file}. "
|
||||||
|
"Create Google OAuth desktop credentials and save the JSON at this path."
|
||||||
|
)
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(
|
||||||
|
settings.google_client_secrets_file, GOOGLE_SCOPES
|
||||||
|
)
|
||||||
|
creds = flow.run_local_server(port=0)
|
||||||
|
|
||||||
|
with open(settings.google_token_file, "w", encoding="utf-8") as token_file:
|
||||||
|
token_file.write(creds.to_json())
|
||||||
|
|
||||||
|
return creds
|
||||||
|
|
||||||
|
|
||||||
|
def build_gmail_service(settings: Settings):
|
||||||
|
creds = get_google_credentials(settings)
|
||||||
|
return build("gmail", "v1", credentials=creds, cache_discovery=False)
|
||||||
|
|
||||||
|
|
||||||
|
def build_calendar_service(settings: Settings):
|
||||||
|
creds = get_google_credentials(settings)
|
||||||
|
return build("calendar", "v3", credentials=creds, cache_discovery=False)
|
||||||
@ -0,0 +1,193 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.calendar_agent import CalendarAvailabilityAgent
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.gmail_agent import GmailTriageAgent
|
||||||
|
from app.google_clients import build_calendar_service, build_gmail_service
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
|
||||||
|
logger = logging.getLogger("personal-agent")
|
||||||
|
|
||||||
|
app = FastAPI(title="Personal Agent", version="0.1.0")
|
||||||
|
scheduler: AsyncIOScheduler | None = None
|
||||||
|
scan_lock: asyncio.Lock | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResponse(BaseModel):
|
||||||
|
scanned: int
|
||||||
|
linkedin: int
|
||||||
|
advertising: int
|
||||||
|
skipped: int
|
||||||
|
failed: int
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityRequest(BaseModel):
|
||||||
|
start: str
|
||||||
|
end: str
|
||||||
|
calendar_ids: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BusySlot(BaseModel):
|
||||||
|
calendar_id: str
|
||||||
|
start: str
|
||||||
|
end: str
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityResponse(BaseModel):
|
||||||
|
start: str
|
||||||
|
end: str
|
||||||
|
available: bool
|
||||||
|
busy_slots: list[BusySlot]
|
||||||
|
checked_calendars: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def verify_api_key(
|
||||||
|
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||||
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
|
) -> None:
|
||||||
|
expected = settings.agent_api_key
|
||||||
|
if not expected:
|
||||||
|
return
|
||||||
|
|
||||||
|
provided = x_api_key
|
||||||
|
if not provided and authorization:
|
||||||
|
parts = authorization.split(" ", 1)
|
||||||
|
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||||
|
provided = parts[1]
|
||||||
|
|
||||||
|
if provided != expected:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scan_once(max_results: int) -> ScanResponse:
|
||||||
|
gmail_service = build_gmail_service(settings)
|
||||||
|
gmail_agent = GmailTriageAgent(gmail_service=gmail_service, query=settings.gmail_query)
|
||||||
|
result = gmail_agent.scan_and_route_messages(max_results=max_results)
|
||||||
|
return ScanResponse(
|
||||||
|
scanned=result.scanned,
|
||||||
|
linkedin=result.linkedin,
|
||||||
|
advertising=result.advertising,
|
||||||
|
skipped=result.skipped,
|
||||||
|
failed=result.failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_scan_lock() -> asyncio.Lock:
|
||||||
|
global scan_lock
|
||||||
|
if scan_lock is None:
|
||||||
|
scan_lock = asyncio.Lock()
|
||||||
|
return scan_lock
|
||||||
|
|
||||||
|
|
||||||
|
async def _scheduled_scan() -> None:
|
||||||
|
lock = _get_scan_lock()
|
||||||
|
if lock.locked():
|
||||||
|
logger.info("Previous scan still running, skipping this tick.")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with lock:
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(_run_scan_once, 100)
|
||||||
|
logger.info("Scheduled scan complete: %s", result.model_dump())
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scheduled scan failed")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event() -> None:
|
||||||
|
global scheduler
|
||||||
|
_get_scan_lock()
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
scheduler.add_job(
|
||||||
|
_scheduled_scan,
|
||||||
|
"interval",
|
||||||
|
minutes=settings.gmail_scan_interval_minutes,
|
||||||
|
next_run_time=datetime.now(),
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
logger.info(
|
||||||
|
"Scheduler started (interval=%s min)", settings.gmail_scan_interval_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown_event() -> None:
|
||||||
|
if scheduler:
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"scan_interval_minutes": settings.gmail_scan_interval_minutes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/scan",
|
||||||
|
response_model=ScanResponse,
|
||||||
|
dependencies=[Depends(verify_api_key)],
|
||||||
|
)
|
||||||
|
async def scan_now(max_results: int = Query(100, ge=1, le=500)) -> ScanResponse:
|
||||||
|
async with _get_scan_lock():
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_run_scan_once, max_results)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Gmail scan failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/availability",
|
||||||
|
response_model=AvailabilityResponse,
|
||||||
|
dependencies=[Depends(verify_api_key)],
|
||||||
|
)
|
||||||
|
async def availability(request: AvailabilityRequest) -> AvailabilityResponse:
|
||||||
|
try:
|
||||||
|
calendar_service = build_calendar_service(settings)
|
||||||
|
availability_agent = CalendarAvailabilityAgent(calendar_service=calendar_service)
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
availability_agent.get_availability,
|
||||||
|
request.start,
|
||||||
|
request.end,
|
||||||
|
request.calendar_ids,
|
||||||
|
)
|
||||||
|
return AvailabilityResponse(
|
||||||
|
start=result.start,
|
||||||
|
end=result.end,
|
||||||
|
available=result.available,
|
||||||
|
busy_slots=result.busy_slots,
|
||||||
|
checked_calendars=result.checked_calendars,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Availability lookup failed: {exc}",
|
||||||
|
) from exc
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
apscheduler
|
||||||
|
fastapi
|
||||||
|
google-api-python-client
|
||||||
|
google-auth
|
||||||
|
google-auth-oauthlib
|
||||||
|
python-dotenv
|
||||||
|
uvicorn[standard]
|
||||||
Loading…
Reference in New Issue