M1: backend core + proposal engine

- FastAPI skeleton, SQLAlchemy models (§13), Alembic initial migration
- SchedulingProvider interface with google_calendar (free/busy read-only),
  partner_api (Appendix B client) and mock implementations
- Proposal engine: create → provider-routed delivery → owner actions
  (resolve/confirm+SMS/reject) → expiry + reminders (§9)
- Signed single-use action links, .ics METHOD:REQUEST attachment
- Partner outcome webhook with HMAC verification + polling fallback
- SmsProvider (console) with Bosnian templates (§5.5), EmailProvider (console/SMTP)
- Fake partner API server in tests/ — Appendix B reference implementation
- 43 tests: slot math, proposal lifecycle, action links, partner contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 09:45:06 +02:00
commit e855650f09
48 changed files with 4941 additions and 0 deletions

3
gogo/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Gogo Telefon — voice-first AI receptionist for small service businesses."""
__version__ = "0.1.0"

0
gogo/api/__init__.py Normal file
View File

190
gogo/api/actions.py Normal file
View File

@@ -0,0 +1,190 @@
"""Owner action links from proposal emails (§9.1-9.2): /a/{token}.
Signed, single-use, no login required, idempotent (§15). All owner-facing
copy is Bosnian. Reject shows a minimal form to edit the client SMS before
sending; confirm re-checks free/busy and warns if the slot has been taken.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Form
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.db import get_session
from gogo.domain import BookingStatus, Slot
from gogo.i18n import fmt_slot
from gogo.models import ActionToken, BookingRequest, Tenant, utcnow
from gogo.proposals import engine
from gogo.proposals.tokens import unsign
from gogo.sms.templates import render_sms
router = APIRouter()
_PAGE = """<!doctype html><html lang="bs"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Gogo Telefon</title>
<style>
body{{font-family:system-ui,sans-serif;background:#f4f4f5;margin:0;padding:24px;
display:flex;justify-content:center}}
.card{{background:#fff;border-radius:12px;padding:32px;max-width:480px;width:100%;
box-shadow:0 1px 4px rgba(0,0,0,.08)}}
h1{{font-size:20px;margin-top:0}} p{{line-height:1.5}}
.ok{{color:#16a34a}} .warn{{color:#d97706}} .err{{color:#dc2626}}
textarea{{width:100%;min-height:90px;font:inherit;padding:8px;box-sizing:border-box}}
button,a.btn{{background:#2563eb;color:#fff;border:0;border-radius:8px;padding:12px 20px;
font-size:15px;cursor:pointer;text-decoration:none;display:inline-block;margin-top:8px}}
button.green{{background:#16a34a}} button.red{{background:#dc2626}}
.muted{{color:#71717a;font-size:13px;margin-top:24px}}
</style></head><body><div class="card">{body}
<p class="muted">Gogo Telefon — virtuelni asistent</p></div></body></html>"""
def page(body: str, status_code: int = 200) -> HTMLResponse:
return HTMLResponse(_PAGE.format(body=body), status_code=status_code)
STATUS_LABEL = {
BookingStatus.pending.value: "na čekanju",
BookingStatus.resolved_by_owner.value: "riješen — klijent kontaktiran",
BookingStatus.confirmed.value: "potvrđen",
BookingStatus.rejected.value: "odbijen",
BookingStatus.expired.value: "istekao",
}
async def _load(token: str, session: AsyncSession):
raw = unsign(token)
if raw is None:
return None, None, None, page(
"<h1 class='err'>Nevažeći link</h1><p>Link je oštećen ili nije ispravan.</p>", 400
)
at = (
await session.execute(select(ActionToken).where(ActionToken.token == raw))
).scalar_one_or_none()
if at is None:
return None, None, None, page(
"<h1 class='err'>Nepoznat link</h1><p>Ovaj link više ne postoji.</p>", 404
)
req = (
await session.execute(select(BookingRequest).where(BookingRequest.id == at.request_id))
).scalar_one()
tenant = (
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
).scalar_one()
return at, req, tenant, None
def _already_done(req: BookingRequest) -> HTMLResponse:
label = STATUS_LABEL.get(req.status, req.status)
return page(
f"<h1 class='ok'>Zahtjev je već obrađen</h1>"
f"<p>Status zahtjeva klijenta <b>{req.client_name}</b>: <b>{label}</b>.</p>"
"<p>Nije potrebna dodatna akcija.</p>"
)
@router.get("/a/{token}", response_class=HTMLResponse)
async def action_get(token: str, session: AsyncSession = Depends(get_session)):
at, req, tenant, err = await _load(token, session)
if err:
return err
if req.status != BookingStatus.pending.value:
return _already_done(req)
if at.action == "resolve":
await engine.resolve_request(session, tenant, req)
at.used_at = utcnow()
await session.commit()
return page(
"<h1 class='ok'>✓ Označeno kao riješeno</h1>"
f"<p>Zahtjev klijenta <b>{req.client_name}</b> je zatvoren. "
"Klijentu <b>nije</b> poslan SMS — dogovorili ste se direktno.</p>"
"<p>Ne zaboravite upisati termin u svoj kalendar (možete iskoristiti "
"priloženi .ics iz emaila).</p>"
)
if at.action == "confirm":
slots = [Slot.model_validate(s) for s in (req.slots or [])]
idx = at.slot_index or 0
if idx >= len(slots):
return page("<h1 class='err'>Greška</h1><p>Traženi termin ne postoji.</p>", 400)
slot = slots[idx]
ok = await engine.confirm_request(session, tenant, req, slot, recheck=True)
if not ok:
# slot taken since — warn, offer force (§9.2)
return page(
"<h1 class='warn'>⚠ Termin je u međuvremenu zauzet</h1>"
f"<p>U kalendaru više nije slobodno: <b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
"<p>Možete svejedno potvrditi (npr. ako ste sami upisali ovaj termin "
"u kalendar), ili se javiti klijentu direktno.</p>"
f"<form method='post' action='/a/{token}/force-confirm'>"
"<button class='green'>Svejedno potvrdi i pošalji SMS</button></form>"
)
at.used_at = utcnow()
await session.commit()
return page(
"<h1 class='ok'>✓ Termin potvrđen</h1>"
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s potvrdom za "
f"<b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
"<p>Ne zaboravite upisati termin u svoj kalendar.</p>"
)
if at.action == "reject":
default_sms = render_sms(tenant, "rejection")
return page(
"<h1>Odbij zahtjev</h1>"
f"<p>Klijent <b>{req.client_name}</b> ({req.client_phone}) će dobiti ovu poruku — "
"možete je izmijeniti prije slanja:</p>"
f"<form method='post' action='/a/{token}/reject'>"
f"<textarea name='sms_body'>{default_sms}</textarea>"
"<button class='red'>Pošalji i odbij zahtjev</button></form>"
)
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
@router.post("/a/{token}/reject", response_class=HTMLResponse)
async def action_reject(
token: str, sms_body: str = Form(""), session: AsyncSession = Depends(get_session)
):
at, req, tenant, err = await _load(token, session)
if err:
return err
if at.action != "reject":
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
if req.status != BookingStatus.pending.value:
return _already_done(req)
await engine.reject_request(session, tenant, req, custom_sms=sms_body.strip() or None)
at.used_at = utcnow()
await session.commit()
return page(
"<h1 class='ok'>Zahtjev odbijen</h1>"
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s obavještenjem.</p>"
)
@router.post("/a/{token}/force-confirm", response_class=HTMLResponse)
async def action_force_confirm(token: str, session: AsyncSession = Depends(get_session)):
at, req, tenant, err = await _load(token, session)
if err:
return err
if at.action != "confirm":
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
if req.status != BookingStatus.pending.value:
return _already_done(req)
slots = [Slot.model_validate(s) for s in (req.slots or [])]
idx = at.slot_index or 0
if idx >= len(slots):
return page("<h1 class='err'>Greška</h1><p>Traženi termin ne postoji.</p>", 400)
slot = slots[idx]
await engine.confirm_request(session, tenant, req, slot, recheck=False)
at.used_at = utcnow()
await session.commit()
return page(
"<h1 class='ok'>✓ Termin potvrđen</h1>"
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s potvrdom za "
f"<b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
)

23
gogo/api/health.py Normal file
View File

