""" app.py — HuggingFace Spaces entry point Pure FastAPI + uvicorn. No Gradio. Slack webhook at /slack/events. Admin endpoints at /admin/*. """ import os import hmac import hashlib import time import threading import json from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel import router import scheduler as sched import sheets # ───────────────────────────────────────────── # LIFESPAN # ───────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): import asyncio async def delayed_setup(): await asyncio.sleep(5) try: sheets.setup_sheets() except Exception as e: print(f"setup_sheets warning: {e}") asyncio.create_task(delayed_setup()) sched.start_scheduler() yield # ───────────────────────────────────────────── # APP # ───────────────────────────────────────────── app = FastAPI( title="Intern Management Agent", description="Slack agent managing intern pipeline — Week 0 through Probation.", version="1.0.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=["*"], # restrict to your dashboard URL in production allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "") CHANNEL_ID = os.environ.get("SLACK_CHANNEL_ID", "") # ───────────────────────────────────────────── # SLACK SIGNATURE VERIFICATION # ───────────────────────────────────────────── def verify_slack_signature(body: bytes, timestamp: str, signature: str) -> bool: if not timestamp or not signature: return False if abs(time.time() - int(timestamp)) > 300: return False sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}" computed = "v0=" + hmac.new( SLACK_SIGNING_SECRET.encode(), sig_basestring.encode(), hashlib.sha256, ).hexdigest() return hmac.compare_digest(computed, signature) # ───────────────────────────────────────────── # SLACK EVENTS # ───────────────────────────────────────────── @app.post("/slack/events", summary="Slack event webhook") async def slack_events(request: Request): try: body = await request.body() if not body: return JSONResponse({"ok": True}) payload = json.loads(body) # URL verification challenge — must respond before signature check if payload.get("type") == "url_verification": return JSONResponse({"challenge": payload["challenge"]}) # Verify all other requests timestamp = request.headers.get("X-Slack-Request-Timestamp", "") signature = request.headers.get("X-Slack-Signature", "") if not verify_slack_signature(body, timestamp, signature): return JSONResponse({"error": "Invalid signature"}, status_code=403) if payload.get("type") == "event_callback": event = payload.get("event", {}) if event.get("type") == "message": thread = threading.Thread(target=router.route_event, args=(event,)) thread.daemon = True thread.start() return JSONResponse({"ok": True}) except json.JSONDecodeError: return JSONResponse({"ok": True}) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) # ───────────────────────────────────────────── # ADMIN — ADD CANDIDATE # ───────────────────────────────────────────── class CandidateIn(BaseModel): slack_user_id: str name: str cohort_id: str ft_slack_id: str hr_slack_id: str week0_start_date: str stage: str = "week0" # week0 | intern | probation_w1 | probation_w2 department: str = "general" # ai | hr | sales | product | general channel_id: str = "" # Slack channel ID where intern posts reports @app.post("/admin/add_candidate", summary="Register a new intern") async def add_candidate(data: CandidateIn): try: candidate = sheets.create_candidate( slack_user_id=data.slack_user_id, name=data.name, cohort_id=data.cohort_id, ft_slack_id=data.ft_slack_id, hr_slack_id=data.hr_slack_id, week0_start_date=data.week0_start_date, stage=data.stage, department=data.department, channel_id=data.channel_id, ) return JSONResponse({"ok": True, "candidate_id": candidate["candidate_id"]}) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) # ───────────────────────────────────────────── # ADMIN — LOG COMMITMENT # ───────────────────────────────────────────── class CommitmentIn(BaseModel): candidate_id: str description: str due_date: str due_time: str @app.post("/admin/log_commitment", summary="Log a Week 2 delivery commitment") async def log_commitment(data: CommitmentIn): try: commitment_id = sheets.log_commitment( candidate_id=data.candidate_id, description=data.description, due_date=data.due_date, due_time=data.due_time, ) return JSONResponse({"ok": True, "commitment_id": commitment_id}) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) # ───────────────────────────────────────────── # HEALTH # ───────────────────────────────────────────── # ───────────────────────────────────────────── # INTERN MANAGEMENT — GET / UPDATE / DELETE # ───────────────────────────────────────────── class InternUpdate(BaseModel): name: str | None = None stage: str | None = None department: str | None = None channel_id: str | None = None ft_slack_id: str | None = None hr_slack_id: str | None = None week0_start_date: str | None = None status: str | None = None cohort_id: str | None = None @app.get("/interns", summary="List all interns — filter by department") async def get_interns(department: str = None): """ Returns all active interns. Optional query param: ?department=ai Example: /interns?department=ai """ try: candidates = sheets.get_active_candidates() if department: candidates = [ c for c in candidates if c.get("department", "").lower() == department.lower() ] return JSONResponse({"ok": True, "count": len(candidates), "interns": candidates}) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) @app.get("/interns/{candidate_id}", summary="Get a single intern by candidate ID") async def get_intern(candidate_id: str): try: candidate = sheets.get_candidate_by_id(candidate_id) if not candidate: return JSONResponse({"error": "Intern not found"}, status_code=404) return JSONResponse({"ok": True, "intern": candidate}) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) @app.patch("/interns/{candidate_id}", summary="Update intern fields") async def update_intern(candidate_id: str, data: InternUpdate): """ Update one or more fields of an intern. Only pass the fields you want to change. """ try: candidate = sheets.get_candidate_by_id(candidate_id) if not candidate: return JSONResponse({"error": "Intern not found"}, status_code=404) updates = data.dict(exclude_none=True) if not updates: return JSONResponse({"error": "No fields to update"}, status_code=400) for field, value in updates.items(): sheets.update_candidate_field(candidate_id, field, value) return JSONResponse({"ok": True, "updated": updates}) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) @app.delete("/interns/{candidate_id}", summary="Remove an intern from the pipeline") async def delete_intern(candidate_id: str): """ Marks the intern as eliminated and inactive. Does not delete the row — preserves audit trail. """ try: candidate = sheets.get_candidate_by_id(candidate_id) if not candidate: return JSONResponse({"error": "Intern not found"}, status_code=404) sheets.update_candidate_field(candidate_id, "status", "eliminated") return JSONResponse({ "ok": True, "message": f"{candidate.get('name')} removed from pipeline. Row preserved for audit." }) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) @app.post("/admin/trigger_agenda", summary="Manually trigger morning agenda prompt") async def trigger_agenda(): """Trigger the agenda prompt manually — bypasses working day check.""" try: def run(): try: sched.job_morning_agenda_prompt(force=True) except Exception as e: print(f"[trigger_agenda] error: {e}") thread = threading.Thread(target=run) thread.daemon = True thread.start() return JSONResponse({"ok": True, "message": "Agenda prompt triggered"}) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) @app.post("/admin/trigger_summary", summary="Manually trigger EOD summary and miss check") async def trigger_summary(): """Trigger the 11:30 PM miss check and summary manually — for testing.""" try: import threading import handlers thread = threading.Thread(target=handlers.handle_missed_report_check) thread.daemon = True thread.start() return JSONResponse({"ok": True, "message": "Miss check and summary triggered"}) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) @app.get("/health", summary="Health check") async def health(): return JSONResponse({"status": "ok"})