@@ -0,0 +1,23 @@
"""Health/observability endpoints (§15)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
import gogo
from gogo.db import get_session
router = APIRouter()
@router.get("/health")
async def health():
return {"status": "ok", "version": gogo.__version__}
@router.get("/health/db")
async def health_db(session: AsyncSession = Depends(get_session)):
await session.execute(text("SELECT 1"))
return {"status": "ok"}

120
gogo/api/webhooks.py Normal file
View File

@@ -0,0 +1,120 @@
"""Partner outcome webhook (§B.3): POST /webhooks/partner/{tenant_id}.
HMAC-SHA256 of the raw body with the per-tenant shared secret, sent as
X-Gogo-Signature: sha256=<hex>. Idempotent per (gogo_request_id, outcome).
Drives the same transitions (and client SMS) as the email actions.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import uuid
from fastapi import APIRouter, Depends, Header, Request
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.crypto import decrypt
from gogo.db import get_session
from gogo.domain import BookingStatus, Slot
from gogo.models import BookingRequest, ProviderConfig, Tenant
from gogo.proposals import engine
log = logging.getLogger("gogo.webhooks")
router = APIRouter()
def _err(status: int, code: str, message: str) -> JSONResponse:
return JSONResponse({"error": {"code": code, "message": message}}, status_code=status)
def verify_signature(secret: str, body: bytes, header_value: str | None) -> bool:
if not header_value or not header_value.startswith("sha256="):
return False
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(header_value.removeprefix("sha256="), expected)
@router.post("/webhooks/partner/{tenant_id}")
async def partner_webhook(
tenant_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
x_gogo_signature: str | None = Header(default=None),
):
try:
tid = uuid.UUID(tenant_id)
except ValueError:
return _err(404, "unknown_tenant", "Unknown tenant id")
tenant = (
await session.execute(select(Tenant).where(Tenant.id == tid))
).scalar_one_or_none()
if tenant is None:
return _err(404, "unknown_tenant", "Unknown tenant id")
config_row = (
await session.execute(select(ProviderConfig).where(ProviderConfig.tenant_id == tid))
).scalar_one_or_none()
secret_enc = (config_row.config if config_row else {}).get("webhook_secret_encrypted")
if not secret_enc:
return _err(409, "not_configured", "Webhook secret not configured for tenant")
body = await request.body()
if not verify_signature(decrypt(secret_enc), body, x_gogo_signature):
return _err(401, "bad_signature", "Invalid or missing X-Gogo-Signature")
try:
payload = await request.json()
gogo_request_id = uuid.UUID(payload["gogo_request_id"])
outcome = payload["outcome"]
except Exception: # noqa: BLE001
return _err(400, "bad_payload", "Malformed JSON payload")
if outcome not in ("confirmed", "resolved", "rejected"):
return _err(400, "bad_outcome", f"Unknown outcome: {outcome}")
req = (
await session.execute(
select(BookingRequest).where(
BookingRequest.id == gogo_request_id, BookingRequest.tenant_id == tid
)
)
).scalar_one_or_none()
if req is None:
return _err(404, "unknown_request", "Unknown gogo_request_id")
if payload.get("partner_request_id") and not req.partner_request_id:
req.partner_request_id = str(payload["partner_request_id"])
# Idempotency per (gogo_request_id, outcome) (§B.3)
target = {
"confirmed": BookingStatus.confirmed.value,
"resolved": BookingStatus.resolved_by_owner.value,
"rejected": BookingStatus.rejected.value,
}[outcome]
if req.status == target:
return {"ok": True}
if req.status != BookingStatus.pending.value:
return _err(409, "conflict", f"Request already {req.status}")
if outcome == "confirmed":
slot_data = payload.get("confirmed_slot")
if not slot_data:
return _err(400, "missing_slot", "confirmed_slot is required for outcome=confirmed")
try:
slot = Slot.model_validate(slot_data)
except Exception: # noqa: BLE001
return _err(400, "bad_slot", "Malformed confirmed_slot")
# Partner software is the source of truth — no free/busy recheck (§8.2)
await engine.confirm_request(session, tenant, req, slot, recheck=False)
elif outcome == "resolved":
await engine.resolve_request(session, tenant, req, note=payload.get("note", ""))
else:
await engine.reject_request(session, tenant, req)
await session.commit()
log.info("webhook: request %s%s (tenant %s)", req.id, outcome, tenant.slug)
return {"ok": True}

59
gogo/config.py Normal file
View File

@@ -0,0 +1,59 @@
"""Application configuration via environment variables / .env file."""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="GOGO_", extra="ignore")
# Core
env: str = "dev" # dev | test | prod
secret_key: str = "dev-secret-change-me"
base_url: str = "http://localhost:8000" # public URL for action links / transcripts
database_url: str = "postgresql+psycopg://gogo:gogo@localhost:5432/gogo"
# Email (SMTP). provider=console logs instead of sending.
email_provider: str = "console" # console | smtp
smtp_host: str = "localhost"
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_starttls: bool = True
email_from: str = "Gogo Telefon <noreply@gogotelefon.ba>"
# SMS. provider=console logs instead of sending (GSM gateway impl in M5).
sms_provider: str = "console" # console | gsm_gateway
gsm_gateway_url: str = ""
gsm_gateway_user: str = ""
gsm_gateway_password: str = ""
# LLM
anthropic_api_key: str = ""
llm_model: str = "claude-haiku-4-5-20251001" # default; per-tenant override possible
# TTS (decided by Phase-0 PoC; both supported behind TTSProvider)
tts_provider: str = "azure" # azure | elevenlabs
azure_speech_key: str = ""
azure_speech_region: str = "westeurope"
elevenlabs_api_key: str = ""
# Google OAuth (Calendar free/busy)
google_client_id: str = ""
google_client_secret: str = ""
# Proposal engine defaults (overridable per tenant)
proposal_ttl_hours: int = 24
# Retention
audio_retention_days: int = 90
# Session cookies
session_cookie: str = "gogo_session"
session_max_age: int = 60 * 60 * 24 * 14 # 14 days
@lru_cache
def get_settings() -> Settings:
return Settings()

24
gogo/crypto.py Normal file
View File

@@ -0,0 +1,24 @@
"""Symmetric encryption for secrets at rest (OAuth tokens, partner API keys).
Key is derived from GOGO_SECRET_KEY; rotate by re-encrypting after a key change.
"""
import base64
import hashlib
from cryptography.fernet import Fernet
from gogo.config import get_settings
def _fernet() -> Fernet:
key = hashlib.sha256(get_settings().secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt(plaintext: str) -> str:
return _fernet().encrypt(plaintext.encode()).decode()
def decrypt(ciphertext: str) -> str:
return _fernet().decrypt(ciphertext.encode()).decode()

43
gogo/db.py Normal file
View File

@@ -0,0 +1,43 @@
"""Async SQLAlchemy engine/session setup."""
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from gogo.config import get_settings
class Base(DeclarativeBase):
pass
_engine = None
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
def get_engine():
global _engine, _sessionmaker
if _engine is None:
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)
return _engine
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
get_engine()
assert _sessionmaker is not None
return _sessionmaker
async def get_session() -> AsyncIterator[AsyncSession]:
"""FastAPI dependency."""
async with get_sessionmaker()() as session:
yield session
def reset_engine() -> None:
"""Test helper: force re-creation of the engine (e.g. after settings change)."""
global _engine, _sessionmaker
_engine = None
_sessionmaker = None

87
gogo/domain.py Normal file
View File

@@ -0,0 +1,87 @@
"""Shared domain value objects used across providers, agent tools and the proposal engine.
These are deliberately plain (pydantic) models decoupled from the ORM so that
SchedulingProvider implementations and the agent can be tested without a database.
"""
from __future__ import annotations
import enum
from datetime import datetime
from pydantic import BaseModel, Field
class BookingStatus(enum.StrEnum):
pending = "pending"
resolved_by_owner = "resolved_by_owner" # owner contacted client directly (primary flow)
confirmed = "confirmed" # owner confirmed a slot → Gogo sends confirmation SMS
rejected = "rejected"
expired = "expired"
class CallOutcome(enum.StrEnum):
human_answered = "human_answered"
request_created = "request_created"
info_only = "info_only"
message_taken = "message_taken"
abandoned = "abandoned"
class ProviderType(enum.StrEnum):
google_calendar = "google_calendar"
partner_api = "partner_api"
mock = "mock" # in-memory, for tests and demos
class Slot(BaseModel):
"""One concrete offerable time slot (timezone-aware datetimes)."""
start: datetime
end: datetime
staff_id: str | None = None
staff_name: str | None = None
def model_post_init(self, __context) -> None:
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("Slot datetimes must be timezone-aware")
class ServiceInfo(BaseModel):
"""Service as seen by providers / the agent (decoupled from ORM row)."""
id: str # our service UUID as string, or partner service id for catalog sync
name: str
duration_min: int
price_min: float | None = None
price_max: float | None = None
currency: str = "BAM"
home_visit: bool = False
agent_note: str | None = None
active: bool = True
class BookingRequestData(BaseModel):
"""Payload the proposal engine hands to a provider's deliver_request()."""
gogo_request_id: str
tenant_id: str
created_at: datetime
source: str # "voice" | "chat"
client_name: str
client_phone: str
service_id: str | None = None
service_name_raw: str = ""
requested_slots: list[Slot] = Field(default_factory=list) # 0-3, ordered by preference
time_preference_text: str = ""
home_visit: bool = False
address: str | None = None
summary: str = ""
transcript_url: str = ""
dry_run: bool = False
class DeliveryResult(BaseModel):
ok: bool
partner_request_id: str | None = None
detail: str = ""

1
gogo/email/__init__.py Normal file
View File

@@ -0,0 +1 @@
from gogo.email.sender import EmailMessage, get_email_provider, send_email # noqa: F401

92
gogo/email/sender.py Normal file
View File

@@ -0,0 +1,92 @@
"""EmailProvider interface: SMTP for production, console for dev/tests."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from email.message import EmailMessage as MimeMessage
from typing import Protocol
import aiosmtplib
from gogo.config import get_settings
log = logging.getLogger("gogo.email")
@dataclass
class EmailMessage:
to: list[str]
subject: str
text: str
html: str | None = None
# attachments: (filename, mimetype, bytes)
attachments: list[tuple[str, str, bytes]] = field(default_factory=list)
class EmailProvider(Protocol):
async def send(self, msg: EmailMessage) -> None: ...
class ConsoleEmailProvider:
"""Logs emails instead of sending; keeps them in memory for tests."""
sent: list[EmailMessage]
def __init__(self) -> None:
self.sent = []
async def send(self, msg: EmailMessage) -> None:
self.sent.append(msg)
log.info(
"EMAIL to=%s subject=%r attachments=%d\n%s",
msg.to,
msg.subject,
len(msg.attachments),
msg.text,
)
class SmtpEmailProvider:
async def send(self, msg: EmailMessage) -> None:
s = get_settings()
mime = MimeMessage()
mime["From"] = s.email_from
mime["To"] = ", ".join(msg.to)
mime["Subject"] = msg.subject
mime.set_content(msg.text)
if msg.html:
mime.add_alternative(msg.html, subtype="html")
for filename, mimetype, data in msg.attachments:
maintype, subtype = mimetype.split("/", 1)
mime.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
await aiosmtplib.send(
mime,
hostname=s.smtp_host,
port=s.smtp_port,
username=s.smtp_user or None,
password=s.smtp_password or None,
start_tls=s.smtp_starttls,
)
_provider: EmailProvider | None = None
def get_email_provider() -> EmailProvider:
global _provider
if _provider is None:
_provider = (
SmtpEmailProvider() if get_settings().email_provider == "smtp" else ConsoleEmailProvider()
)
return _provider
def set_email_provider(p: EmailProvider | None) -> None:
"""Test hook."""
global _provider
_provider = p
async def send_email(msg: EmailMessage) -> None:
await get_email_provider().send(msg)

79
gogo/hours.py Normal file
View File

@@ -0,0 +1,79 @@
"""Working-hours helpers.
Working hours are stored per tenant as:
{"mon": [["09:00", "13:00"], ["14:00", "18:00"]], ..., "sun": []}
Multiple intervals per day model lunch breaks. Missing/empty day = closed.
All computations happen in the tenant's timezone.
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo
WEEKDAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
# Bosnian day names (Latin) for UI / agent context
DAY_NAMES_BS = {
"mon": "ponedjeljak",
"tue": "utorak",
"wed": "srijeda",
"thu": "četvrtak",
"fri": "petak",
"sat": "subota",
"sun": "nedjelja",
}
DEFAULT_WORKING_HOURS: dict[str, list[list[str]]] = {
"mon": [["09:00", "18:00"]],
"tue": [["09:00", "18:00"]],
"wed": [["09:00", "18:00"]],
"thu": [["09:00", "18:00"]],
"fri": [["09:00", "18:00"]],
"sat": [["09:00", "14:00"]],
"sun": [],
}
def _parse_hhmm(s: str) -> time:
h, m = s.split(":")
return time(int(h), int(m))
def day_intervals(
working_hours: dict, day: date, tz: ZoneInfo
) -> list[tuple[datetime, datetime]]:
"""Open intervals for a calendar day as tz-aware datetimes."""
key = WEEKDAY_KEYS[day.weekday()]
out = []
for start_s, end_s in working_hours.get(key, []):
start = datetime.combine(day, _parse_hhmm(start_s), tzinfo=tz)
end = datetime.combine(day, _parse_hhmm(end_s), tzinfo=tz)
if end > start:
out.append((start, end))
return out
def is_open_at(working_hours: dict, at: datetime, tz: ZoneInfo) -> bool:
at = at.astimezone(tz)
return any(s <= at < e for s, e in day_intervals(working_hours, at.date(), tz))
def hours_summary_bs(working_hours: dict) -> str:
"""Human-readable Bosnian working-hours summary for prompts/emails."""
parts = []
for key in WEEKDAY_KEYS:
intervals = working_hours.get(key, [])
if not intervals:
parts.append(f"{DAY_NAMES_BS[key]}: zatvoreno")
else:
spans = ", ".join(f"{a}{b}" for a, b in intervals)
parts.append(f"{DAY_NAMES_BS[key]}: {spans}")
return "; ".join(parts)
def iter_days(start: date, end: date):
d = start
while d <= end:
yield d
d += timedelta(days=1)

65
gogo/i18n.py Normal file
View File

@@ -0,0 +1,65 @@
"""Bosnian (Latin) formatting helpers for client- and owner-facing text."""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
DAY_NAMES = [
"ponedjeljak",
"utorak",
"srijeda",
"četvrtak",
"petak",
"subota",
"nedjelja",
]
MONTH_NAMES = [
"januar",
"februar",
"mart",
"april",
"maj",
"juni",
"juli",
"august",
"septembar",
"oktobar",
"novembar",
"decembar",
]
def fmt_slot(dt: datetime, tz: str = "Europe/Sarajevo") -> str:
"""'srijeda, 15.07. u 17:00'"""
local = dt.astimezone(ZoneInfo(tz))
day = DAY_NAMES[local.weekday()]
return f"{day}, {local.day:02d}.{local.month:02d}. u {local:%H:%M}"
def fmt_date(dt: datetime, tz: str = "Europe/Sarajevo") -> str:
local = dt.astimezone(ZoneInfo(tz))
return f"{local.day:02d}.{local.month:02d}.{local.year}."
def fmt_time(dt: datetime, tz: str = "Europe/Sarajevo") -> str:
local = dt.astimezone(ZoneInfo(tz))
return f"{local:%H:%M}"
def fmt_price(price_min: float | None, price_max: float | None, currency: str = "KM") -> str:
cur = "KM" if currency == "BAM" else currency
if price_min is None and price_max is None:
return "cijena na upit"
if price_max is None or price_min == price_max:
return f"{_num(price_min)} {cur}"
if price_min is None:
return f"do {_num(price_max)} {cur}"
return f"{_num(price_min)}{_num(price_max)} {cur}"
def _num(x: float | None) -> str:
if x is None:
return "?"
return str(int(x)) if float(x).is_integer() else f"{x:.2f}".replace(".", ",")

110
gogo/jobs.py Normal file
View File

@@ -0,0 +1,110 @@
"""Background jobs: proposal expiry/reminders, partner polling fallback, retention.
APScheduler (in-process) — no extra infra needed for MVP scale (§4).
"""
from __future__ import annotations
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select
from gogo.db import get_sessionmaker
from gogo.domain import BookingStatus, Slot
from gogo.models import BookingRequest, Tenant
from gogo.proposals import engine as proposal_engine
from gogo.scheduling.base import get_provider
log = logging.getLogger("gogo.jobs")
async def run_expiry_job() -> None:
async with get_sessionmaker()() as session:
expired = await proposal_engine.process_expirations(session)
await session.commit()
if expired:
log.info("expired %d proposals", expired)
async def run_partner_polling_job() -> None:
"""Polling fallback (§B.3) for partner tenants that cannot call webhooks."""
async with get_sessionmaker()() as session:
pending = (
(
await session.execute(
select(BookingRequest).where(
BookingRequest.status == BookingStatus.pending.value,
BookingRequest.partner_request_id.is_not(None),
)
)
)
.scalars()
.all()
)
for req in pending:
tenant = (
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
).scalar_one()
if tenant.scheduling_provider != "partner_api":
continue
provider = await get_provider(session, tenant)
if not provider.config.get("polling_fallback"):
continue
try:
data = await provider.poll_status(req.partner_request_id)
except Exception: # noqa: BLE001
log.exception("polling failed for request %s", req.id)
continue
status = data.get("status")
if status == "confirmed" and data.get("confirmed_slot"):
slot = Slot.model_validate(data["confirmed_slot"])
await proposal_engine.confirm_request(session, tenant, req, slot, recheck=False)
elif status == "resolved":
await proposal_engine.resolve_request(session, tenant, req)
elif status == "rejected":
await proposal_engine.reject_request(session, tenant, req)
await session.commit()
async def run_retention_job() -> None:
"""Delete call audio older than retention window; keep transcripts (§5.4)."""
import os
from datetime import UTC, datetime, timedelta
from gogo.config import get_settings
from gogo.models import Call
cutoff = datetime.now(UTC) - timedelta(days=get_settings().audio_retention_days)
async with get_sessionmaker()() as session:
rows = (
(
await session.execute(
select(Call).where(
Call.recording_path.is_not(None), Call.started_at < cutoff
)
)
)
.scalars()
.all()
)
for call in rows:
path = call.recording_path
if path and os.path.exists(path):
try:
os.remove(path)
except OSError:
log.exception("failed to delete recording %s", path)
continue
call.recording_path = None
await session.commit()
if rows:
log.info("retention: cleared %d recordings", len(rows))
def build_scheduler() -> AsyncIOScheduler:
scheduler = AsyncIOScheduler()
scheduler.add_job(run_expiry_job, "interval", minutes=5, id="proposal_expiry")
scheduler.add_job(run_partner_polling_job, "interval", minutes=5, id="partner_polling")
scheduler.add_job(run_retention_job, "cron", hour=4, minute=0, id="retention")
return scheduler

39
gogo/main.py Normal file
View File

@@ -0,0 +1,39 @@
"""FastAPI application assembly."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from gogo.api import actions, health, webhooks
from gogo.config import get_settings
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
)
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = None
if get_settings().env != "test":
from gogo.jobs import build_scheduler
scheduler = build_scheduler()
scheduler.start()
yield
if scheduler:
scheduler.shutdown(wait=False)
def create_app() -> FastAPI:
app = FastAPI(title="Gogo Telefon", docs_url=None, redoc_url=None, lifespan=lifespan)
app.include_router(health.router)
app.include_router(actions.router)
app.include_router(webhooks.router)
return app
app = create_app()

359
gogo/models.py Normal file
View File

@@ -0,0 +1,359 @@
"""ORM models — core tables per spec §13."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
Uuid,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from gogo.db import Base
JSONVariant = JSON().with_variant(JSONB(), "postgresql")
def utcnow() -> datetime:
return datetime.now(UTC)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
class Tenant(Base, TimestampMixin):
"""A salon."""
__tablename__ = "tenants"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
slug: Mapped[str] = mapped_column(String(80), unique=True) # short id used in URLs
address: Mapped[str] = mapped_column(String(300), default="")
city: Mapped[str] = mapped_column(String(100), default="")
phone: Mapped[str] = mapped_column(String(40), default="") # salon's own public number
website: Mapped[str] = mapped_column(String(200), default="")
locale: Mapped[str] = mapped_column(String(20), default="bs-Latn-BA")
timezone: Mapped[str] = mapped_column(String(50), default="Europe/Sarajevo")
currency: Mapped[str] = mapped_column(String(10), default="BAM")
# {"mon": [["09:00","13:00"],["14:00","18:00"]], ..., "sun": []}
working_hours: Mapped[dict] = mapped_column(JSONVariant, default=dict)
scheduling_provider: Mapped[str] = mapped_column(String(30), default="google_calendar")
# Plan / metering (§12)
plan: Mapped[str] = mapped_column(String(40), default="gogo_start")
included_minutes: Mapped[int] = mapped_column(Integer, default=300)
hard_cutoff: Mapped[bool] = mapped_column(Boolean, default=False)
paid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active") # active | disabled
# Agent config (§10.5)
tts_voice: Mapped[str] = mapped_column(String(80), default="")
price_mode: Mapped[str] = mapped_column(String(20), default="exact") # exact|range|on_request
agent_notes: Mapped[str] = mapped_column(Text, default="") # plain informational notes
llm_model: Mapped[str] = mapped_column(String(80), default="") # empty = global default
# Proposal engine
proposal_ttl_hours: Mapped[int] = mapped_column(Integer, default=24)
notify_emails: Mapped[list] = mapped_column(JSONVariant, default=list) # owner emails
sms_request_received: Mapped[bool] = mapped_column(Boolean, default=False)
sms_templates: Mapped[dict] = mapped_column(JSONVariant, default=dict) # overrides only
# Google Calendar tenants
min_notice_hours: Mapped[int] = mapped_column(Integer, default=2)
max_days_ahead: Mapped[int] = mapped_column(Integer, default=14)
# Telephony
ring_first_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
ring_timeout_s: Mapped[int] = mapped_column(Integer, default=12)
ring_strategy: Mapped[str] = mapped_column(String(20), default="ring_all") # ring_all|sequential
# Chat widget
widget_public_key: Mapped[str] = mapped_column(
String(64), default=lambda: uuid.uuid4().hex, unique=True
)
services: Mapped[list[Service]] = relationship(back_populates="tenant")
class ProviderConfig(Base, TimestampMixin):
__tablename__ = "provider_configs"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
provider_type: Mapped[str] = mapped_column(String(30))
# partner_api: {base_url, api_key_encrypted, webhook_secret_encrypted,
# catalog_sync, email_to_owner, polling_fallback}
# google_calendar: {} (connection lives in calendar_connections)
config: Mapped[dict] = mapped_column(JSONVariant, default=dict)
class Service(Base, TimestampMixin):
__tablename__ = "services"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
name: Mapped[str] = mapped_column(String(200))
duration_min: Mapped[int] = mapped_column(Integer, default=30)
price_min: Mapped[float | None] = mapped_column(Float, nullable=True)
price_max: Mapped[float | None] = mapped_column(Float, nullable=True)
home_visit: Mapped[bool] = mapped_column(Boolean, default=False)
agent_note: Mapped[str] = mapped_column(Text, default="")
buffer_min: Mapped[int] = mapped_column(Integer, default=0) # per-service buffer (GCal)
partner_service_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
tenant: Mapped[Tenant] = relationship(back_populates="services")
class CalendarConnection(Base, TimestampMixin):
__tablename__ = "calendar_connections"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
google_account: Mapped[str] = mapped_column(String(200))
scopes: Mapped[list] = mapped_column(JSONVariant, default=list)
token_data_encrypted: Mapped[str] = mapped_column(Text) # Fernet-encrypted OAuth tokens
status: Mapped[str] = mapped_column(String(20), default="connected")
class CalendarMapping(Base):
"""service → google calendar id. A row with service_id NULL is the tenant default."""
__tablename__ = "calendar_mappings"
__table_args__ = (UniqueConstraint("tenant_id", "service_id"),)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
service_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("services.id"), nullable=True
)
google_calendar_id: Mapped[str] = mapped_column(String(300))
class Worker(Base, TimestampMixin):
"""Ring-group member — exists only as a SIP endpoint, no user account (§5.3)."""
__tablename__ = "workers"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
name: Mapped[str] = mapped_column(String(100))
sip_username: Mapped[str] = mapped_column(String(80), unique=True)
sip_password: Mapped[str] = mapped_column(String(80)) # provisioned to softphone via QR
active: Mapped[bool] = mapped_column(Boolean, default=True)
class PhoneNumber(Base, TimestampMixin):
"""Gogo SIM number assigned to a salon; maps to a GSM gateway port (§5.1)."""
__tablename__ = "phone_numbers"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("tenants.id"), nullable=True, unique=True
)
msisdn: Mapped[str] = mapped_column(String(30), unique=True)
gateway_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
operator: Mapped[str] = mapped_column(String(30), default="") # mtel|bhtelecom|eronet
sim_status: Mapped[str] = mapped_column(String(20), default="unassigned")
class Call(Base):
__tablename__ = "calls"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
caller_msisdn: Mapped[str] = mapped_column(String(30), default="")
duration_s: Mapped[int] = mapped_column(Integer, default=0)
outcome: Mapped[str] = mapped_column(String(30), default="abandoned")
recording_path: Mapped[str | None] = mapped_column(String(400), nullable=True)
transcript: Mapped[list] = mapped_column(JSONVariant, default=list) # [{role, text, t}]
agent_seconds_charged: Mapped[int] = mapped_column(Integer, default=0)
trace: Mapped[dict] = mapped_column(JSONVariant, default=dict) # latency breakdown etc.
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
resume_token: Mapped[str] = mapped_column(String(64), default=lambda: uuid.uuid4().hex)
client_ip: Mapped[str] = mapped_column(String(60), default="")
outcome: Mapped[str] = mapped_column(String(30), default="")
class ChatMessage(Base):
__tablename__ = "chat_messages"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("chat_sessions.id"), index=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
role: Mapped[str] = mapped_column(String(20)) # user | assistant | tool
content: Mapped[str] = mapped_column(Text)
class BookingRequest(Base, TimestampMixin):
__tablename__ = "booking_requests"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
source: Mapped[str] = mapped_column(String(10)) # voice | chat
client_name: Mapped[str] = mapped_column(String(200))
client_phone: Mapped[str] = mapped_column(String(40))
service_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("services.id"), nullable=True)
service_name_raw: Mapped[str] = mapped_column(String(300), default="")
slots: Mapped[list] = mapped_column(JSONVariant, default=list) # ordered Slot dicts
time_preference_text: Mapped[str] = mapped_column(String(300), default="")
home_visit: Mapped[bool] = mapped_column(Boolean, default=False)
address: Mapped[str | None] = mapped_column(String(300), nullable=True)
summary: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(30), default="pending", index=True)
confirmed_slot: Mapped[dict | None] = mapped_column(JSONVariant, nullable=True)
partner_request_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
call_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("calls.id"), nullable=True)
chat_session_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("chat_sessions.id"), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reminder_sent: Mapped[bool] = mapped_column(Boolean, default=False)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolution_note: Mapped[str] = mapped_column(Text, default="")
class ActionToken(Base):
"""Signed single-use action link tokens for proposal emails (§9.1)."""
__tablename__ = "action_tokens"
token: Mapped[str] = mapped_column(String(64), primary_key=True)
request_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("booking_requests.id"), index=True)
action: Mapped[str] = mapped_column(String(30)) # resolve | confirm | reject
slot_index: Mapped[int | None] = mapped_column(Integer, nullable=True) # for confirm
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class MessageForOwner(Base):
"""Non-booking messages taken by the agent (take_message tool)."""
__tablename__ = "messages_for_owner"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
client_name: Mapped[str] = mapped_column(String(200), default="")
client_phone: Mapped[str] = mapped_column(String(40), default="")
text: Mapped[str] = mapped_column(Text)
source: Mapped[str] = mapped_column(String(10), default="voice")
read: Mapped[bool] = mapped_column(Boolean, default=False)
class SmsLog(Base):
__tablename__ = "sms_log"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
to_msisdn: Mapped[str] = mapped_column(String(30))
body: Mapped[str] = mapped_column(Text)
kind: Mapped[str] = mapped_column(String(30)) # missed_call|received|confirmation|rejection|expiry
status: Mapped[str] = mapped_column(String(20), default="queued") # queued|sent|failed
error: Mapped[str] = mapped_column(Text, default="")
class EmailLog(Base):
__tablename__ = "email_log"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tenants.id"), nullable=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
to_addr: Mapped[str] = mapped_column(String(300))
subject: Mapped[str] = mapped_column(String(400))
kind: Mapped[str] = mapped_column(String(30))
status: Mapped[str] = mapped_column(String(20), default="queued")
error: Mapped[str] = mapped_column(Text, default="")
class UsageCounter(Base):
"""Per tenant per month usage (§12). month format: 'YYYY-MM'."""
__tablename__ = "usage_counters"
__table_args__ = (UniqueConstraint("tenant_id", "month"),)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
month: Mapped[str] = mapped_column(String(7))
agent_seconds: Mapped[int] = mapped_column(Integer, default=0)
calls: Mapped[int] = mapped_column(Integer, default=0)
sms_sent: Mapped[int] = mapped_column(Integer, default=0)
chat_sessions: Mapped[int] = mapped_column(Integer, default=0)
warned_80: Mapped[bool] = mapped_column(Boolean, default=False)
warned_100: Mapped[bool] = mapped_column(Boolean, default=False)
class User(Base, TimestampMixin):
"""Owner account — single account per salon (§10)."""
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
email: Mapped[str] = mapped_column(String(200), unique=True)
password_hash: Mapped[str] = mapped_column(String(200))
active: Mapped[bool] = mapped_column(Boolean, default=True)
class Admin(Base, TimestampMixin):
__tablename__ = "admins"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(200), unique=True)
password_hash: Mapped[str] = mapped_column(String(200))
active: Mapped[bool] = mapped_column(Boolean, default=True)
class PromptTemplate(Base):
"""Versioned global agent prompt template, super-admin owned (§6.3)."""
__tablename__ = "prompt_templates"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
version: Mapped[int] = mapped_column(Integer, unique=True)
body: Mapped[str] = mapped_column(Text)
notes: Mapped[str] = mapped_column(Text, default="")
published: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class TenantPromptOverride(Base):
"""Per-tenant prompt override — super-admin-only escape hatch (§6.3)."""
__tablename__ = "tenant_prompt_overrides"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
body: Mapped[str] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)

View File

217
gogo/proposals/email.py Normal file
View File

@@ -0,0 +1,217 @@
"""Proposal email to the owner: subject, body, action links, .ics (§9.1)."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.config import get_settings
from gogo.domain import Slot
from gogo.email import EmailMessage, send_email
from gogo.i18n import fmt_slot
from gogo.models import BookingRequest, EmailLog, Service, Tenant
from gogo.proposals.ics import build_ics
from gogo.proposals.tokens import action_url, create_action_token
def _slots_from_request(req: BookingRequest) -> list[Slot]:
return [Slot.model_validate(s) for s in (req.slots or [])]
async def send_proposal_email(
session: AsyncSession, tenant: Tenant, req: BookingRequest, service: Service | None
) -> None:
recipients = list(tenant.notify_emails or [])
if not recipients:
return
slots = _slots_from_request(req)
service_name = service.name if service else (req.service_name_raw or "termin")
days = ", ".join(sorted({fmt_slot(s.start, tenant.timezone).split(",")[0] for s in slots}))
subject = f"Novi zahtjev za termin — {service_name}{req.client_name}"
if days:
subject += f" ({days})"
resolve_url = action_url(await create_action_token(session, req.id, "resolve"))
reject_url = action_url(await create_action_token(session, req.id, "reject"))
confirm_urls = [
(
fmt_slot(s.start, tenant.timezone),
action_url(await create_action_token(session, req.id, "confirm", slot_index=i)),
)
for i, s in enumerate(slots)
]
transcript_url = f"{get_settings().base_url}/t/{req.id}"
lines = [
f"Novi zahtjev za termin — {tenant.name}",
"",
f"Klijent: {req.client_name}",
f"Telefon: {req.client_phone}",
f"Usluga: {service_name}",
]
if req.home_visit:
lines.append(f"Dolazak na adresu: DA — {req.address or 'adresa nije navedena'}")
if slots:
lines.append("Traženi termini (po redoslijedu želje):")
lines += [f" {i + 1}. {fmt_slot(s.start, tenant.timezone)}" for i, s in enumerate(slots)]
if req.time_preference_text and not slots:
lines.append(f"Željeno vrijeme: {req.time_preference_text}")
if req.summary:
lines += ["", f"Sažetak razgovora: {req.summary}"]
lines += [
"",
f"Cijeli razgovor: {transcript_url}",
"",
"" * 40,
f"RIJEŠENO — kontaktirao/la sam klijenta:\n {resolve_url}",
]
for label, url in confirm_urls:
lines.append(f"POTVRDI {label} + pošalji SMS klijentu:\n {url}")
lines.append(f"ODBIJ zahtjev:\n {reject_url}")
html = _render_html(
tenant, req, service_name, slots, resolve_url, confirm_urls, reject_url, transcript_url
)
attachments = []
if slots:
first = slots[0]
ics = build_ics(
uid=str(req.id),
start=first.start,
end=first.end,
summary=f"{service_name}{req.client_name} (zahtjev)",
description=(
f"Zahtjev putem Gogo Telefona.\nKlijent: {req.client_name}, {req.client_phone}\n"
f"{req.summary}"
),
)
attachments.append(("termin.ics", "text/calendar", ics))
await send_email(EmailMessage(recipients, subject, "\n".join(lines), html, attachments))
session.add(
EmailLog(
tenant_id=tenant.id,
to_addr=", ".join(recipients),
subject=subject,
kind="proposal",
status="sent",
)
)
def _render_html(
tenant: Tenant,
req: BookingRequest,
service_name: str,
slots: list[Slot],
resolve_url: str,
confirm_urls: list[tuple[str, str]],
reject_url: str,
transcript_url: str,
) -> str:
def esc(s: str) -> str:
return (
str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
)
slot_rows = "".join(
f"<li>{esc(fmt_slot(s.start, tenant.timezone))}</li>" for s in slots
)
confirm_buttons = "".join(
f'<p><a href="{url}" style="background:#2563eb;color:#fff;padding:10px 16px;'
f'border-radius:6px;text-decoration:none;display:inline-block">'
f"Potvrdi {esc(label)} + SMS</a></p>"
for label, url in confirm_urls
)
home = (
f"<p><b>Dolazak na adresu:</b> DA — {esc(req.address or 'adresa nije navedena')}</p>"
if req.home_visit
else ""
)
pref = (
f"<p><b>Željeno vrijeme:</b> {esc(req.time_preference_text)}</p>"
if req.time_preference_text and not slots
else ""
)
summary = f"<p><b>Sažetak:</b> {esc(req.summary)}</p>" if req.summary else ""
return f"""
<div style="font-family:sans-serif;max-width:560px">
<h2 style="margin-bottom:4px">Novi zahtjev za termin</h2>
<p style="color:#666;margin-top:0">{esc(tenant.name)}</p>
<p><b>Klijent:</b> {esc(req.client_name)}<br>
<b>Telefon:</b> {esc(req.client_phone)}<br>
<b>Usluga:</b> {esc(service_name)}</p>
{home}
{"<p><b>Traženi termini:</b></p><ol>" + slot_rows + "</ol>" if slots else ""}
{pref}
{summary}
<p><a href="{transcript_url}">Cijeli razgovor →</a></p>
<hr>
<p><a href="{resolve_url}" style="background:#16a34a;color:#fff;padding:12px 20px;
border-radius:6px;text-decoration:none;display:inline-block;font-weight:bold">
✓ Riješeno — kontaktirao/la sam klijenta</a></p>
{confirm_buttons}
<p><a href="{reject_url}" style="color:#dc2626">Odbij zahtjev</a></p>
<p style="color:#999;font-size:12px">Gogo Telefon — virtuelni asistent salona.
U prilogu je .ics za prvi traženi termin (možete ga pomjeriti prije spremanja u kalendar).</p>
</div>
"""
async def send_reminder_email(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None:
"""Reminder at 50% of proposal TTL (§9.2)."""
recipients = list(tenant.notify_emails or [])
if not recipients:
return
subject = f"Podsjetnik: neodgovoren zahtjev — {req.client_name}"
ttl = tenant.proposal_ttl_hours or 24
body = (
f"Zahtjev klijenta {req.client_name} ({req.client_phone}) čeka odgovor.\n"
f"Ako ne odgovorite u roku od {ttl // 2}h, zahtjev ističe i klijent dobija "
f"SMS s molbom da nazove ponovo.\n\n"
f"Pregled: {get_settings().base_url}/t/{req.id}"
)
await send_email(EmailMessage(recipients, subject, body))
session.add(
EmailLog(
tenant_id=tenant.id,
to_addr=", ".join(recipients),
subject=subject,
kind="proposal_reminder",
status="sent",
)
)
async def send_usage_warning_email(
session: AsyncSession, tenant: Tenant, used_minutes: int, pct: int
) -> None:
recipients = list(tenant.notify_emails or [])
if not recipients:
return
subject = f"Gogo Telefon: iskorišteno {pct}% minuta ovaj mjesec"
body = (
f"Salon {tenant.name} je iskoristio {used_minutes} od {tenant.included_minutes} "
f"uključenih minuta virtualnog asistenta ovaj mjesec.\n"
"Asistent nastavlja odgovarati na pozive. Za veći paket javite se Gogo podršci."
)
await send_email(EmailMessage(recipients, subject, body))
session.add(
EmailLog(
tenant_id=tenant.id,
to_addr=", ".join(recipients),
subject=subject,
kind="usage_warning",
status="sent",
)
)
def now_utc() -> datetime:
from datetime import UTC
return datetime.now(UTC)

251
gogo/proposals/engine.py Normal file
View File

@@ -0,0 +1,251 @@
"""Proposal engine (§9): create booking requests, route delivery through the
tenant's scheduling provider, drive state transitions and client SMS.
States: pending → resolved_by_owner | confirmed | rejected | expired
The same transition functions are used by email action links, the dashboard,
and the partner webhook — semantics are identical everywhere (§B.3).
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.config import get_settings
from gogo.domain import BookingRequestData, BookingStatus, DeliveryResult, Slot
from gogo.i18n import fmt_date, fmt_slot, fmt_time
from gogo.models import BookingRequest, Service, Tenant, utcnow
from gogo.scheduling.base import get_provider
from gogo.sms import send_sms
from gogo.sms.templates import render_sms
log = logging.getLogger("gogo.proposals")
class TransitionError(Exception):
"""Invalid state transition (e.g. confirming an already-rejected request)."""
async def create_booking_request(
session: AsyncSession,
tenant: Tenant,
*,
source: str,
client_name: str,
client_phone: str,
service_id: str | None = None,
service_name_raw: str = "",
slots: list[Slot] | None = None,
time_preference_text: str = "",
home_visit: bool = False,
address: str | None = None,
summary: str = "",
call_id: uuid.UUID | None = None,
chat_session_id: uuid.UUID | None = None,
) -> tuple[BookingRequest, DeliveryResult]:
"""Create a pending request and deliver it via the tenant's provider."""
slots = (slots or [])[:3] # hard cap: at most 3 offered slots (§6.2)
now = utcnow()
req = BookingRequest(
tenant_id=tenant.id,
source=source,
client_name=client_name.strip(),
client_phone=client_phone.strip(),
service_id=uuid.UUID(service_id) if service_id else None,
service_name_raw=service_name_raw,
slots=[s.model_dump(mode="json") for s in slots],
time_preference_text=time_preference_text,
home_visit=home_visit,
address=address,
summary=summary,
call_id=call_id,
chat_session_id=chat_session_id,
status=BookingStatus.pending.value,
expires_at=now + timedelta(hours=tenant.proposal_ttl_hours or 24),
)
session.add(req)
await session.flush() # assign req.id
data = BookingRequestData(
gogo_request_id=str(req.id),
tenant_id=str(tenant.id),
created_at=now,
source=source,
client_name=req.client_name,
client_phone=req.client_phone,
service_id=service_id,
service_name_raw=service_name_raw,
requested_slots=slots,
time_preference_text=time_preference_text,
home_visit=home_visit,
address=address,
summary=summary,
transcript_url=f"{get_settings().base_url}/t/{req.id}",
)
provider = await get_provider(session, tenant)
result = await provider.deliver_request(data)
if not result.ok:
log.error("delivery failed for request %s: %s", req.id, result.detail)
# Optional "request received" SMS to the client (§9.1)
if tenant.sms_request_received and req.client_phone:
await send_sms(
session,
tenant.id,
req.client_phone,
render_sms(tenant, "request_received"),
kind="received",
)
return req, result
# -- transitions -------------------------------------------------------------
async def resolve_request(
session: AsyncSession, tenant: Tenant, req: BookingRequest, note: str = ""
) -> None:
"""Owner contacted the client directly — primary flow. Gogo sends NO SMS (§9.2)."""
_require_pending(req, allow_same=BookingStatus.resolved_by_owner)
if req.status != BookingStatus.pending.value:
return # idempotent replay
req.status = BookingStatus.resolved_by_owner.value
req.resolved_at = utcnow()
req.resolution_note = note
async def confirm_request(
session: AsyncSession,
tenant: Tenant,
req: BookingRequest,
slot: Slot,
*,
recheck: bool = True,
) -> bool:
"""Owner confirms a slot → confirmation SMS to client.
Returns False (no transition) if recheck finds the slot busy — the caller
should show a warning and let the owner decide (force with recheck=False).
"""
_require_pending(req, allow_same=BookingStatus.confirmed)
if req.status == BookingStatus.confirmed.value:
return True # idempotent replay
if recheck:
provider = await get_provider(session, tenant)
checker = getattr(provider, "is_slot_free", None)
if checker is not None:
try:
free = await checker(
str(req.service_id) if req.service_id else None, slot
)
except Exception: # noqa: BLE001 — recheck is best-effort
log.exception("free/busy recheck failed for %s", req.id)
free = True
if not free:
return False
req.status = BookingStatus.confirmed.value
req.confirmed_slot = slot.model_dump(mode="json")
req.resolved_at = utcnow()
service_name = await _service_name(session, req)
day_name = fmt_slot(slot.start, tenant.timezone).split(",")[0]
body = render_sms(
tenant,
"confirmation",
usluga=service_name,
dan=day_name,
datum=fmt_date(slot.start, tenant.timezone),
vrijeme=fmt_time(slot.start, tenant.timezone),
)
if req.client_phone:
await send_sms(session, tenant.id, req.client_phone, body, kind="confirmation")
return True
async def reject_request(
session: AsyncSession, tenant: Tenant, req: BookingRequest, custom_sms: str | None = None
) -> None:
"""Reject → rejection SMS to client (template, editable before send §9.2)."""
_require_pending(req, allow_same=BookingStatus.rejected)
if req.status == BookingStatus.rejected.value:
return # idempotent replay
req.status = BookingStatus.rejected.value
req.resolved_at = utcnow()
body = custom_sms or render_sms(tenant, "rejection")
if req.client_phone:
await send_sms(session, tenant.id, req.client_phone, body, kind="rejection")
async def expire_request(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None:
"""TTL passed with no owner action → apology SMS to client (§9.2)."""
if req.status != BookingStatus.pending.value:
return
req.status = BookingStatus.expired.value
req.resolved_at = utcnow()
if req.client_phone:
await send_sms(
session, tenant.id, req.client_phone, render_sms(tenant, "expiry"), kind="expiry"
)
def _require_pending(req: BookingRequest, allow_same: BookingStatus) -> None:
if req.status not in (BookingStatus.pending.value, allow_same.value):
raise TransitionError(
f"request {req.id} is {req.status}, cannot transition to {allow_same.value}"
)
async def _service_name(session: AsyncSession, req: BookingRequest) -> str:
if req.service_id:
service = (
await session.execute(select(Service).where(Service.id == req.service_id))
).scalar_one_or_none()
if service:
return service.name
return req.service_name_raw or "termin"
# -- background jobs ---------------------------------------------------------
async def process_expirations(session: AsyncSession) -> int:
"""Expire overdue pending requests; send owner reminders at 50% TTL. Returns count expired."""
from gogo.proposals.email import send_reminder_email
now = datetime.now(UTC)
pending = (
(
await session.execute(
select(BookingRequest).where(
BookingRequest.status == BookingStatus.pending.value
)
)
)
.scalars()
.all()
)
expired = 0
for req in pending:
tenant = (
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
).scalar_one()
expires_at = req.expires_at
if expires_at is None:
continue
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at <= now:
await expire_request(session, tenant, req)
expired += 1
else:
ttl = timedelta(hours=tenant.proposal_ttl_hours or 24)
if not req.reminder_sent and expires_at - now <= ttl / 2:
await send_reminder_email(session, tenant, req)
req.reminder_sent = True
return expired

42
gogo/proposals/ics.py Normal file
View File

@@ -0,0 +1,42 @@
"""Generate the .ics attachment (METHOD:REQUEST) for the first-choice slot (§9.1).
The .ics is a convenience so Gmail offers "Add to calendar" — the owner can move
and rearrange the event before saving. Gogo never writes to the owner's calendar.
"""
from __future__ import annotations
from datetime import datetime
from icalendar import Calendar, Event, vCalAddress, vText
from gogo.config import get_settings
def build_ics(
*,
uid: str,
start: datetime,
end: datetime,
summary: str,
description: str,
organizer_email: str = "noreply@gogotelefon.ba",
) -> bytes:
cal = Calendar()
cal.add("prodid", "-//Gogo Telefon//gogotelefon.ba//BS")
cal.add("version", "2.0")
cal.add("method", "REQUEST")
ev = Event()
ev.add("uid", f"{uid}@gogotelefon.ba")
ev.add("dtstart", start)
ev.add("dtend", end)
ev.add("summary", summary)
ev.add("description", description)
ev.add("status", "TENTATIVE")
organizer = vCalAddress(f"MAILTO:{organizer_email}")
organizer.params["cn"] = vText("Gogo Telefon")
ev["organizer"] = organizer
ev.add("url", get_settings().base_url)
cal.add_component(ev)
return cal.to_ical()

44
gogo/proposals/tokens.py Normal file
View File

@@ -0,0 +1,44 @@
"""Single-use signed action tokens for proposal email links (§9.1).
Tokens are random, stored in DB (single-use enforced there) and the URL carries
an itsdangerous signature so guessing/forging is infeasible even if the DB row
leaked. Action links must be idempotent (§15): a used 'resolve' link re-visited
shows "already resolved", it never errors or double-fires.
"""
from __future__ import annotations
import secrets
import uuid
from itsdangerous import BadSignature, URLSafeSerializer
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.config import get_settings
from gogo.models import ActionToken
def _serializer() -> URLSafeSerializer:
return URLSafeSerializer(get_settings().secret_key, salt="proposal-action")
async def create_action_token(
session: AsyncSession, request_id: uuid.UUID, action: str, slot_index: int | None = None
) -> str:
"""Create a DB-backed token and return the signed URL-safe token string."""
raw = secrets.token_urlsafe(24)[:40]
session.add(
ActionToken(token=raw, request_id=request_id, action=action, slot_index=slot_index)
)
return _serializer().dumps(raw)
def action_url(signed: str) -> str:
return f"{get_settings().base_url}/a/{signed}"
def unsign(signed: str) -> str | None:
try:
return _serializer().loads(signed)
except BadSignature:
return None

View File

@@ -0,0 +1 @@
from gogo.scheduling.base import SchedulingProvider, get_provider # noqa: F401

72
gogo/scheduling/base.py Normal file
View File

@@ -0,0 +1,72 @@
"""SchedulingProvider interface (§8) and per-tenant provider resolution.
Adding a third provider = one new module registering itself here. No changes
to the agent, tools, or proposal engine are needed.
"""
from __future__ import annotations
from datetime import date
from typing import Protocol, runtime_checkable
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
from gogo.models import ProviderConfig, Tenant
@runtime_checkable
class SchedulingProvider(Protocol):
"""Availability lookup + proposal delivery for one tenant."""
async def get_services(self) -> list[ServiceInfo] | None:
"""Optional catalog sync; None = provider has no catalog."""
...
async def get_availability(
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
) -> list[Slot]:
"""Ready-to-offer free slots. The agent picks up to 3."""
...
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
"""Deliver the proposal to the owner (email / partner push).
Outcome is signaled back via webhook/polling (partner) or the owner's
email/dashboard actions (google_calendar, mock).
"""
...
_REGISTRY: dict[str, type] = {}
def register_provider(name: str):
def deco(cls):
_REGISTRY[name] = cls
return cls
return deco
async def get_provider(session: AsyncSession, tenant: Tenant) -> SchedulingProvider:
"""Instantiate the tenant's configured provider."""
# Imports here to avoid circulars; modules self-register on import.
from gogo.scheduling import google_calendar, mock, partner_api # noqa: F401
cls = _REGISTRY.get(tenant.scheduling_provider)
if cls is None:
raise ValueError(f"Unknown scheduling provider: {tenant.scheduling_provider}")
config = await _load_config(session, tenant)
return cls(session=session, tenant=tenant, config=config)
async def _load_config(session: AsyncSession, tenant: Tenant) -> dict:
from sqlalchemy import select
row = (
await session.execute(
select(ProviderConfig).where(ProviderConfig.tenant_id == tenant.id)
)
).scalar_one_or_none()
return row.config if row else {}

View File

@@ -0,0 +1,211 @@
"""Google Calendar provider (§8.1) — free/busy READ ONLY.
Gogo never requests calendar write access and never creates/modifies events.
Availability = tenant working hours minus busy blocks from the mapped calendar,
quantized to service duration. Delivery = owner email with action links + .ics.
Uses raw HTTP (httpx) against the Calendar v3 freeBusy endpoint + OAuth token
refresh — no heavy Google SDK, easy to point at a fake server in tests.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import UTC, date, datetime, timedelta
from zoneinfo import ZoneInfo
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.crypto import decrypt, encrypt
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
from gogo.hours import DEFAULT_WORKING_HOURS
from gogo.models import (
BookingRequest,
CalendarConnection,
CalendarMapping,
Service,
Tenant,
utcnow,
)
from gogo.scheduling.base import register_provider
from gogo.scheduling.slots import compute_slots
log = logging.getLogger("gogo.gcal")
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_FREEBUSY_URL = "https://www.googleapis.com/calendar/v3/freeBusy"
@register_provider("google_calendar")
class GoogleCalendarProvider:
# test hooks: override endpoints / freeze time
token_url: str = GOOGLE_TOKEN_URL
freebusy_url: str = GOOGLE_FREEBUSY_URL
now_override: datetime | None = None
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
self.session = session
self.tenant = tenant
self.config = config
async def get_services(self) -> list[ServiceInfo] | None:
return None # services are maintained in the Gogo dashboard
async def get_availability(
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
) -> list[Slot]:
service = (
await self.session.execute(
select(Service).where(Service.id == uuid.UUID(service_id))
)
).scalar_one_or_none()
duration = service.duration_min if service else 30
buffer_min = service.buffer_min if service else 0
tz = ZoneInfo(self.tenant.timezone)
calendar_id = await self._calendar_for_service(service)
busy = await self.fetch_busy(calendar_id, date_from, date_to, tz)
return compute_slots(
working_hours=self.tenant.working_hours or DEFAULT_WORKING_HOURS,
busy=busy,
duration_min=duration,
buffer_min=buffer_min,
date_from=date_from,
date_to=date_to,
tz=tz,
now=self.now_override or utcnow(),
min_notice_hours=self.tenant.min_notice_hours,
max_days_ahead=self.tenant.max_days_ahead,
)
async def is_slot_free(self, service_id: str | None, slot: Slot) -> bool:
"""Re-check free/busy before 'confirm + SMS' (§9.2)."""
service = None
if service_id:
service = (
await self.session.execute(
select(Service).where(Service.id == uuid.UUID(str(service_id)))
)
).scalar_one_or_none()
calendar_id = await self._calendar_for_service(service)
tz = ZoneInfo(self.tenant.timezone)
busy = await self.fetch_busy(
calendar_id, slot.start.date(), slot.end.date(), tz
)
return not any(b_start < slot.end and b_end > slot.start for b_start, b_end in busy)
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
if booking_request.dry_run:
return DeliveryResult(ok=True, detail="dry-run ok (google_calendar)")
from gogo.proposals.email import send_proposal_email
req = (
await self.session.execute(
select(BookingRequest).where(
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
)
)
).scalar_one()
service = None
if req.service_id:
service = (
await self.session.execute(select(Service).where(Service.id == req.service_id))
).scalar_one_or_none()
await send_proposal_email(self.session, self.tenant, req, service)
return DeliveryResult(ok=True, detail="email sent")
# -- internals ---------------------------------------------------------
async def _calendar_for_service(self, service: Service | None) -> str:
"""Mapped calendar for the service, tenant default mapping, or 'primary'."""
if service is not None:
row = (
await self.session.execute(
select(CalendarMapping).where(
CalendarMapping.tenant_id == self.tenant.id,
CalendarMapping.service_id == service.id,
)
)
).scalar_one_or_none()
if row:
return row.google_calendar_id
default = (
await self.session.execute(
select(CalendarMapping).where(
CalendarMapping.tenant_id == self.tenant.id,
CalendarMapping.service_id.is_(None),
)
)
).scalar_one_or_none()
return default.google_calendar_id if default else "primary"
async def fetch_busy(
self, calendar_id: str, date_from: date, date_to: date, tz: ZoneInfo
) -> list[tuple[datetime, datetime]]:
conn = (
await self.session.execute(
select(CalendarConnection).where(CalendarConnection.tenant_id == self.tenant.id)
)
).scalar_one_or_none()
if conn is None:
log.warning("tenant %s: google_calendar provider without connection", self.tenant.id)
return []
access_token = await self._fresh_access_token(conn)
time_min = datetime.combine(date_from, datetime.min.time(), tzinfo=tz)
time_max = datetime.combine(date_to + timedelta(days=1), datetime.min.time(), tzinfo=tz)
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
self.freebusy_url,
headers={"Authorization": f"Bearer {access_token}"},
json={
"timeMin": time_min.isoformat(),
"timeMax": time_max.isoformat(),
"items": [{"id": calendar_id}],
},
)
resp.raise_for_status()
data = resp.json()
busy = []
for cal in data.get("calendars", {}).values():
for block in cal.get("busy", []):
busy.append(
(
datetime.fromisoformat(block["start"]),
datetime.fromisoformat(block["end"]),
)
)
return busy
async def _fresh_access_token(self, conn: CalendarConnection) -> str:
token_data = json.loads(decrypt(conn.token_data_encrypted))
expiry = token_data.get("expiry")
if expiry and datetime.fromisoformat(expiry) > datetime.now(UTC) + timedelta(minutes=2):
return token_data["access_token"]
# refresh
from gogo.config import get_settings
s = get_settings()
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
self.token_url,
data={
"client_id": s.google_client_id,
"client_secret": s.google_client_secret,
"refresh_token": token_data["refresh_token"],
"grant_type": "refresh_token",
},
)
resp.raise_for_status()
fresh = resp.json()
token_data["access_token"] = fresh["access_token"]
token_data["expiry"] = (
datetime.now(UTC) + timedelta(seconds=fresh.get("expires_in", 3600))
).isoformat()
conn.token_data_encrypted = encrypt(json.dumps(token_data))
return token_data["access_token"]

81
gogo/scheduling/mock.py Normal file
View File

@@ -0,0 +1,81 @@
"""Mock scheduling provider — in-memory, for tests and demos (§16 M1).
Availability: generated from tenant working hours with configurable busy blocks
(class-level, settable by tests/demo seeder). Delivery: owner email, same as
google_calendar.
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
from gogo.hours import DEFAULT_WORKING_HOURS
from gogo.models import BookingRequest, Service, Tenant, utcnow
from gogo.scheduling.base import register_provider
from gogo.scheduling.slots import compute_slots
@register_provider("mock")
class MockProvider:
# tenant_id(str) -> list[(start, end)] busy blocks; tests populate this
busy_blocks: dict[str, list[tuple[datetime, datetime]]] = {}
# test hook: freeze "now"
now_override: datetime | None = None
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
self.session = session
self.tenant = tenant
self.config = config
async def get_services(self) -> list[ServiceInfo] | None:
return None # no external catalog
async def get_availability(
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
) -> list[Slot]:
service = (
await self.session.execute(
select(Service).where(Service.id == uuid.UUID(service_id))
)
).scalar_one_or_none()
duration = service.duration_min if service else 30
buffer_min = service.buffer_min if service else 0
tz = ZoneInfo(self.tenant.timezone)
return compute_slots(
working_hours=self.tenant.working_hours or DEFAULT_WORKING_HOURS,
busy=self.busy_blocks.get(str(self.tenant.id), []),
duration_min=duration,
buffer_min=buffer_min,
date_from=date_from,
date_to=date_to,
tz=tz,
now=self.now_override or utcnow(),
min_notice_hours=self.tenant.min_notice_hours,
max_days_ahead=self.tenant.max_days_ahead,
)
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
if booking_request.dry_run:
return DeliveryResult(ok=True, detail="dry-run ok (mock)")
from gogo.proposals.email import send_proposal_email
req = (
await self.session.execute(
select(BookingRequest).where(
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
)
)
).scalar_one()
service = None
if req.service_id:
service = (
await self.session.execute(select(Service).where(Service.id == req.service_id))
).scalar_one_or_none()
await send_proposal_email(self.session, self.tenant, req, service)
return DeliveryResult(ok=True, detail="email sent (mock)")

View File

@@ -0,0 +1,226 @@
"""Partner API provider (§8.2, Appendix B).
The salon's own booking software is the source of truth: availability is pulled
from `GET /availability` (ready slots, no slot math on our side) and booking
requests are pushed via `POST /booking-requests` with an Idempotency-Key.
Outcomes arrive on our webhook (gogo/api/webhooks.py) or via polling fallback.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import date, datetime
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.crypto import decrypt
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
from gogo.models import BookingRequest, Service, Tenant
from gogo.scheduling.base import register_provider
log = logging.getLogger("gogo.partner")
RETRIES = 3 # 5xx retries with exponential backoff (§B.0)
RETRY_BASE_DELAY = 0.5
@register_provider("partner_api")
class PartnerApiProvider:
# test hook: factory for the HTTP client (e.g. httpx.ASGITransport against a fake app)
client_factory = staticmethod(lambda timeout: httpx.AsyncClient(timeout=timeout))
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
self.session = session
self.tenant = tenant
self.config = config
@property
def base_url(self) -> str:
return (self.config.get("base_url") or "").rstrip("/")
@property
def api_key(self) -> str:
enc = self.config.get("api_key_encrypted")
return decrypt(enc) if enc else ""
def _headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self.api_key}"}
async def get_services(self) -> list[ServiceInfo] | None:
"""Catalog sync from GET /services (optional endpoint, §B.4)."""
if not self.config.get("catalog_sync"):
return None
data = await self._get("/services")
return [
ServiceInfo(
id=str(s["id"]),
name=s["name"],
duration_min=int(s.get("duration_min", 30)),
price_min=s.get("price_min"),
price_max=s.get("price_max"),
currency=s.get("currency", "BAM"),
home_visit=bool(s.get("home_visit", False)),
active=bool(s.get("active", True)),
)
for s in data.get("services", [])
]
async def get_availability(
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
) -> list[Slot]:
partner_service_id = await self._partner_service_id(service_id)
data = await self._get(
"/availability",
params={
"service_id": partner_service_id,
"from": date_from.isoformat(),
"to": date_to.isoformat(),
"home_visit": "true" if home_visit else "false",
},
# §B.1: the voice agent is waiting mid-conversation
timeout=2.0,
)
return [
Slot(
start=datetime.fromisoformat(s["start"]),
end=datetime.fromisoformat(s["end"]),
staff_id=s.get("staff_id"),
staff_name=s.get("staff_name"),
)
for s in data.get("slots", [])
]
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
payload = {
"gogo_request_id": booking_request.gogo_request_id,
"created_at": booking_request.created_at.isoformat(),
"source": booking_request.source,
"client": {
"name": booking_request.client_name,
"phone": booking_request.client_phone,
},
"service_id": (
await self._partner_service_id(booking_request.service_id)
if booking_request.service_id
else None
),
"service_name_raw": booking_request.service_name_raw,
"requested_slots": [
{"start": s.start.isoformat(), "end": s.end.isoformat()}
for s in booking_request.requested_slots
],
"time_preference_text": booking_request.time_preference_text,
"home_visit": booking_request.home_visit,
"address": booking_request.address,
"summary": booking_request.summary,
"transcript_url": booking_request.transcript_url,
"dry_run": booking_request.dry_run,
}
try:
data = await self._post(
"/booking-requests",
json=payload,
headers={"Idempotency-Key": booking_request.gogo_request_id},
)
except Exception as e: # noqa: BLE001
log.exception("partner push failed tenant=%s", self.tenant.id)
return DeliveryResult(ok=False, detail=f"partner push failed: {e}")
partner_id = data.get("partner_request_id")
if not booking_request.dry_run and partner_id:
req = (
await self.session.execute(
select(BookingRequest).where(
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
)
)
).scalar_one_or_none()
if req:
req.partner_request_id = str(partner_id)
# Email to the owner is optional for partner tenants (default off, §8.2)
if not booking_request.dry_run and self.config.get("email_to_owner"):
from gogo.proposals.email import send_proposal_email
req = (
await self.session.execute(
select(BookingRequest).where(
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
)
)
).scalar_one_or_none()
if req:
service = None
if req.service_id:
service = (
await self.session.execute(
select(Service).where(Service.id == req.service_id)
)
).scalar_one_or_none()
await send_proposal_email(self.session, self.tenant, req, service)
return DeliveryResult(ok=True, partner_request_id=partner_id, detail="pushed to partner")
async def poll_status(self, partner_request_id: str) -> dict:
"""Polling fallback (§B.3): GET /booking-requests/{id}."""
return await self._get(f"/booking-requests/{partner_request_id}")
# -- internals ---------------------------------------------------------
async def _partner_service_id(self, service_id: str | uuid.UUID | None) -> str | None:
"""Our service UUID → partner's service id (services.partner_service_id)."""
if service_id is None:
return None
service = (
await self.session.execute(
select(Service).where(Service.id == uuid.UUID(str(service_id)))
)
).scalar_one_or_none()
if service and service.partner_service_id:
return service.partner_service_id
return str(service_id)
async def _get(self, path: str, params: dict | None = None, timeout: float = 10.0) -> dict:
return await self._request("GET", path, params=params, timeout=timeout)
async def _post(
self, path: str, json: dict, headers: dict | None = None, timeout: float = 10.0
) -> dict:
return await self._request("POST", path, json=json, headers=headers, timeout=timeout)
async def _request(
self,
method: str,
path: str,
*,
params: dict | None = None,
json: dict | None = None,
headers: dict | None = None,
timeout: float = 10.0,
) -> dict:
url = f"{self.base_url}{path}"
hdrs = {**self._headers(), **(headers or {})}
last_exc: Exception | None = None
async with self.client_factory(timeout=timeout) as client:
for attempt in range(RETRIES):
try:
resp = await client.request(
method, url, params=params, json=json, headers=hdrs
)
except httpx.HTTPError as e:
last_exc = e
await asyncio.sleep(RETRY_BASE_DELAY * 2**attempt)
continue
if resp.status_code >= 500:
last_exc = httpx.HTTPStatusError(
f"{resp.status_code} from partner", request=resp.request, response=resp
)
await asyncio.sleep(RETRY_BASE_DELAY * 2**attempt)
continue
resp.raise_for_status() # 4xx: not retried, surfaced (§B.0)
return resp.json()
raise last_exc if last_exc else RuntimeError("partner request failed")

77
gogo/scheduling/slots.py Normal file
View File

@@ -0,0 +1,77 @@
"""Slot generation for Google Calendar tenants (§8.1).
working hours busy blocks, quantized to service duration (+ optional buffer),
respecting min_notice_hours and max_days_ahead. Partner tenants never hit this —
their software returns ready slots.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from gogo.domain import Slot
from gogo.hours import day_intervals, iter_days
QUANTIZE_MIN = 15 # slot starts snap to :00/:15/:30/:45
def subtract_busy(
interval: tuple[datetime, datetime], busy: list[tuple[datetime, datetime]]
) -> list[tuple[datetime, datetime]]:
"""Subtract busy blocks from one open interval → list of free intervals."""
free = [interval]
for b_start, b_end in sorted(busy):
next_free = []
for f_start, f_end in free:
if b_end <= f_start or b_start >= f_end:
next_free.append((f_start, f_end))
continue
if b_start > f_start:
next_free.append((f_start, b_start))
if b_end < f_end:
next_free.append((b_end, f_end))
free = next_free
return free
def compute_slots(
*,
working_hours: dict,
busy: list[tuple[datetime, datetime]],
duration_min: int,
buffer_min: int = 0,
date_from: date,
date_to: date,
tz: ZoneInfo,
now: datetime,
min_notice_hours: int = 2,
max_days_ahead: int = 14,
max_slots: int = 20,
) -> list[Slot]:
"""Generate offerable free slots, earliest first."""
earliest_start = now + timedelta(hours=min_notice_hours)
horizon = (now + timedelta(days=max_days_ahead)).date()
date_to = min(date_to, horizon)
total_min = duration_min + buffer_min
step = timedelta(minutes=total_min) # spec §8.1: quantized to service duration (+ buffer)
slots: list[Slot] = []
for day in iter_days(date_from, date_to):
for open_start, open_end in day_intervals(working_hours, day, tz):
for f_start, f_end in subtract_busy((open_start, open_end), busy):
cursor = _quantize_up(max(f_start, earliest_start.astimezone(tz)))
while cursor + timedelta(minutes=total_min) <= f_end:
slots.append(Slot(start=cursor, end=cursor + timedelta(minutes=duration_min)))
if len(slots) >= max_slots:
return slots
cursor += step
return slots
def _quantize_up(dt: datetime) -> datetime:
dt = dt.replace(second=0, microsecond=0)
rem = dt.minute % QUANTIZE_MIN
if rem:
dt += timedelta(minutes=QUANTIZE_MIN - rem)
return dt

1
gogo/sms/__init__.py Normal file
View File

@@ -0,0 +1 @@
from gogo.sms.base import SmsProvider, get_sms_provider, send_sms # noqa: F401

72
gogo/sms/base.py Normal file
View File

@@ -0,0 +1,72 @@
"""SmsProvider interface. Console impl for dev/tests; GSM gateway impl in gateway.py (M5)."""
from __future__ import annotations
import logging
import uuid
from typing import Protocol
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.config import get_settings
from gogo.models import SmsLog
log = logging.getLogger("gogo.sms")
class SmsProvider(Protocol):
async def send(self, tenant_id: uuid.UUID, to_msisdn: str, body: str) -> None:
"""Send one SMS from the tenant's assigned SIM. Raises on failure."""
...
class ConsoleSmsProvider:
"""Logs SMS instead of sending; keeps them in memory for tests."""
sent: list[tuple[str, str]]
def __init__(self) -> None:
self.sent = []
async def send(self, tenant_id: uuid.UUID, to_msisdn: str, body: str) -> None:
self.sent.append((to_msisdn, body))
log.info("SMS tenant=%s to=%s: %s", tenant_id, to_msisdn, body)
_provider: SmsProvider | None = None
def get_sms_provider() -> SmsProvider:
global _provider
if _provider is None:
if get_settings().sms_provider == "gsm_gateway":
from gogo.sms.gateway import GsmGatewaySmsProvider
_provider = GsmGatewaySmsProvider()
else:
_provider = ConsoleSmsProvider()
return _provider
def set_sms_provider(p: SmsProvider | None) -> None:
"""Test hook."""
global _provider
_provider = p
async def send_sms(
session: AsyncSession, tenant_id: uuid.UUID, to_msisdn: str, body: str, kind: str
) -> bool:
"""Send + log. Returns success. Never raises (SMS failure must not break the flow)."""
row = SmsLog(tenant_id=tenant_id, to_msisdn=to_msisdn, body=body, kind=kind)
try:
await get_sms_provider().send(tenant_id, to_msisdn, body)
row.status = "sent"
ok = True
except Exception as e: # noqa: BLE001
log.exception("SMS send failed tenant=%s to=%s", tenant_id, to_msisdn)
row.status = "failed"
row.error = str(e)
ok = False
session.add(row)
return ok

37
gogo/sms/templates.py Normal file
View File

@@ -0,0 +1,37 @@
"""Per-tenant SMS templates with safe Bosnian defaults (§5.5).
Tenant overrides live in tenants.sms_templates (only overridden keys stored).
Placeholders are .format()-style; unknown placeholders are left intact.
"""
from __future__ import annotations
from gogo.models import Tenant
DEFAULT_TEMPLATES: dict[str, str] = {
"missed_call": (
"Poštovani, dobili ste {salon}. Možete zakazati i putem poruke ili chata: {link}. "
"Nazvaćemo vas ili nas pozovite ponovo."
),
"request_received": (
"Primili smo vaš zahtjev za termin. Javićemo vam potvrdu u najkraćem roku. — {salon}"
),
"confirmation": "Potvrđen termin: {usluga}, {dan} {datum} u {vrijeme}h — {salon}.",
"rejection": "{salon}: nažalost traženi termin nije moguć. Molimo pozovite nas da dogovorimo drugi.",
"expiry": (
"{salon}: nismo uspjeli potvrditi vaš zahtjev za termin. "
"Molimo pozovite nas ponovo da dogovorimo termin."
),
}
class _SafeDict(dict):
def __missing__(self, key: str) -> str:
return "{" + key + "}"
def render_sms(tenant: Tenant, kind: str, **params: str) -> str:
templates = {**DEFAULT_TEMPLATES, **(tenant.sms_templates or {})}
template = templates[kind]
params.setdefault("salon", tenant.name)
return template.format_map(_SafeDict(params))