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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.pyc
.env
.pytest_cache/
.ruff_cache/
*.egg-info/
dist/
recordings/
*.db

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml README.md* ./
COPY gogo ./gogo
COPY alembic ./alembic
COPY alembic.ini ./
RUN pip install --no-cache-dir -e .
EXPOSE 8000
CMD ["uvicorn", "gogo.main:app", "--host", "0.0.0.0", "--port", "8000"]

55
README.md Normal file
View File

@@ -0,0 +1,55 @@
# Gogo Telefon
Voice-first AI receptionist for small service businesses in Bosnia and Herzegovina.
The agent answers forwarded calls (and web chat), speaks Bosnian, checks real
availability and sends **booking proposals** to the salon owner — it never books
appointments itself. See `docs/` and the product spec for details.
## Architecture (short version)
```
caller → salon number (conditional forwarding) → GSM gateway → Asterisk
→ ring group (staff softphones) → no answer → voice agent (Pipecat)
→ tools HTTP → FastAPI backend → SchedulingProvider (Google Calendar | Partner API)
→ proposal email / partner push → owner action → (optional) SMS to client
```
Everything self-hosted except LLM + TTS APIs (swappable interfaces).
## Development
```bash
uv venv && uv pip install -e '.[dev]'
docker compose up -d db # Postgres 16 on localhost:5433 (or use your own)
export GOGO_DATABASE_URL=postgresql+psycopg://gogo:gogo@localhost:5432/gogo
.venv/bin/alembic upgrade head
.venv/bin/uvicorn gogo.main:app --reload
```
Run tests (SQLite, no services needed):
```bash
.venv/bin/python -m pytest
```
The fake partner booking API used in tests (`tests/fake_partner.py`) doubles as
the **reference implementation of Appendix B** for partner IT teams:
```bash
.venv/bin/uvicorn tests.fake_partner:app --port 9100
```
## Layout
| Path | What |
|---|---|
| `gogo/scheduling/` | `SchedulingProvider` interface + `google_calendar`, `partner_api`, `mock` |
| `gogo/proposals/` | proposal engine, action tokens, owner emails, .ics |
| `gogo/sms/` | `SmsProvider` (console now, GSM gateway in M5) + Bosnian templates |
| `gogo/api/` | action links, partner webhook, health |
| `gogo/agent/` | LLM agent: prompt composition, tools, conversation loop (M2) |
| `gogo/voice/` | Pipecat voice pipeline (M4) |
| `gogo/telephony/` | Asterisk/GSM/forwarding codes (M4/M5) |
| `tests/fake_partner.py` | Appendix B reference implementation |
All code/comments in English; all client- and owner-facing text in Bosnian (Latin).

37
alembic.ini Normal file
View File

@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

50
alembic/env.py Normal file
View File

@@ -0,0 +1,50 @@
import re
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from gogo.config import get_settings
from gogo.db import Base
from gogo import models # noqa: F401 — register all tables
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _sync_url() -> str:
# alembic runs sync; strip async driver suffixes
url = get_settings().database_url
return re.sub(r"\+(asyncpg|aiosqlite)", "", url)
def run_migrations_offline() -> None:
context.configure(
url=_sync_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
{"sqlalchemy.url": _sync_url()},
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,352 @@
"""initial schema
Revision ID: 62fcb10345fa
Revises:
Create Date: 2026-07-11 09:43:04.542280
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = '62fcb10345fa'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('admins',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('password_hash', sa.String(length=200), nullable=False),
sa.Column('active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('prompt_templates',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('notes', sa.Text(), nullable=False),
sa.Column('published', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('version')
)
op.create_table('tenants',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('slug', sa.String(length=80), nullable=False),
sa.Column('address', sa.String(length=300), nullable=False),
sa.Column('city', sa.String(length=100), nullable=False),
sa.Column('phone', sa.String(length=40), nullable=False),
sa.Column('website', sa.String(length=200), nullable=False),
sa.Column('locale', sa.String(length=20), nullable=False),
sa.Column('timezone', sa.String(length=50), nullable=False),
sa.Column('currency', sa.String(length=10), nullable=False),
sa.Column('working_hours', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.Column('scheduling_provider', sa.String(length=30), nullable=False),
sa.Column('plan', sa.String(length=40), nullable=False),
sa.Column('included_minutes', sa.Integer(), nullable=False),
sa.Column('hard_cutoff', sa.Boolean(), nullable=False),
sa.Column('paid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('tts_voice', sa.String(length=80), nullable=False),
sa.Column('price_mode', sa.String(length=20), nullable=False),
sa.Column('agent_notes', sa.Text(), nullable=False),
sa.Column('llm_model', sa.String(length=80), nullable=False),
sa.Column('proposal_ttl_hours', sa.Integer(), nullable=False),
sa.Column('notify_emails', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.Column('sms_request_received', sa.Boolean(), nullable=False),
sa.Column('sms_templates', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.Column('min_notice_hours', sa.Integer(), nullable=False),
sa.Column('max_days_ahead', sa.Integer(), nullable=False),
sa.Column('ring_first_enabled', sa.Boolean(), nullable=False),
sa.Column('ring_timeout_s', sa.Integer(), nullable=False),
sa.Column('ring_strategy', sa.String(length=20), nullable=False),
sa.Column('widget_public_key', sa.String(length=64), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('slug'),
sa.UniqueConstraint('widget_public_key')
)
op.create_table('calendar_connections',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('google_account', sa.String(length=200), nullable=False),
sa.Column('scopes', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.Column('token_data_encrypted', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant_id')
)
op.create_table('calls',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('caller_msisdn', sa.String(length=30), nullable=False),
sa.Column('duration_s', sa.Integer(), nullable=False),
sa.Column('outcome', sa.String(length=30), nullable=False),
sa.Column('recording_path', sa.String(length=400), nullable=True),
sa.Column('transcript', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.Column('agent_seconds_charged', sa.Integer(), nullable=False),
sa.Column('trace', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_calls_tenant_id'), 'calls', ['tenant_id'], unique=False)
op.create_table('chat_sessions',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('resume_token', sa.String(length=64), nullable=False),
sa.Column('client_ip', sa.String(length=60), nullable=False),
sa.Column('outcome', sa.String(length=30), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_sessions_tenant_id'), 'chat_sessions', ['tenant_id'], unique=False)
op.create_table('email_log',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=True),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.Column('to_addr', sa.String(length=300), nullable=False),
sa.Column('subject', sa.String(length=400), nullable=False),
sa.Column('kind', sa.String(length=30), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('error', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('messages_for_owner',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('client_name', sa.String(length=200), nullable=False),
sa.Column('client_phone', sa.String(length=40), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('source', sa.String(length=10), nullable=False),
sa.Column('read', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_messages_for_owner_tenant_id'), 'messages_for_owner', ['tenant_id'], unique=False)
op.create_table('phone_numbers',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=True),
sa.Column('msisdn', sa.String(length=30), nullable=False),
sa.Column('gateway_port', sa.Integer(), nullable=True),
sa.Column('operator', sa.String(length=30), nullable=False),
sa.Column('sim_status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('msisdn'),
sa.UniqueConstraint('tenant_id')
)
op.create_table('provider_configs',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('provider_type', sa.String(length=30), nullable=False),
sa.Column('config', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant_id')
)
op.create_table('services',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('duration_min', sa.Integer(), nullable=False),
sa.Column('price_min', sa.Float(), nullable=True),
sa.Column('price_max', sa.Float(), nullable=True),
sa.Column('home_visit', sa.Boolean(), nullable=False),
sa.Column('agent_note', sa.Text(), nullable=False),
sa.Column('buffer_min', sa.Integer(), nullable=False),
sa.Column('partner_service_id', sa.String(length=80), nullable=True),
sa.Column('active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_services_tenant_id'), 'services', ['tenant_id'], unique=False)
op.create_table('sms_log',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.Column('to_msisdn', sa.String(length=30), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('kind', sa.String(length=30), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('error', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sms_log_tenant_id'), 'sms_log', ['tenant_id'], unique=False)
op.create_table('tenant_prompt_overrides',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant_id')
)
op.create_table('usage_counters',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('month', sa.String(length=7), nullable=False),
sa.Column('agent_seconds', sa.Integer(), nullable=False),
sa.Column('calls', sa.Integer(), nullable=False),
sa.Column('sms_sent', sa.Integer(), nullable=False),
sa.Column('chat_sessions', sa.Integer(), nullable=False),
sa.Column('warned_80', sa.Boolean(), nullable=False),
sa.Column('warned_100', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant_id', 'month')
)
op.create_index(op.f('ix_usage_counters_tenant_id'), 'usage_counters', ['tenant_id'], unique=False)
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('password_hash', sa.String(length=200), nullable=False),
sa.Column('active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('tenant_id')
)
op.create_table('workers',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('sip_username', sa.String(length=80), nullable=False),
sa.Column('sip_password', sa.String(length=80), nullable=False),
sa.Column('active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('sip_username')
)
op.create_index(op.f('ix_workers_tenant_id'), 'workers', ['tenant_id'], unique=False)
op.create_table('booking_requests',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('source', sa.String(length=10), nullable=False),
sa.Column('client_name', sa.String(length=200), nullable=False),
sa.Column('client_phone', sa.String(length=40), nullable=False),
sa.Column('service_id', sa.Uuid(), nullable=True),
sa.Column('service_name_raw', sa.String(length=300), nullable=False),
sa.Column('slots', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.Column('time_preference_text', sa.String(length=300), nullable=False),
sa.Column('home_visit', sa.Boolean(), nullable=False),
sa.Column('address', sa.String(length=300), nullable=True),
sa.Column('summary', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=30), nullable=False),
sa.Column('confirmed_slot', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
sa.Column('partner_request_id', sa.String(length=120), nullable=True),
sa.Column('call_id', sa.Uuid(), nullable=True),
sa.Column('chat_session_id', sa.Uuid(), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('reminder_sent', sa.Boolean(), nullable=False),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('resolution_note', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['call_id'], ['calls.id'], ),
sa.ForeignKeyConstraint(['chat_session_id'], ['chat_sessions.id'], ),
sa.ForeignKeyConstraint(['service_id'], ['services.id'], ),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_booking_requests_status'), 'booking_requests', ['status'], unique=False)
op.create_index(op.f('ix_booking_requests_tenant_id'), 'booking_requests', ['tenant_id'], unique=False)
op.create_table('calendar_mappings',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('service_id', sa.Uuid(), nullable=True),
sa.Column('google_calendar_id', sa.String(length=300), nullable=False),
sa.ForeignKeyConstraint(['service_id'], ['services.id'], ),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant_id', 'service_id')
)
op.create_index(op.f('ix_calendar_mappings_tenant_id'), 'calendar_mappings', ['tenant_id'], unique=False)
op.create_table('chat_messages',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('session_id', sa.Uuid(), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.Column('role', sa.String(length=20), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_messages_session_id'), 'chat_messages', ['session_id'], unique=False)
op.create_table('action_tokens',
sa.Column('token', sa.String(length=64), nullable=False),
sa.Column('request_id', sa.Uuid(), nullable=False),
sa.Column('action', sa.String(length=30), nullable=False),
sa.Column('slot_index', sa.Integer(), nullable=True),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['request_id'], ['booking_requests.id'], ),
sa.PrimaryKeyConstraint('token')
)
op.create_index(op.f('ix_action_tokens_request_id'), 'action_tokens', ['request_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_action_tokens_request_id'), table_name='action_tokens')
op.drop_table('action_tokens')
op.drop_index(op.f('ix_chat_messages_session_id'), table_name='chat_messages')
op.drop_table('chat_messages')
op.drop_index(op.f('ix_calendar_mappings_tenant_id'), table_name='calendar_mappings')
op.drop_table('calendar_mappings')
op.drop_index(op.f('ix_booking_requests_tenant_id'), table_name='booking_requests')
op.drop_index(op.f('ix_booking_requests_status'), table_name='booking_requests')
op.drop_table('booking_requests')
op.drop_index(op.f('ix_workers_tenant_id'), table_name='workers')
op.drop_table('workers')
op.drop_table('users')
op.drop_index(op.f('ix_usage_counters_tenant_id'), table_name='usage_counters')
op.drop_table('usage_counters')
op.drop_table('tenant_prompt_overrides')
op.drop_index(op.f('ix_sms_log_tenant_id'), table_name='sms_log')
op.drop_table('sms_log')
op.drop_index(op.f('ix_services_tenant_id'), table_name='services')
op.drop_table('services')
op.drop_table('provider_configs')
op.drop_table('phone_numbers')
op.drop_index(op.f('ix_messages_for_owner_tenant_id'), table_name='messages_for_owner')
op.drop_table('messages_for_owner')
op.drop_table('email_log')
op.drop_index(op.f('ix_chat_sessions_tenant_id'), table_name='chat_sessions')
op.drop_table('chat_sessions')
op.drop_index(op.f('ix_calls_tenant_id'), table_name='calls')
op.drop_table('calls')
op.drop_table('calendar_connections')
op.drop_table('tenants')
op.drop_table('prompt_templates')
op.drop_table('admins')
# ### end Alembic commands ###

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: gogo
POSTGRES_PASSWORD: gogo
POSTGRES_DB: gogo
ports:
- "127.0.0.1:5433:5432" # 5433 on the host to avoid clashing with a system postgres
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gogo"]
interval: 5s
timeout: 3s
retries: 10
app:
build: .
depends_on:
db:
condition: service_healthy
environment:
GOGO_DATABASE_URL: postgresql+psycopg://gogo:gogo@db:5432/gogo
env_file:
- path: .env
required: false
ports:
- "127.0.0.1:8000:8000"
volumes:
- recordings:/data/recordings
command: >
sh -c "alembic upgrade head &&
uvicorn gogo.main:app --host 0.0.0.0 --port 8000"
volumes:
pgdata:
recordings:

606
docs/SPEC.md Normal file
View File

@@ -0,0 +1,606 @@
# Gogo Telefon — Product Specification (v1.0)
**Product name:** Gogo Telefon
**Market:** Bosnia and Herzegovina (architecture must support later expansion to Serbia and Kosovo)
**Spec status:** Draft v1.4 — living document agreed in planning session; open questions in §17
**Intended reader:** Claude Code (implementation agent) and the founder
---
## 1. Product Overview
Gogo Telefon is a **voice-first AI receptionist** for small service businesses in Bosnia and Herzegovina (beauty salons, hairdressers, pedicure/manicure studios, and similar businesses with up to ~15 employees). These businesses lose bookings because nobody can answer the phone while working, and especially outside working hours.
The product:
1. **Voice agent** answers phone calls forwarded from the salon's existing number, speaks Bosnian/Serbian/Croatian, knows the salon's services and prices, checks real availability in the salon's scheduling system (Google Calendar or the salon's own booking software via Partner API), and collects a **booking request** from the caller.
2. **Chat agent** — an embeddable web widget on the salon's website that does the same over text, using the same backend logic.
3. **Crucially, the agent never books appointments itself.** It sends a **proposal** (booking request) to the owner by email. The primary flow: the owner contacts the client directly, arranges the appointment themselves, and marks the request as **resolved** with one click (no SMS is sent by us in that case). A secondary one-click option lets the owner confirm a proposed slot and have Gogo send the confirmation SMS to the client. Gogo never writes to the owner's calendar.
4. **Humans first:** before the agent answers, the call rings the salon staff's softphones (SIP clients on their mobile phones). The agent picks up only if nobody answers, or immediately outside working hours.
**Business model:** flat rate ≈ **30 KM/month** per salon (target; annual prepay option ~300 KM/yr), with a fair-use limit on agent voice minutes (see §12). Payment is handled offline via B2B bank transfer — **no online payments / card processing in this product.**
**Distribution:** word of mouth. There is **no public sign-up.** A super-admin creates and configures every salon.
---
## 2. Key Design Principles
1. **Propose, don't book.** The agent gathers intent + availability and produces a structured proposal. The owner is always the decision-maker.
2. **The owner keeps their tools.** Salon keeps its phone number (conditional call forwarding) and keeps its scheduling tool — Google Calendar (often multiple color-coded calendars per service type) or its own custom booking software (Gogo integrates via the Partner API, Appendix B). Proposals arrive where the owner already works: plain email, or directly inside their booking software. The dashboard is for setup and occasional review, not daily work.
3. **Voice is the primary product.** Chat is a secondary channel sharing the same backend.
4. **Own the infrastructure, minimize per-minute costs.** Self-hosted pipeline in the founder's own data center from day one. Only LLM and TTS remain paid APIs (both behind swappable interfaces).
5. **Short calls by design.** Target call length 60120 seconds. The agent is efficient and polite, not chatty.
6. **Multi-country-ready foundations.** Currency, language, phone formats, and locale are per-tenant configuration, not hardcoded (BiH now; Serbia/Kosovo later).
---
## 3. System Architecture
```
Caller (PSTN, salon's customers)
salon's own number (m:tel / BH Telecom / HT Eronet)
│ conditional call forwarding
│ (no answer / busy / out of hours)
Our SIM number (per salon or pooled)
GSM Gateway (multi-SIM device,
e.g. GoIP/Yeastar class) — also used
for outbound SMS to clients
│ SIP
SIP Server (Asterisk or FreeSWITCH) — founder's DC
│ dial-plan: ring group first (staff softphones,
│ N seconds, within working hours only)
│ no answer → route to agent
Voice Pipeline (Pipecat, Python) — founder's DC
┌──────────────────────────────────────────────┐
│ STT: faster-whisper (large-v3) on local GPU │
│ LLM: API (Claude Haiku class) — swappable │
│ TTS: API (Azure Neural or ElevenLabs Flash) │
│ — swappable, decided by Phase-0 PoC │
│ Turn-taking / barge-in: Pipecat built-ins │
└──────────────────────────────────────────────┘
│ tool calls (HTTP)
Backend API (FastAPI, Python) — founder's DC
┌──────────────┬───────────────┬──────────────┬───────────────┐
│ Scheduling │ Proposal │ SMS sender │ Tenant config │
│ providers: │ engine + │ (via GSM │ services, │
│ GCal free/ │ email (.ics) │ gateway API) │ hours, limits │
│ busy or │ or partner │ │ │
│ Partner API │ push │ │ │
└──────────────┴───────────────┴──────────────┴───────────────┘
│ same tools API
Chat Agent (web widget, WebSocket)
Dashboard (owner) + Super-admin panel
```
**Deployment:** everything runs in the founder's own data center. GPU node for Whisper (one RTX 4090 / L4-class GPU handles 1015 concurrent calls). Docker Compose for MVP (no Kubernetes). PostgreSQL as the primary database.
---
## 4. Tech Stack
| Layer | Choice | Notes |
|---|---|---|
| Language | Python 3.12+ | one language across the whole repo |
| Voice orchestration | Pipecat | open source; handles turn-taking, barge-in, SIP integration |
| SIP server | Asterisk (or FreeSWITCH if Pipecat integration proves simpler) | dial-plan: ring group → agent fallback; call recording |
| STT | faster-whisper, large-v3, on local GPU | language hint: `sr` (covers spoken bs/sr/hr); Phase-0 PoC validates |
| LLM | Anthropic API, Haiku-class model | behind `LLMProvider` interface; model per-tenant configurable |
| TTS | Azure Neural TTS **or** ElevenLabs Flash API | behind `TTSProvider` interface; Phase-0 PoC decides default voice |
| Backend | FastAPI + SQLAlchemy + Alembic | REST + WebSocket (chat) |
| DB | PostgreSQL 16 | |
| Queue/scheduling | Built-in (arq or APScheduler) | proposal expiry, SMS dispatch retries |
| Email | SMTP (configurable provider) + generated `.ics` attachments | one-click action links |
| Dashboard | Server-rendered (FastAPI + Jinja2 + htmx) or lightweight React — implementer's choice, favor simplicity | Bosnian UI, Latin script for MVP |
| Chat widget | Vanilla JS embeddable snippet + WebSocket | no framework requirements on salon's site |
| Auth | Session-based; single account per salon + super-admin role | **no public registration** |
---
## 5. Telephony Layer
### 5.1 Inbound call path
1. Salon activates **conditional call forwarding** on its own number: forward on no-answer (~15s), on busy, and (if operator supports schedules — usually not, so handled by us) out of hours. Dashboard shows per-operator activation codes (m:tel, BH Telecom, HT Eronet) generated for the salon's assigned Gogo number.
2. Calls arrive at a **SIM in the GSM gateway** mapped to exactly one salon (MVP: 1 SIM = 1 salon; pooling with DID-style mapping is Phase 2).
3. GSM gateway converts to SIP → Asterisk.
### 5.2 Dial-plan logic (per salon, driven by tenant config)
```
IF within salon working hours AND ring_group not empty AND ring_first_enabled:
ring staff softphones (strategy: ring-all or sequential; default ring-all)
timeout: configurable, default 12s
IF answered by human → bridge call, record, tag outcome = "human_answered"
ELSE → route to AI agent
ELSE (out of hours, or ring group empty/disabled):
route to AI agent immediately
```
### 5.3 Staff softphones
- Staff use **off-the-shelf SIP clients** (Linphone / Zoiper / Groundwire). We build nothing mobile for MVP.
- Dashboard: owner adds a worker (name) → system generates SIP credentials + QR code / config link for easy setup.
- Workers have **no accounts** in the product — they exist only as SIP endpoints in the ring group.
### 5.4 Call recording & disclosure
- All agent calls are recorded (audio) and transcribed.
- The agent's greeting **must include a recording disclosure**, e.g.:
> "Dobar dan, dobili ste salon {naziv}. Ja sam Gogo, virtuelni asistent. Razgovor se snima. Kako vam mogu pomoći?"
- Recordings retention: default 90 days (configurable), then delete audio, keep transcript.
### 5.5 SMS (outbound, via GSM gateway)
- The same GSM gateway sends SMS through the salon's assigned SIM (effectively free within SIM plan).
- MVP SMS use cases:
1. **Missed-call SMS**: caller hung up before human or agent answered → "Poštovani, dobili ste {salon}. Možete zakazati i putem poruke ili chata: {link}. Nazvaćemo vas ili nas pozovite ponovo."
2. **Request received** (optional, config): "Primili smo vaš zahtjev za termin. Javićemo vam potvrdu u najkraćem roku."
3. **Confirmation**: "Potvrđen termin: {usluga}, {dan} {datum} u {vrijeme}h — {salon}."
4. **Rejection / new proposal**: "{salon}: nažalost traženi termin nije moguć. {alternativa ili molba da pozovu}."
- SMS templates are per-tenant editable (with safe defaults), always in Bosnian.
---
## 6. Voice Agent
### 6.1 Language & voice
- Understands and speaks **Bosnian/Serbian/Croatian** (treated as one spoken language; STT language hint `sr`).
- TTS voice: 23 curated voices to choose from in dashboard (final list decided by Phase-0 PoC).
- Agent persona name: "Gogo". Tone: warm, brief, professional. No small talk beyond politeness.
### 6.2 Conversation goals (in order)
1. Greet + recording disclosure (template with salon name).
2. Identify caller's need: **(a)** book an appointment, **(b)** ask a question (prices, hours, location, services), **(c)** cancel/reschedule → MVP: agent takes a message for the owner (full flow is Phase 2), **(d)** other → take a message.
3. For bookings, collect: **service** (match against tenant service list), **client name**, **callback phone number** (confirm digit by digit if unclear; default to caller ID with verbal confirmation), **home visit?** (only if the service is flagged as available at home — then also collect address/area), and **time preferences**.
4. Call the availability tool (§8.2) and offer **up to 3 concrete free slots** consistent with the caller's preference. The caller may also state their own preferred time — the agent checks it, and if busy, offers nearest alternatives.
5. Close explicitly: "Vaš zahtjev prosljeđujem salonu — kontaktiraće vas u najkraćem roku radi potvrde termina. Hvala i prijatno!"
### 6.3 Prompt composition (owners are never prompt engineers)
- The agent's system prompt is built from a **global prompt template owned and maintained by the super-admin** (versioned; improving the template upgrades all salons at once).
- The template contains placeholders filled from structured tenant data only: `{salon_profile}`, `{working_hours}`, `{services_table}`, `{notes}`, `{price_mode}`, `{greeting}`.
- The greeting is auto-generated from the template + salon name (includes the recording disclosure). Owners do not edit prompt text anywhere.
- **Per-tenant prompt overrides exist but are super-admin-only** (advanced escape hatch for unusual salons).
- Owner-entered "notes" are plain informational fields injected as data, not free-form instructions to the model; the template instructs the model to treat them as facts about the salon.
### 6.4 Hard rules (system prompt requirements)
- **Never state a booking as confirmed.** Always "zahtjev" / "prijedlog" until the owner approves.
- Never invent services, prices, or free slots — only from tenant data and availability tool results.
- Price answering mode per tenant config: exact prices / ranges only / "cijene na upit".
- Include per-tenant custom notes in context (e.g., "ne primamo djecu ispod 7 godina", "parking iza zgrade").
- Target call duration 60120s; the agent steers back to the goal politely if the caller drifts.
- If the caller is abusive or the agent cannot understand after 2 clarification attempts → apologize, promise a callback, end call, log a message for the owner.
- Escalation to human mid-call: **Phase 2** (see §14).
### 6.5 Tools exposed to the agent (LLM function-calling)
Identical for voice and chat:
| Tool | Purpose |
|---|---|
| `get_salon_info()` | working hours, address, services with duration/price/notes, custom notes |
| `check_availability(service_id, date_range, preference)` | returns free slots via the tenant's `SchedulingProvider` (§8): Google Calendar free/busy computation, or the partner's `GET /availability`; buffers applied for GCal tenants, partner tenants return ready slots |
| `submit_booking_request(...)` | creates the proposal: service, client name, phone, 13 slots (ordered by client preference), home-visit flag + address, free-text summary of the request/problem |
| `take_message(text, client_name, phone)` | for non-booking intents; emailed to owner |
---
## 7. Chat Agent (web widget)
- Embeddable JS snippet (one `<script>` tag + tenant public key) rendering a chat bubble.
- Same system prompt, same tools, same proposal flow — text instead of voice.
- Collects client's **phone number in chat** (required for confirmation SMS).
- Rate-limited per IP; simple anti-spam (honeypot + optional Turnstile).
- Chat transcripts appear in the dashboard alongside call history.
- No client accounts; ephemeral session, resumable via localStorage token for 24h.
---
## 8. Scheduling Providers (pluggable — core MVP abstraction)
Availability lookup and proposal delivery are abstracted behind a **`SchedulingProvider` interface**, selected and configured **per tenant by the super-admin**. MVP ships two implementations. Adding a third provider must require no changes to the agent, tools, or proposal engine.
```python
class SchedulingProvider(Protocol):
def get_services(self) -> list[Service] | None # optional catalog sync
def get_availability(self, service_id, date_from, date_to) -> list[Slot]
def deliver_request(self, booking_request) -> DeliveryResult
# provider signals request outcome back via callback/webhook or is
# closed manually by the owner (email actions / dashboard)
```
### 8.1 Provider A — Google Calendar (`google_calendar`)
- OAuth 2.0 (Google), initiated from the dashboard by the owner (or super-admin during onboarding, on the owner's device/account).
- **Read (free/busy) scope only.** Gogo never requests calendar write access and never creates/modifies events.
- A salon maps **each service (or service group) to one calendar** — this mirrors the common real-world pattern of separate color-coded calendars per service type. Default: single primary calendar for everything.
- **Availability logic:** free/busy computed against the mapped calendar; slot generation = working hours minus busy blocks, quantized to service duration (+ optional per-service buffer minutes); tenant buffers `min_notice_hours` (default 2) and `max_days_ahead` (default 14).
- **Proposal delivery:** email to owner with action links + `.ics` (§9).
- **Sync without write access:** the owner enters appointments into their own calendar (directly or via the `.ics`); free/busy reads then automatically reflect them in the slots the agent offers. If the owner uses the "confirm + SMS" action, the backend re-checks free/busy first and warns if the slot has since been taken.
### 8.2 Provider B — Partner API (`partner_api`)
For salons that run their own custom booking software (the first two customers are in this category). Their software is the **source of truth for both availability and requests**:
- **Availability:** Gogo calls the partner's HTTP endpoint for free slots — no slot computation on our side, the partner returns ready-to-offer slots.
- **Proposal delivery:** Gogo **pushes the booking request into the partner's software** via HTTP; salon staff handle it in the tool they already use. Email to the owner is optional per tenant (default off for partner-API tenants) — the partner system is the inbox.
- **Request outcome:** the partner system calls our webhook when staff resolve/confirm/reject the request, which drives the client SMS and closes the request in Gogo. If no webhook is feasible, super-admin can enable polling mode as fallback.
- **Service catalog:** optionally synced from the partner (`GET /services`) so the owner maintains services in one place; otherwise entered in the Gogo dashboard as usual.
- The exact HTTP contract the partner's IT team must implement is specified in **Appendix B** — it is deliberately minimal (2 required endpoints + 1 webhook).
### 8.3 Super-admin provider configuration (per tenant)
- Provider type: `google_calendar` | `partner_api`.
- For `partner_api`: base URL, API key (ours → theirs), webhook shared secret (theirs → ours), catalog sync on/off, email-to-owner on/off, polling fallback on/off.
- Connectivity test button: runs a live `get_availability` probe and a no-op request delivery (dry-run flag, see Appendix B) and shows the raw response — essential for debugging partner integrations during onboarding.
---
## 9. Proposal Engine (the core flow)
### 9.1 Creation
`submit_booking_request` creates a `BookingRequest` with status `pending` and delivers it **through the tenant's scheduling provider** (§8):
- **`google_calendar` tenants** → email to owner (below).
- **`partner_api` tenants** → HTTP push into the partner's software (Appendix B); email optional per tenant config. Outcome arrives via the partner's webhook (or polling fallback) and triggers the same state transitions and client SMS as the email actions do.
**Email to owner** (one or more configured addresses):
- Subject: `Novi zahtjev za termin — {Usluga} — {Ime} ({dan/i})`
- Body: client name, phone, service, requested slots (ordered), home-visit info if any, short conversation summary, link to full transcript in dashboard.
- **Action buttons** (signed, single-use links to backend, no login required):
`[Riješeno — kontaktirao sam klijenta]` *(primary)*
`[Potvrdi {slot 1} + pošalji SMS] [Potvrdi {slot 2} + pošalji SMS] ...` *(secondary convenience)*
`[Odbij]`
- **`.ics` attachment** for the first-choice slot (METHOD:REQUEST) so Gmail offers "Add to calendar"; the owner can open, move, and rearrange the event freely before saving to their own calendar. Buttons remain the mechanism that closes the request in Gogo; `.ics` is convenience for calendar entry.
Regardless of provider: optional "request received" SMS to the client (per tenant config).
### 9.2 Owner actions
- **Riješeno (primary):** owner has contacted the client directly and arranged everything themselves → status `resolved_by_owner`; **Gogo sends no SMS**; request is closed. The owner adds the appointment to their own calendar (manually or via the `.ics`), which free/busy reads then reflect automatically.
- **Confirm slot N + SMS (secondary):** for owners who prefer one click over calling back → backend re-checks free/busy (warns if slot taken), status `confirmed`, confirmation SMS to client. No calendar write — the owner still enters the event themselves.
- **Reject** → status `rejected`; SMS to client (template, editable before send via a minimal web form on the action link).
- **Expiry:** if no owner action within `proposal_ttl_hours` (default 24, configurable), status `expired`, client gets an apology SMS asking to call again. Reminder email to owner at 50% of TTL.
### 9.3 States
`pending → resolved_by_owner | confirmed | rejected | expired`
All transitions timestamped and visible in dashboard.
---
## 10. Dashboard (owner) — MVP scope
Single account per salon (username/email + password, created by super-admin). Bosnian UI, Latin script.
**Settings**
1. Salon profile: name, address, city, phone, working hours per weekday incl. breaks.
2. Services: name, duration (min), price or price range, "home visit available" flag, note for the agent. CRUD table **plus "Zalijepi cjenovnik" import**: owner pastes their price list as free text (from website, Word, Facebook, anywhere) → LLM parses it into a structured services table → owner reviews/edits durations and confirms. Same paste-to-parse approach for working hours.
3. Scheduling (contents depend on tenant's provider): for `google_calendar` — connect Google account (free/busy read only), map services → calendars, buffers (`min_notice_hours`, `max_days_ahead`); for `partner_api` — read-only view of connection status and last sync (all configuration is super-admin-only, §8.3).
4. Telephony: assigned Gogo number; per-operator forwarding activation/deactivation codes; ring group management (add worker → name + generated SIP credentials + QR); ring timeout seconds; ring-first on/off.
5. Agent: voice selection (23 curated options), price-answering mode (exact / ranges / on request), plain informational notes fields (e.g. "parking iza zgrade"); chat widget embed snippet. **No prompt or greeting editing** — the greeting and all agent behavior come from the super-admin-owned template (§6.3).
6. Notifications: proposal email recipients; SMS template tweaks; "request received" SMS on/off.
**Review**
1. Booking requests list with statuses + same action buttons as email.
2. Call history: timestamp, caller number, duration, outcome (`human_answered / request_created / info_only / message_taken / abandoned`), transcript, audio player.
3. Chat history (same format, text only).
4. Usage: agent minutes this month vs. plan limit (simple progress bar).
Explicitly **out** of MVP dashboard: statistics/analytics, client CRM, multi-user roles, reminders configuration.
---
## 11. Super-admin panel (internal)
- Create/edit/disable salons (tenant config incl. plan and minute limit).
- **Prompt template management:** edit the global agent prompt template (versioned, with rollback); per-tenant prompt overrides; test playground (run a text conversation against a tenant's composed prompt before publishing).
- Assign SIM/number to salon; see GSM gateway port mapping.
- Impersonate ("open dashboard as salon") for support and hands-on onboarding — expected primary onboarding path: **super-admin sets everything up for the owner** (word-of-mouth sales, "mi vam sve podesimo za 15 minuta").
- Global usage overview: minutes, SMS count, per-salon costs (LLM+TTS estimated), proposals funnel.
- System health: GPU/STT status, SIP registrations, gateway status, email/SMS delivery errors.
---
## 12. Plans, metering, limits
- Metering unit: **agent voice minutes** (human-answered ring-group time does NOT count; chat does not count toward voice minutes).
- MVP plan (single plan): **"Gogo Start" — 30 KM/mj** (annual option ~300 KM), includes fair-use voice minutes: `included_minutes` per tenant, default **300 min/month** (tunable per tenant by super-admin).
- Soft-limit behavior: at 80% → email to owner + banner in dashboard; at 100% → agent still answers (never silently drop customer calls) but super-admin is alerted for an upsell/limit conversation. Hard cutoff is a config flag, default off.
- Billing itself is offline (bank transfer); the product only tracks plan, period, and usage. No invoicing module in MVP (super-admin marks "paid until" date; dashboard shows it).
---
## 13. Data model (core tables, indicative)
- `tenants` (salon): profile, locale (`bs-Latn-BA` default), currency (`BAM`), plan, included_minutes, paid_until, status, **scheduling_provider** (`google_calendar` | `partner_api`)
- `provider_configs`: tenant_id, provider type, config jsonb (partner base URL, encrypted API key, webhook secret, sync/polling/email flags)
- `services`: tenant_id, name, duration_min, price_min/price_max, home_visit bool, agent_note, calendar_id (FK to calendar mapping)
- `calendar_connections`: tenant_id, google account, scopes, token data (encrypted)
- `calendar_mappings`: service_id → google_calendar_id
- `workers`: tenant_id, name, sip_username, sip_secret (hashed/managed), active bool
- `phone_numbers`: tenant_id, msisdn, gateway_port, sim_status
- `calls`: tenant_id, started_at, caller_msisdn, duration_s, outcome, recording_path, transcript, agent_minutes_charged
- `chat_sessions` / `chat_messages`
- `booking_requests`: tenant_id, source (voice/chat), client_name, client_phone, service_id, slots (jsonb, ordered), home_visit + address, summary, status, action tokens, timestamps
- `messages_for_owner` (non-booking messages)
- `sms_log`, `email_log`
- `usage_counters` (per tenant per month)
- `users` (owner accounts) + `admins`
---
## 14. Phasing
### Phase 0 — Proof of Concept (before any product code)
Small scripts, ~12 days, kill the biggest risk first:
1. **STT test:** transcribe recorded local speech samples via faster-whisper large-v3 (language `sr`): service names ("trajna", "pramenovi", "gel nokti", "depilacija"), personal names, and **phone numbers spoken aloud**. Measure practical accuracy.
2. **TTS bake-off:** generate 56 typical agent sentences with Azure Neural (sr/hr/bs voices) and ElevenLabs (Flash v2.5 hr voice; v3-conversational sr/bs if latency acceptable). Founder listens, picks default voice(s); record latency and per-character cost.
3. **End-to-end latency smoke test:** one Pipecat demo call through SIP (can be a softphone→Asterisk→Pipecat loop, no GSM needed yet) measuring mouth-to-ear response time. Target: < 1.5 s response latency.
**Gate:** if STT on phone numbers/names is unusable or latency > 2.5 s, stop and rethink providers before building.
### Phase 1 — MVP (everything in this spec except items below)
Core call path (forwarding → GSM → SIP → ring group → agent), voice agent with tools, chat widget, **two scheduling providers (Google Calendar free/busy + Partner API per Appendix B)**, proposal engine with provider-routed delivery (email+.ics with "Riješeno" as primary, or push into partner software), SMS (missed-call, received, confirmation, rejection), owner dashboard, super-admin panel, metering.
### Phase 2 (explicitly out of MVP, design for it)
- Mid-call transfer agent → human (retry ring group on request/failure)
- SMS appointment reminders (day before)
- Cancel/reschedule full flow through the agent
- Inbound SMS handling ("DA" confirmations)
- Owner statistics ("calls saved", out-of-hours volume) as retention/upsell tool
- Additional `SchedulingProvider` implementations beyond the two MVP providers (Google Calendar, Partner API) — e.g. adapters for specific commercial booking tools, built on demand
- Viber/WhatsApp channels
- SIM pooling (many salons per SIM via signaling), multi-user accounts, Cyrillic UI toggle, Serbia/Kosovo locales
---
## 15. Non-functional requirements
- **Latency:** agent voice response < 1.5 s target, < 2.5 s worst case.
- **Concurrency:** MVP sized for 10 concurrent calls (one GPU); architecture must scale by adding GPU workers.
- **Privacy/GDPR-alignment:** recording disclosure in greeting; audio retention 90 days default; transcripts retained; client phone numbers used only for booking communication; per-tenant data isolation; encrypted OAuth tokens at rest; right-to-delete via super-admin.
- **Reliability:** if the voice pipeline is down, Asterisk fallback: play a recorded message ("...pozovite kasnije ili posjetite {web}") and send the missed-call SMS. All owner-facing actions (email links) must be idempotent.
- **Language:** all client- and owner-facing text in Bosnian (Latin). All code, comments, and docs in English.
- **Observability:** structured logs, per-call trace (audio, transcript, tool calls, latency breakdown), basic health endpoints.
---
## 16. Implementation Plan for Claude Code
**Golden rule: everything that can run without hardware is built AND tested first.** Hardware-dependent code (GSM gateway, SIM, real operator forwarding) is written to completion but its live testing is explicitly deferred to a joint session with the founder. Milestones are ordered so each one is demonstrable on a laptop/DC with no telephony hardware.
### M0 — PoC scripts (Phase 0, §14)
Standalone scripts, no product code: STT accuracy test (needs sample audio recordings from the founder), TTS bake-off, Pipecat latency smoke test using a **desktop softphone registered directly to Asterisk over the network** (no GSM needed). Deliverable: short results report + chosen default TTS voice. *(The latency test needs Asterisk running, which is software-only — include its Docker setup here.)*
### M1 — Backend core + proposal engine (pure software)
FastAPI skeleton, DB schema/migrations, tenants/services/working-hours, `SchedulingProvider` interface with **three implementations: `google_calendar`, `partner_api`, and `mock` (in-memory, for tests and demos)**. Proposal engine end-to-end: create request → email with action links + `.ics` → owner actions → state transitions. SMS sending behind an `SmsProvider` interface with a `console/log` implementation (real GSM-gateway implementation written in M5).
**Tests:** unit + integration (pytest); full proposal lifecycle against `mock` provider; email rendering snapshots; a **fake partner API server** (small FastAPI app in `tests/`) that implements Appendix B — used to test the `partner_api` provider including webhook signatures, idempotency replay, and polling fallback. This fake server doubles as a **reference implementation to hand to partner IT teams.**
### M2 — Agent logic in text mode
Prompt composition from template + tenant data, tool definitions, conversation loop runnable as **text chat in the terminal/playground** (no voice). Implement the Appendix A scenarios as automated acceptance tests (assert tool calls, no "confirmed" language, ≤3 slots, closing line).
**Tests:** all Appendix A scenarios green against `mock` and fake-partner providers.
### M3 — Chat widget + dashboard + super-admin panel
Embeddable widget (WebSocket) reusing M2 agent, owner dashboard (all §10), super-admin panel (§11) incl. provider config, connectivity test (dry-run against fake partner server), prompt template editor + playground, "Zalijepi cjenovnik" paste-to-parse.
**Tests:** widget e2e (Playwright or similar) booking flow → request appears in dashboard and email.
### M4 — Voice pipeline (software-only telephony)
Asterisk + Pipecat + STT/TTS/LLM wired to the M2 agent tools. **Tested entirely with desktop/mobile softphones registering to Asterisk over IP** — this exercises the full real call path (SIP, audio, barge-in, recording, transcripts, metering) except the GSM leg. Ring-group-first dial-plan logic included and tested with two softphones (one as "worker", one as "caller").
**Tests:** scripted call sessions; latency budget assertions; recording + transcript stored; minutes metered.
### M5 — Hardware integration layer (WRITE ONLY — live testing deferred)
GSM gateway integration: SIP trunk config templates for the gateway, `SmsProvider` implementation for the gateway's HTTP SMS API, per-salon SIM/port mapping in super-admin, operator forwarding-code generator (m:tel / BH Telecom / HT Eronet). Everything written, code-reviewed, and covered by unit tests against a **mocked gateway API**; a `HARDWARE_TESTING.md` runbook is produced with the exact step-by-step live test plan (gateway setup, SIM insertion, forwarding activation, end-to-end call, SMS delivery) to execute together with the founder later.
### M6 — Hardening
Fallback message when pipeline is down (§15), retention jobs, usage limit notifications, monitoring/health endpoints, backup strategy, deploy docs for the founder's DC (Docker Compose).
**Definition of done for the software phase:** a demo where a softphone call books a request end-to-end (agent conversation → availability from fake partner API → request pushed → webhook outcome → console-SMS logged), and the same flow via the chat widget with a Google-Calendar mock tenant — all without any hardware present.
---
## 17. Open questions (to resolve, not blockers for scaffolding)
1. **TTS default voice/provider** — decided by Phase-0 PoC (Azure vs ElevenLabs; quality vs cost).
2. **Partner API adoption** — Appendix B is our proposed contract for the first two customers' custom booking software; awaiting their IT teams' review (possible adjustments to field names/flows, but the shape — availability pull + request push + outcome webhook — is fixed).
3. **GSM gateway model** — founder to select hardware (GoIP/Yeastar class, SMS API capability required); spec assumes SIP + HTTP SMS API.
4. **Operator terms for SIMs in gateways** — verify acceptable use with m:tel/BH Telecom/HT Eronet for inbound-only + SMS usage.
5. **Recording consent wording** — confirm exact legally appropriate phrasing for BiH (both entities) before launch.
6. **Exact plan limits** — 300 included minutes is a starting assumption; revisit after first pilot salons.
---
## Appendix A — Golden conversation examples (voice agent)
These dialogues are the **behavioral gold standard** for the agent. They feed directly into the super-admin prompt template as few-shot examples and as acceptance criteria for testing. Editable by super-admin; the hard rules in §6.4 always take precedence. Fictional salon: "Salon Merima", Banja Luka.
**Style rules visible in all examples:** short sentences; one question at a time; numbers and times spoken naturally ("u dva i trideset", not "14:30h"); nikad "bukirano/potvrđeno", uvijek "zahtjev/prijedlog"; agent never fills silence with chatter.
### A.1 Happy path — booking with a preferred time
> **Gogo:** Dobar dan, dobili ste salon Merima. Ja sam Gogo, virtuelni asistent — razgovor se snima. Kako vam mogu pomoći?
> **Klijent:** Dobar dan, htjela bih zakazati šišanje i feniranje.
> **Gogo:** Može. Kada bi vam odgovaralo?
> **Klijent:** Nešto u srijedu poslijepodne ako ima.
> **Gogo:** *(tool: check_availability)* U srijedu poslijepodne slobodno je u dva i trideset ili u pet. Šta vam više odgovara?
> **Klijent:** U pet.
> **Gogo:** Važi. Na koje ime da zavedem zahtjev?
> **Klijent:** Amra Hodžić.
> **Gogo:** Hvala, Amra. Da li je broj s kojeg zovete pravi broj za kontakt?
> **Klijent:** Jeste.
> **Gogo:** Odlično. Prosljeđujem salonu zahtjev: šišanje i feniranje, srijeda u pet. Kontaktiraće vas u najkraćem roku radi potvrde. Hvala na pozivu i prijatno!
*Notes: agent offers max 23 slots, confirms caller-ID instead of asking to dictate the number, closes with the standard non-committal line. Target duration ~60s.*
### A.2 Price question only (no booking)
> **Gogo:** Dobar dan, dobili ste salon Merima. Ja sam Gogo, virtuelni asistent — razgovor se snima. Kako vam mogu pomoći?
> **Klijent:** Koliko košta farbanje kose?
> **Gogo:** Farbanje cijele dužine je od šezdeset do devedeset maraka, zavisno od dužine kose. Želite li da zakažemo termin?
> **Klijent:** Ne, samo sam htjela cijenu. Hvala.
> **Gogo:** Nema na čemu. Prijatno!
*Notes: answer from `{services_table}` respecting `{price_mode}` (here: ranges). One gentle booking offer, never a second. Call ends in ~20s — that's success, not failure.*
### A.3 Client unsure what they need
> **Klijent:** Uništila mi se kosa od peglanja, ne znam šta bi mi pomoglo...
> **Gogo:** Razumijem. Salon radi keratinski tretman i dubinsku njegu — obje usluge su za oštećenu kosu. Ne mogu vam reći šta je bolje za vašu kosu, to će procijeniti frizerka. Mogu proslijediti zahtjev s napomenom da je kosa oštećena od peglanja, pa da vas salon nazove i posavjetuje. Može?
> **Klijent:** Može, super.
> **Gogo:** Kada vam uglavnom odgovara da dođete, radnim danom ili vikendom?
> **Klijent:** Radnim danom poslije četiri.
> **Gogo:** Zabilježeno. Vaše ime, molim?
> ...
*Notes: agent NEVER gives beauty/treatment advice — it lists relevant services and routes the decision to the professional. The problem description goes into the request `summary`, which is exactly the "information about what the client wants or what problem they have" from the product definition.*
### A.4 Cancellation (MVP: message for the owner)
> **Klijent:** Trebala bih otkazati termin za sutra.
> **Gogo:** Naravno. Recite mi vaše ime i u koliko sati je termin?
> **Klijent:** Selma Kovač, sutra u deset.
> **Gogo:** Hvala, Selma. Prosljeđujem salonu poruku da otkazujete sutrašnji termin u deset. Ako budete željeli novi termin, slobodno nazovite. Prijatno!
*Notes: MVP uses `take_message` — the agent does not touch the calendar and does not promise the slot is freed. Full reschedule flow is Phase 2.*
### A.5 Out-of-hours call
> **Gogo:** Dobar dan, dobili ste salon Merima. Ja sam Gogo, virtuelni asistent — razgovor se snima. Salon je trenutno zatvoren — radno vrijeme je od devet do osamnaest radnim danom i subotom do četrnaest. Mogu li vam pomoći da zakažete termin?
> **Klijent:** Da, može manikir u petak.
> **Gogo:** *(tool: check_availability)* U petak je slobodno u jedanaest i u petnaest i trideset...
*Notes: out-of-hours greeting variant states working hours proactively, then proceeds with the normal booking flow — this is the core product moment ("agent radi kad salon ne radi").*
### A.6 Difficult call — unclear speech, then graceful exit
> **Klijent:** *(nerazgovijetno / buka)*
> **Gogo:** Izvinite, nisam vas dobro razumio. Možete li ponoviti?
> **Klijent:** *(i dalje nerazumljivo)*
> **Gogo:** Žao mi je, veza je loša i ne razumijem vas. Ostaviću salonu poruku da vas nazove na ovaj broj. Hvala na pozivu i prijatno!
> *(tool: take_message — "nerazumljiv poziv, nazvati klijenta")*
*Notes: exactly two clarification attempts (per §6.4), then a promise of a callback and a clean, polite exit. The owner gets the caller's number either way — no call is ever fully "lost".*
### A.7 Home-visit service
> **Klijent:** Da li vi dolazite kući za pedikir? Majka mi je nepokretna.
> **Gogo:** Da, salon radi pedikir na kućnoj adresi. U kojem ste dijelu grada?
> **Klijent:** Lauš.
> **Gogo:** Hvala. Kada bi vam odgovaralo da ekipa dođe?
> **Klijent:** Bilo koje prijepodne ove sedmice.
> **Gogo:** Zabilježeno — pedikir kod kuće, naselje Lauš, prijepodne ove sedmice. Vaše ime i da li je ovaj broj za kontakt?
> ...
*Notes: only offered for services flagged `home_visit`; collects area/address and time window rather than exact slot (travel logistics are the owner's call). The request is marked `home_visit=true`.*
---
**Acceptance testing:** each example above becomes an automated test scenario (text-mode conversation against the composed prompt, asserting: correct tool calls, no "confirmed" language, ≤3 slots offered, closing line present). The super-admin playground (§11) runs the same scenarios on demand.
---
## Appendix B — Partner Scheduling API (spec for the partner's IT team)
This is the HTTP contract a salon's custom booking software must implement so Gogo Telefon can (1) read free slots and (2) push booking requests into it. It is deliberately minimal: **two endpoints on the partner side, one webhook on ours, plus one optional endpoint.** This document can be handed to the partner's IT team as-is.
### B.0 General conventions
- **Transport:** HTTPS only. Partner exposes a base URL, e.g. `https://booking.salon-example.ba/gogo/v1`.
- **Auth (Gogo → partner):** every request carries `Authorization: Bearer <api_key>` (key issued by the partner, stored by Gogo super-admin).
- **Auth (partner → Gogo webhook):** HMAC-SHA256 signature of the raw body using a shared secret, sent as `X-Gogo-Signature: sha256=<hex>`.
- **Format:** JSON (UTF-8). Timestamps: **ISO 8601 with timezone offset** (e.g. `2026-07-15T14:30:00+02:00`); salon timezone is `Europe/Sarajevo`.
- **IDs:** all Gogo-issued IDs are opaque strings (UUID). Partner returns its own `partner_request_id`; both sides store the pair.
- **Idempotency:** `POST /booking-requests` includes an `Idempotency-Key` header (the Gogo request UUID). Replays with the same key MUST return the original result, not create a duplicate.
- **Errors:** non-2xx with body `{"error": {"code": "string", "message": "human readable"}}`. Gogo retries 5xx with exponential backoff (3 attempts); 4xx are not retried and are surfaced to super-admin monitoring.
- **Latency requirement:** `GET /availability` should respond in **< 800 ms** — the voice agent is waiting on it mid-conversation. Everything else may be slower.
### B.1 `GET /availability` (required)
Gogo asks for ready-to-offer free slots for one service in a date range. **The partner computes availability** (working hours, staff, existing bookings — all their logic); Gogo does no slot math for partner tenants.
Request:
```
GET /availability?service_id=SRV-12&from=2026-07-15&to=2026-07-22&home_visit=false
Authorization: Bearer <api_key>
```
Response `200`:
```json
{
"service_id": "SRV-12",
"slots": [
{"start": "2026-07-15T14:30:00+02:00", "end": "2026-07-15T15:15:00+02:00",
"staff_id": "EMP-3", "staff_name": "Merima"},
{"start": "2026-07-15T17:00:00+02:00", "end": "2026-07-15T17:45:00+02:00"}
]
}
```
Notes: `staff_id`/`staff_name` optional (if present, the agent may say "kod Merime"). Empty `slots` array is a valid answer. Return at most ~20 slots; Gogo picks up to 3 to offer.
### B.2 `POST /booking-requests` (required)
Gogo pushes a new booking request into the partner's software, where salon staff see and handle it in their usual workflow.
Request:
```json
POST /booking-requests
Authorization: Bearer <api_key>
Idempotency-Key: 7c9e6679-7425-40de-963d-...
{
"gogo_request_id": "7c9e6679-7425-40de-963d-...",
"created_at": "2026-07-14T19:42:10+02:00",
"source": "voice", // "voice" | "chat"
"client": {"name": "Amra Hodžić", "phone": "+38765123456"},
"service_id": "SRV-12", // null if the client couldn't be matched to a service
"service_name_raw": "šišanje i feniranje",
"requested_slots": [ // 03, ordered by client preference
{"start": "2026-07-15T17:00:00+02:00", "end": "2026-07-15T17:45:00+02:00"}
],
"time_preference_text": "srijeda poslijepodne", // free text when no exact slot
"home_visit": false,
"address": null, // filled when home_visit = true
"summary": "Klijentica želi šišanje i feniranje, preferira srijedu poslijepodne.",
"transcript_url": "https://app.gogotelefon.ba/t/abc123", // auth-protected
"dry_run": false // true = validate & discard (connectivity test)
}
```
Response `201`:
```json
{"partner_request_id": "REQ-8841", "status": "received"}
```
### B.3 Webhook: request outcome (partner → Gogo, required)
When staff resolve the request in the partner software, the partner calls Gogo's webhook. This is what triggers the client SMS and closes the request on our side.
```json
POST https://app.gogotelefon.ba/webhooks/partner/{tenant_id}
X-Gogo-Signature: sha256=<hmac of body>
{
"gogo_request_id": "7c9e6679-...",
"partner_request_id": "REQ-8841",
"outcome": "confirmed", // "confirmed" | "resolved" | "rejected"
"confirmed_slot": {"start": "2026-07-15T17:00:00+02:00",
"end": "2026-07-15T17:45:00+02:00"}, // required when "confirmed"
"note": "optional free text"
}
```
Semantics (mirrors §9.2): `confirmed` → Gogo sends the confirmation SMS to the client; `resolved` → staff contacted the client themselves, **Gogo sends nothing**, request closed; `rejected` → Gogo sends the rejection SMS. Response: `200 {"ok": true}`. Gogo's webhook is idempotent per (`gogo_request_id`, `outcome`).
**Polling fallback (if the partner cannot call webhooks):** partner instead implements `GET /booking-requests/{partner_request_id}` returning `{"status": "pending" | "confirmed" | "resolved" | "rejected", "confirmed_slot": ...}`; Gogo polls every 5 minutes until terminal state or proposal TTL.
### B.4 `GET /services` (optional, recommended)
Lets Gogo sync the service catalog so the salon maintains it in one place.
```json
{"services": [
{"id": "SRV-12", "name": "Šišanje i feniranje", "duration_min": 45,
"price_min": 25, "price_max": 35, "currency": "BAM",
"home_visit": false, "active": true}
]}
```
Synced nightly + on-demand from super-admin panel. If absent, services are maintained manually in the Gogo dashboard and `service_id` mapping is configured there.
### B.5 Partner checklist
1. Issue an API key for Gogo; agree the base URL.
2. Implement `GET /availability` (< 800 ms) and `POST /booking-requests` (idempotent).
3. Implement the outcome webhook (or the polling endpoint) with the shared HMAC secret.
4. Optionally implement `GET /services`.
5. Joint test using Gogo's connectivity test (dry-run) from the super-admin panel.

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))

61
pyproject.toml Normal file
View File

@@ -0,0 +1,61 @@
[project]
name = "gogotelefon"
version = "0.1.0"
description = "Gogo Telefon — voice-first AI receptionist for small service businesses in BiH"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"sqlalchemy>=2.0",
"alembic>=1.13",
"psycopg[binary]>=3.2",
"pydantic>=2.8",
"pydantic-settings>=2.4",
"jinja2>=3.1",
"python-multipart>=0.0.9",
"itsdangerous>=2.2",
"cryptography>=43.0",
"bcrypt>=4.2",
"httpx>=0.27",
"aiosmtplib>=3.0",
"icalendar>=5.0",
"apscheduler>=3.10",
"anthropic>=0.40",
"google-auth>=2.34",
"google-auth-oauthlib>=1.2",
"qrcode[pil]>=7.4",
"websockets>=13.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"pytest-httpx>=0.32",
"ruff>=0.6",
"aiosqlite>=0.20",
"greenlet>=3.0",
]
voice = [
"pipecat-ai>=0.0.40",
"faster-whisper>=1.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["gogo"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["E501", "B008"] # B008: Depends(...) in defaults is FastAPI idiom

0
tests/__init__.py Normal file
View File

165
tests/conftest.py Normal file
View File

@@ -0,0 +1,165 @@
"""Shared fixtures: file-backed SQLite DB per test, console email/SMS capture,
seeded tenant, fake partner wiring."""
from __future__ import annotations
import os
import uuid
os.environ.setdefault("GOGO_ENV", "test")
os.environ.setdefault("GOGO_SECRET_KEY", "test-secret-key-for-tests-only")
os.environ.setdefault("GOGO_BASE_URL", "http://testserver")
import httpx # noqa: E402
import pytest # noqa: E402
import gogo.db as db # noqa: E402
from gogo.config import get_settings # noqa: E402
from gogo.crypto import encrypt # noqa: E402
from gogo.email.sender import ConsoleEmailProvider, set_email_provider # noqa: E402
from gogo.hours import DEFAULT_WORKING_HOURS # noqa: E402
from gogo.models import ProviderConfig, Service, Tenant # noqa: E402
from gogo.scheduling.mock import MockProvider # noqa: E402
from gogo.scheduling.partner_api import PartnerApiProvider # noqa: E402
from gogo.sms.base import ConsoleSmsProvider, set_sms_provider # noqa: E402
from tests import fake_partner # noqa: E402
@pytest.fixture
async def session(tmp_path):
"""Fresh file-backed SQLite DB; the app's get_session sees the same file."""
db_path = tmp_path / "test.db"
os.environ["GOGO_DATABASE_URL"] = f"sqlite+aiosqlite:///{db_path}"
get_settings.cache_clear()
db.reset_engine()
engine = db.get_engine()
async with engine.begin() as conn:
await conn.run_sync(db.Base.metadata.create_all)
async with db.get_sessionmaker()() as s:
yield s
await engine.dispose()
db.reset_engine()
@pytest.fixture
def emails():
provider = ConsoleEmailProvider()
set_email_provider(provider)
yield provider.sent
set_email_provider(None)
@pytest.fixture
def sms():
provider = ConsoleSmsProvider()
set_sms_provider(provider)
yield provider.sent
set_sms_provider(None)
@pytest.fixture
def clean_mock_provider():
MockProvider.busy_blocks = {}
MockProvider.now_override = None
yield MockProvider
MockProvider.busy_blocks = {}
MockProvider.now_override = None
@pytest.fixture
async def tenant(session) -> Tenant:
"""Seeded mock-provider tenant 'Salon Merima' with two services."""
t = Tenant(
name="Salon Merima",
slug="salon-merima",
city="Banja Luka",
working_hours=DEFAULT_WORKING_HOURS,
scheduling_provider="mock",
notify_emails=["merima@example.ba"],
)
session.add(t)
await session.flush()
session.add_all(
[
Service(
tenant_id=t.id, name="Šišanje i feniranje", duration_min=45,
price_min=25, price_max=35,
),
Service(
tenant_id=t.id, name="Pedikir", duration_min=60,
price_min=30, price_max=30, home_visit=True,
),
]
)
await session.commit()
return t
@pytest.fixture
async def partner_tenant(session) -> Tenant:
"""Tenant wired to the fake partner API via ASGI transport."""
t = Tenant(
name="Salon Aida",
slug="salon-aida",
city="Sarajevo",
working_hours=DEFAULT_WORKING_HOURS,
scheduling_provider="partner_api",
notify_emails=[],
)
session.add(t)
await session.flush()
session.add(
ProviderConfig(
tenant_id=t.id,
provider_type="partner_api",
config={
"base_url": "http://partner.test",
"api_key_encrypted": encrypt(fake_partner.API_KEY),
"webhook_secret_encrypted": encrypt(fake_partner.WEBHOOK_SECRET),
"catalog_sync": True,
"email_to_owner": False,
"polling_fallback": False,
},
)
)
session.add(
Service(
tenant_id=t.id, name="Manikir", duration_min=45,
price_min=20, price_max=25, partner_service_id="SRV-12",
)
)
await session.commit()
return t
@pytest.fixture
def wire_fake_partner(monkeypatch):
"""Point PartnerApiProvider's HTTP client at the fake partner ASGI app."""
fake_partner.state.reset()
def factory(timeout):
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=fake_partner.app),
base_url="http://partner.test",
timeout=timeout,
)
monkeypatch.setattr(PartnerApiProvider, "client_factory", staticmethod(factory))
yield fake_partner.state
fake_partner.state.reset()
@pytest.fixture
async def gogo_client(session):
"""HTTP client against the Gogo app (shares the test DB via GOGO_DATABASE_URL)."""
from gogo.main import create_app
app = create_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
yield client
def make_uuid() -> str:
return str(uuid.uuid4())

176
tests/fake_partner.py Normal file
View File

@@ -0,0 +1,176 @@
"""Fake partner booking-software API — reference implementation of Appendix B.
Used by the test suite to exercise the `partner_api` provider (auth, idempotency
replay, webhook signatures, polling fallback) AND intended to be handed to
partner IT teams as a working example of the contract they must implement.
Run standalone: uvicorn tests.fake_partner:app --port 9100
(API key: "test-partner-key"; see PartnerState to preload slots.)
"""
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass, field
from datetime import datetime
import httpx
from fastapi import FastAPI, Header, Query, Request
from fastapi.responses import JSONResponse
API_KEY = "test-partner-key"
WEBHOOK_SECRET = "test-webhook-secret"
@dataclass
class PartnerState:
"""In-memory 'database' of the partner's booking software."""
# service_id -> list of slot dicts {"start", "end", optionally staff}
slots: dict[str, list[dict]] = field(default_factory=dict)
# idempotency key -> stored response
idempotency: dict[str, dict] = field(default_factory=dict)
# partner_request_id -> request record (incl. "status")
requests: dict[str, dict] = field(default_factory=dict)
services: list[dict] = field(default_factory=list)
seq: int = 0
# availability latency injection for tests (seconds)
availability_delay: float = 0.0
def reset(self) -> None:
self.slots.clear()
self.idempotency.clear()
self.requests.clear()
self.services.clear()
self.seq = 0
self.availability_delay = 0.0
state = PartnerState()
app = FastAPI(title="Fake Partner Booking API (Appendix B reference)")
def _err(status: int, code: str, message: str) -> JSONResponse:
# §B.0 error shape
return JSONResponse({"error": {"code": code, "message": message}}, status_code=status)
def _check_auth(authorization: str | None) -> JSONResponse | None:
if authorization != f"Bearer {API_KEY}":
return _err(401, "unauthorized", "Missing or invalid API key")
return None
@app.get("/availability")
async def availability(
service_id: str = Query(...),
home_visit: str = Query("false"),
from_: str = Query(..., alias="from"),
to: str = Query(...),
authorization: str | None = Header(default=None),
):
"""§B.1 — ready-to-offer free slots; partner does all the slot math."""
if err := _check_auth(authorization):
return err
if state.availability_delay:
import asyncio
await asyncio.sleep(state.availability_delay)
date_from = datetime.fromisoformat(from_).date() if len(from_) > 10 else from_
date_to = datetime.fromisoformat(to).date() if len(to) > 10 else to
slots = [
s
for s in state.slots.get(service_id, [])
if str(date_from) <= s["start"][:10] <= str(date_to)
]
return {"service_id": service_id, "slots": slots[:20]}
@app.post("/booking-requests", status_code=201)
async def create_booking_request(
request: Request,
authorization: str | None = Header(default=None),
idempotency_key: str | None = Header(default=None),
):
"""§B.2 — push a booking request; idempotent per Idempotency-Key."""
if err := _check_auth(authorization):
return err
payload = await request.json()
key = idempotency_key or payload.get("gogo_request_id", "")
if key in state.idempotency:
return state.idempotency[key] # replay MUST return the original result
if payload.get("dry_run"):
return {"partner_request_id": None, "status": "dry_run_ok"}
state.seq += 1
partner_id = f"REQ-{state.seq:04d}"
state.requests[partner_id] = {**payload, "status": "pending"}
response = {"partner_request_id": partner_id, "status": "received"}
state.idempotency[key] = response
return response
@app.get("/booking-requests/{partner_request_id}")
async def booking_request_status(
partner_request_id: str, authorization: str | None = Header(default=None)
):
"""§B.3 polling fallback."""
if err := _check_auth(authorization):
return err
rec = state.requests.get(partner_request_id)
if rec is None:
return _err(404, "not_found", "Unknown partner_request_id")
return {"status": rec["status"], "confirmed_slot": rec.get("confirmed_slot")}
@app.get("/services")
async def services(authorization: str | None = Header(default=None)):
"""§B.4 optional catalog endpoint."""
if err := _check_auth(authorization):
return err
return {"services": state.services}
# -- helpers for driving the partner side in tests / demos -------------------
def sign_webhook(body: bytes, secret: str = WEBHOOK_SECRET) -> str:
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
async def staff_resolve(
gogo_base: httpx.AsyncClient,
tenant_id: str,
partner_request_id: str,
outcome: str,
confirmed_slot: dict | None = None,
secret: str = WEBHOOK_SECRET,
) -> httpx.Response:
"""Simulate salon staff resolving a request in the partner software:
updates local state and calls Gogo's outcome webhook (§B.3)."""
import json as jsonlib
rec = state.requests.get(partner_request_id)
if rec is not None:
rec["status"] = "resolved" if outcome == "resolved" else outcome
if confirmed_slot:
rec["confirmed_slot"] = confirmed_slot
payload = {
"gogo_request_id": rec["gogo_request_id"] if rec else None,
"partner_request_id": partner_request_id,
"outcome": outcome,
}
if confirmed_slot:
payload["confirmed_slot"] = confirmed_slot
body = jsonlib.dumps(payload).encode()
return await gogo_base.post(
f"/webhooks/partner/{tenant_id}",
content=body,
headers={
"X-Gogo-Signature": sign_webhook(body, secret),
"Content-Type": "application/json",
},
)

133
tests/test_action_links.py Normal file
View File

@@ -0,0 +1,133 @@
"""Owner email action links over HTTP (§9.1-9.2, idempotency per §15)."""
import re
from datetime import UTC, datetime, timedelta
from gogo.domain import BookingStatus
from tests.test_proposal_lifecycle import create
def extract_links(text: str) -> dict[str, str]:
"""Pull action URLs out of the plain-text proposal email."""
links = {}
resolve = re.search(r"RIJEŠENO[^\n]*\n\s+(http\S+)", text)
confirm = re.search(r"POTVRDI[^\n]*\n\s+(http\S+)", text)
reject = re.search(r"ODBIJ[^\n]*\n\s+(http\S+)", text)
if resolve:
links["resolve"] = resolve.group(1)
if confirm:
links["confirm"] = confirm.group(1)
if reject:
links["reject"] = reject.group(1)
return links
async def test_resolve_link_closes_request_no_sms(session, tenant, emails, sms, gogo_client):
req, _ = await create(session, tenant)
await session.commit()
links = extract_links(emails[0].text)
resp = await gogo_client.get(links["resolve"])
assert resp.status_code == 200
assert "Označeno kao riješeno" in resp.text
await session.refresh(req)
assert req.status == BookingStatus.resolved_by_owner.value
assert sms == []
# idempotent: second click shows friendly "already handled" page
resp2 = await gogo_client.get(links["resolve"])
assert resp2.status_code == 200
assert "već obrađen" in resp2.text
async def test_confirm_link_sends_sms(session, tenant, emails, sms, gogo_client):
req, _ = await create(session, tenant)
await session.commit()
links = extract_links(emails[0].text)
resp = await gogo_client.get(links["confirm"])
assert resp.status_code == 200
assert "Termin potvrđen" in resp.text
await session.refresh(req)
assert req.status == BookingStatus.confirmed.value
assert len(sms) == 1
assert "Potvrđen termin" in sms[0][1]
async def test_reject_link_shows_editable_form_then_sends(
session, tenant, emails, sms, gogo_client
):
req, _ = await create(session, tenant)
await session.commit()
links = extract_links(emails[0].text)
# GET shows the form with the default template prefilled — nothing sent yet
resp = await gogo_client.get(links["reject"])
assert resp.status_code == 200
assert "<textarea" in resp.text
assert sms == []
resp = await gogo_client.post(
links["reject"] + "/reject", data={"sms_body": "Nažalost, popunjeni smo. Salon Merima"}
)
assert resp.status_code == 200
await session.refresh(req)
assert req.status == BookingStatus.rejected.value
assert sms == [("+38765123456", "Nažalost, popunjeni smo. Salon Merima")]
async def test_forged_token_rejected(gogo_client, session, tenant):
resp = await gogo_client.get("/a/forged-token-value")
assert resp.status_code == 400
assert "Nevažeći link" in resp.text
async def test_action_after_expiry_shows_status(session, tenant, emails, sms, gogo_client):
from gogo.proposals import engine as pe
req, _ = await create(session, tenant)
req.expires_at = datetime.now(UTC) - timedelta(minutes=1)
await pe.process_expirations(session)
await session.commit()
sms.clear()
links = extract_links(emails[0].text)
resp = await gogo_client.get(links["confirm"])
assert resp.status_code == 200
assert "već obrađen" in resp.text
assert "istekao" in resp.text
assert sms == []
async def test_confirm_link_warns_when_slot_taken(session, tenant, emails, sms, gogo_client):
"""Recheck finds slot busy → warning page with force-confirm, no SMS yet."""
from gogo.scheduling.mock import MockProvider
req, _ = await create(session, tenant)
await session.commit()
links = extract_links(emails[0].text)
async def is_slot_free(self, service_id, slot_):
return False
MockProvider.is_slot_free = is_slot_free
try:
resp = await gogo_client.get(links["confirm"])
assert resp.status_code == 200
assert "u međuvremenu zauzet" in resp.text
await session.refresh(req)
assert req.status == BookingStatus.pending.value
assert sms == []
# owner forces the confirmation anyway
resp = await gogo_client.post(links["confirm"] + "/force-confirm")
assert resp.status_code == 200
assert "Termin potvrđen" in resp.text
finally:
del MockProvider.is_slot_free
await session.refresh(req)
assert req.status == BookingStatus.confirmed.value
assert len(sms) == 1

263
tests/test_partner_api.py Normal file
View File

@@ -0,0 +1,263 @@
"""partner_api provider against the fake partner server (Appendix B):
availability pull, request push + idempotency, catalog sync, webhook outcomes
(signature, idempotent replay), polling fallback."""
import json
from sqlalchemy import select
from gogo.domain import BookingStatus, Slot
from gogo.proposals import engine
from gogo.scheduling.base import get_provider
from tests.fake_partner import sign_webhook, staff_resolve
SLOT_1 = {"start": "2026-07-15T14:30:00+02:00", "end": "2026-07-15T15:15:00+02:00",
"staff_id": "EMP-3", "staff_name": "Merima"}
SLOT_2 = {"start": "2026-07-15T17:00:00+02:00", "end": "2026-07-15T17:45:00+02:00"}
async def create_partner_request(session, partner_tenant, **kw):
from gogo.models import Service
svc = (
await session.execute(select(Service).where(Service.tenant_id == partner_tenant.id))
).scalar_one()
defaults = dict(
service_id=str(svc.id),
source="voice",
client_name="Amra Hodžić",
client_phone="+38765123456",
service_name_raw="manikir",
slots=[Slot.model_validate(SLOT_2)],
summary="Klijentica želi manikir.",
)
defaults.update(kw)
return await engine.create_booking_request(session, partner_tenant, **defaults)
async def test_availability_pull(session, partner_tenant, wire_fake_partner):
from datetime import date
wire_fake_partner.slots["SRV-12"] = [SLOT_1, SLOT_2]
provider = await get_provider(session, partner_tenant)
from gogo.models import Service
svc = (
await session.execute(select(Service).where(Service.tenant_id == partner_tenant.id))
).scalar_one()
slots = await provider.get_availability(str(svc.id), date(2026, 7, 15), date(2026, 7, 22))
assert len(slots) == 2
assert slots[0].staff_name == "Merima"
assert slots[1].staff_name is None
async def test_push_and_idempotency(session, partner_tenant, wire_fake_partner, emails, sms):
req, result = await create_partner_request(session, partner_tenant)
await session.commit()
assert result.ok
assert result.partner_request_id == "REQ-0001"
assert req.partner_request_id == "REQ-0001"
# partner tenant default: no owner email (§8.2)
assert emails == []
stored = wire_fake_partner.requests["REQ-0001"]
assert stored["client"]["name"] == "Amra Hodžić"
assert stored["service_id"] == "SRV-12" # mapped to the partner's id
assert stored["source"] == "voice"
# replaying the same push (same Idempotency-Key) must not create a duplicate
provider = await get_provider(session, partner_tenant)
from gogo.domain import BookingRequestData
from gogo.models import utcnow
replay = BookingRequestData(
gogo_request_id=str(req.id),
tenant_id=str(partner_tenant.id),
created_at=utcnow(),
source="voice",
client_name=req.client_name,
client_phone=req.client_phone,
summary=req.summary,
)
result2 = await provider.deliver_request(replay)
assert result2.partner_request_id == "REQ-0001"
assert len(wire_fake_partner.requests) == 1
async def test_dry_run_creates_nothing(session, partner_tenant, wire_fake_partner):
from gogo.domain import BookingRequestData
from gogo.models import utcnow
provider = await get_provider(session, partner_tenant)
result = await provider.deliver_request(
BookingRequestData(
gogo_request_id="00000000-0000-0000-0000-000000000001",
tenant_id=str(partner_tenant.id),
created_at=utcnow(),
source="chat",
client_name="Test",
client_phone="+38700000000",
dry_run=True,
)
)
assert result.ok
assert wire_fake_partner.requests == {}
async def test_catalog_sync(session, partner_tenant, wire_fake_partner):
wire_fake_partner.services = [
{"id": "SRV-12", "name": "Manikir", "duration_min": 45,
"price_min": 20, "price_max": 25, "currency": "BAM",
"home_visit": False, "active": True},
{"id": "SRV-13", "name": "Gel nokti", "duration_min": 90,
"price_min": 50, "price_max": 70, "currency": "BAM",
"home_visit": False, "active": True},
]
provider = await get_provider(session, partner_tenant)
services = await provider.get_services()
assert [s.name for s in services] == ["Manikir", "Gel nokti"]
assert services[1].duration_min == 90
async def test_webhook_confirmed_sends_sms(
session, partner_tenant, wire_fake_partner, gogo_client, sms
):
req, _ = await create_partner_request(session, partner_tenant)
await session.commit()
resp = await staff_resolve(
gogo_client, str(partner_tenant.id), "REQ-0001", "confirmed", confirmed_slot=SLOT_2
)
assert resp.status_code == 200
await session.refresh(req)
assert req.status == BookingStatus.confirmed.value
assert len(sms) == 1
assert "Potvrđen termin" in sms[0][1]
# idempotent replay: same outcome again → ok, no second SMS
resp = await staff_resolve(
gogo_client, str(partner_tenant.id), "REQ-0001", "confirmed", confirmed_slot=SLOT_2
)
assert resp.status_code == 200
assert len(sms) == 1
async def test_webhook_resolved_sends_nothing(
session, partner_tenant, wire_fake_partner, gogo_client, sms
):
req, _ = await create_partner_request(session, partner_tenant)
await session.commit()
resp = await staff_resolve(gogo_client, str(partner_tenant.id), "REQ-0001", "resolved")
assert resp.status_code == 200
await session.refresh(req)
assert req.status == BookingStatus.resolved_by_owner.value
assert sms == []
async def test_webhook_rejected_sends_rejection_sms(
session, partner_tenant, wire_fake_partner, gogo_client, sms
):
req, _ = await create_partner_request(session, partner_tenant)
await session.commit()
resp = await staff_resolve(gogo_client, str(partner_tenant.id), "REQ-0001", "rejected")
assert resp.status_code == 200
await session.refresh(req)
assert req.status == BookingStatus.rejected.value
assert len(sms) == 1
assert "nažalost" in sms[0][1].lower()
async def test_webhook_bad_signature_rejected(
session, partner_tenant, wire_fake_partner, gogo_client, sms
):
req, _ = await create_partner_request(session, partner_tenant)
await session.commit()
body = json.dumps(
{"gogo_request_id": str(req.id), "partner_request_id": "REQ-0001",
"outcome": "confirmed", "confirmed_slot": SLOT_2}
).encode()
resp = await gogo_client.post(
f"/webhooks/partner/{partner_tenant.id}",
content=body,
headers={
"X-Gogo-Signature": sign_webhook(body, secret="wrong-secret"),
"Content-Type": "application/json",
},
)
assert resp.status_code == 401
await session.refresh(req)
assert req.status == BookingStatus.pending.value
assert sms == []
# missing header entirely
resp = await gogo_client.post(
f"/webhooks/partner/{partner_tenant.id}",
content=body,
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 401
async def test_webhook_confirmed_requires_slot(
session, partner_tenant, wire_fake_partner, gogo_client
):
req, _ = await create_partner_request(session, partner_tenant)
await session.commit()
body = json.dumps(
{"gogo_request_id": str(req.id), "partner_request_id": "REQ-0001",
"outcome": "confirmed"}
).encode()
resp = await gogo_client.post(
f"/webhooks/partner/{partner_tenant.id}",
content=body,
headers={"X-Gogo-Signature": sign_webhook(body), "Content-Type": "application/json"},
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "missing_slot"
async def test_webhook_conflicting_outcome_409(
session, partner_tenant, wire_fake_partner, gogo_client, sms
):
req, _ = await create_partner_request(session, partner_tenant)
await session.commit()
resp = await staff_resolve(gogo_client, str(partner_tenant.id), "REQ-0001", "resolved")
assert resp.status_code == 200
resp = await staff_resolve(
gogo_client, str(partner_tenant.id), "REQ-0001", "confirmed", confirmed_slot=SLOT_2
)
assert resp.status_code == 409
async def test_polling_fallback(session, partner_tenant, wire_fake_partner, sms, monkeypatch):
from gogo import jobs
from gogo.models import ProviderConfig
# enable polling for the tenant
cfg = (
await session.execute(
select(ProviderConfig).where(ProviderConfig.tenant_id == partner_tenant.id)
)
).scalar_one()
cfg.config = {**cfg.config, "polling_fallback": True}
req, _ = await create_partner_request(session, partner_tenant)
await session.commit()
# staff confirm inside the partner software only (no webhook call)
wire_fake_partner.requests["REQ-0001"]["status"] = "confirmed"
wire_fake_partner.requests["REQ-0001"]["confirmed_slot"] = SLOT_2
await jobs.run_partner_polling_job()
await session.refresh(req)
assert req.status == BookingStatus.confirmed.value
assert len(sms) == 1

View File

@@ -0,0 +1,169 @@
"""Proposal engine end-to-end against the mock provider (§9, M1)."""
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from sqlalchemy import select
from gogo.domain import BookingStatus, Slot
from gogo.models import ActionToken
from gogo.proposals import engine
TZ = ZoneInfo("Europe/Sarajevo")
def slot(days_ahead: int = 2, hour: int = 17) -> Slot:
base = datetime.now(TZ).replace(hour=hour, minute=0, second=0, microsecond=0)
start = base + timedelta(days=days_ahead)
return Slot(start=start, end=start + timedelta(minutes=45))
async def create(session, tenant, **kw):
defaults = dict(
source="voice",
client_name="Amra Hodžić",
client_phone="+38765123456",
service_name_raw="šišanje i feniranje",
slots=[slot()],
summary="Klijentica želi šišanje i feniranje.",
)
defaults.update(kw)
return await engine.create_booking_request(session, tenant, **defaults)
async def test_create_sends_email_with_actions_and_ics(session, tenant, emails, sms):
req, result = await create(session, tenant)
await session.commit()
assert result.ok
assert req.status == BookingStatus.pending.value
assert req.expires_at is not None
assert len(emails) == 1
mail = emails[0]
assert mail.to == ["merima@example.ba"]
assert "Novi zahtjev za termin" in mail.subject
assert "Amra Hodžić" in mail.subject
assert "+38765123456" in mail.text
assert "RIJEŠENO" in mail.text
assert "POTVRDI" in mail.text
assert "ODBIJ" in mail.text
# .ics attachment with METHOD:REQUEST
assert mail.attachments and mail.attachments[0][0] == "termin.ics"
assert b"METHOD:REQUEST" in mail.attachments[0][2]
assert b"BEGIN:VEVENT" in mail.attachments[0][2]
# action tokens persisted: resolve + confirm(1 slot) + reject
tokens = (await session.execute(select(ActionToken))).scalars().all()
assert {t.action for t in tokens} == {"resolve", "confirm", "reject"}
# no client SMS by default (sms_request_received off)
assert sms == []
async def test_request_received_sms_when_enabled(session, tenant, emails, sms):
tenant.sms_request_received = True
await create(session, tenant)
await session.commit()
assert len(sms) == 1
assert sms[0][0] == "+38765123456"
assert "Primili smo vaš zahtjev" in sms[0][1]
async def test_resolve_sends_no_sms(session, tenant, emails, sms):
req, _ = await create(session, tenant)
await engine.resolve_request(session, tenant, req)
await session.commit()
assert req.status == BookingStatus.resolved_by_owner.value
assert req.resolved_at is not None
assert sms == [] # primary flow: owner contacted client directly, Gogo sends nothing
async def test_confirm_sends_confirmation_sms(session, tenant, emails, sms):
s = slot()
req, _ = await create(session, tenant, slots=[s])
ok = await engine.confirm_request(session, tenant, req, s)
await session.commit()
assert ok
assert req.status == BookingStatus.confirmed.value
assert len(sms) == 1
to, body = sms[0]
assert to == "+38765123456"
assert "Potvrđen termin" in body
assert "Salon Merima" in body
async def test_confirm_recheck_busy_blocks_transition(
session, tenant, emails, sms, clean_mock_provider
):
s = slot()
req, _ = await create(session, tenant, slots=[s])
# is_slot_free is only defined for providers that compute availability;
# mock has no is_slot_free → recheck is skipped. Simulate a gcal-style
# provider recheck by attaching one.
from gogo.scheduling.mock import MockProvider
async def is_slot_free(self, service_id, slot_):
return False
MockProvider.is_slot_free = is_slot_free
try:
ok = await engine.confirm_request(session, tenant, req, s, recheck=True)
finally:
del MockProvider.is_slot_free
assert not ok
assert req.status == BookingStatus.pending.value
assert sms == []
async def test_reject_sends_sms_with_custom_text(session, tenant, emails, sms):
req, _ = await create(session, tenant)
await engine.reject_request(session, tenant, req, custom_sms="Nažalost ne može ovaj termin.")
await session.commit()
assert req.status == BookingStatus.rejected.value
assert sms == [("+38765123456", "Nažalost ne može ovaj termin.")]
async def test_transitions_are_exclusive(session, tenant, emails, sms):
req, _ = await create(session, tenant)
await engine.resolve_request(session, tenant, req)
with pytest.raises(engine.TransitionError):
await engine.reject_request(session, tenant, req)
# idempotent replay of same action is fine
await engine.resolve_request(session, tenant, req)
assert req.status == BookingStatus.resolved_by_owner.value
async def test_expiry_job_expires_and_sends_apology(session, tenant, emails, sms):
req, _ = await create(session, tenant)
req.expires_at = datetime.now(UTC) - timedelta(minutes=1)
await session.flush()
expired = await engine.process_expirations(session)
await session.commit()
assert expired == 1
assert req.status == BookingStatus.expired.value
assert len(sms) == 1
assert "pozovite nas ponovo" in sms[0][1].lower() or "pozovite" in sms[0][1].lower()
async def test_reminder_email_at_half_ttl(session, tenant, emails, sms):
req, _ = await create(session, tenant)
emails.clear()
# 24h TTL → reminder when <= 12h remain
req.expires_at = datetime.now(UTC) + timedelta(hours=11)
await session.flush()
expired = await engine.process_expirations(session)
await session.commit()
assert expired == 0
assert req.reminder_sent
assert len(emails) == 1
assert "Podsjetnik" in emails[0].subject
# second run must not re-send
await engine.process_expirations(session)
assert len(emails) == 1
async def test_slots_capped_at_three(session, tenant, emails, sms):
req, _ = await create(session, tenant, slots=[slot(1), slot(2), slot(3), slot(4), slot(5)])
assert len(req.slots) == 3

96
tests/test_slots.py Normal file
View File

@@ -0,0 +1,96 @@
"""Slot computation (§8.1): working hours busy, quantization, notice, horizon."""
from datetime import date, datetime
from zoneinfo import ZoneInfo
from gogo.scheduling.slots import compute_slots, subtract_busy
TZ = ZoneInfo("Europe/Sarajevo")
def dt(day: int, h: int, m: int = 0) -> datetime:
return datetime(2026, 7, day, h, m, tzinfo=TZ)
WH = {
"mon": [["09:00", "13:00"], ["14:00", "18:00"]], # lunch break
"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": [],
}
# Mon 2026-07-13 .. Sun 2026-07-19
NOW = dt(13, 8, 0) # Monday 08:00
def test_subtract_busy_splits_interval():
free = subtract_busy((dt(13, 9), dt(13, 13)), [(dt(13, 10), dt(13, 11))])
assert free == [(dt(13, 9), dt(13, 10)), (dt(13, 11), dt(13, 13))]
def test_subtract_busy_no_overlap():
free = subtract_busy((dt(13, 9), dt(13, 13)), [(dt(13, 14), dt(13, 15))])
assert free == [(dt(13, 9), dt(13, 13))]
def test_slots_respect_break_and_duration():
slots = compute_slots(
working_hours=WH, busy=[], duration_min=60, date_from=date(2026, 7, 13),
date_to=date(2026, 7, 13), tz=TZ, now=NOW, min_notice_hours=0, max_slots=50,
)
starts = [s.start for s in slots]
# no slot may span the 13:00-14:00 break
assert dt(13, 13) not in starts
for s in slots:
assert not (s.start < dt(13, 14) and s.end > dt(13, 13))
assert slots[0].start == dt(13, 9)
assert all((s.end - s.start).total_seconds() == 3600 for s in slots)
def test_min_notice_pushes_first_slot():
slots = compute_slots(
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 13),
date_to=date(2026, 7, 13), tz=TZ, now=dt(13, 9, 5), min_notice_hours=2,
)
assert slots[0].start >= dt(13, 11, 5)
def test_busy_blocks_remove_slots():
slots = compute_slots(
working_hours=WH, busy=[(dt(14, 9), dt(14, 12))], duration_min=60,
date_from=date(2026, 7, 14), date_to=date(2026, 7, 14), tz=TZ, now=NOW,
min_notice_hours=0,
)
assert slots[0].start == dt(14, 12)
def test_sunday_closed():
slots = compute_slots(
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 19),
date_to=date(2026, 7, 19), tz=TZ, now=NOW, min_notice_hours=0,
)
assert slots == []
def test_max_days_ahead_horizon():
slots = compute_slots(
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 13),
date_to=date(2026, 8, 30), tz=TZ, now=NOW, min_notice_hours=0,
max_days_ahead=2, max_slots=1000,
)
assert max(s.start for s in slots).date() <= date(2026, 7, 15)
def test_buffer_extends_step():
slots = compute_slots(
working_hours=WH, busy=[], duration_min=45, buffer_min=15,
date_from=date(2026, 7, 14), date_to=date(2026, 7, 14), tz=TZ, now=NOW,
min_notice_hours=0,
)
# steps of 60 (45+15), but slot end = start + 45
assert slots[0].start == dt(14, 9)
assert slots[0].end == dt(14, 9, 45)
assert slots[1].start == dt(14, 10)

67
tests/test_units.py Normal file
View File

@@ -0,0 +1,67 @@
"""Small unit tests: working hours, Bosnian formatting, SMS templates, crypto."""
from datetime import datetime
from zoneinfo import ZoneInfo
from gogo import crypto
from gogo.hours import DEFAULT_WORKING_HOURS, hours_summary_bs, is_open_at
from gogo.i18n import fmt_price, fmt_slot
from gogo.models import Tenant
from gogo.sms.templates import render_sms
TZ = ZoneInfo("Europe/Sarajevo")
def test_is_open_at():
wh = DEFAULT_WORKING_HOURS
assert is_open_at(wh, datetime(2026, 7, 15, 10, 0, tzinfo=TZ), TZ) # Wed 10:00
assert not is_open_at(wh, datetime(2026, 7, 15, 20, 0, tzinfo=TZ), TZ) # Wed 20:00
assert not is_open_at(wh, datetime(2026, 7, 19, 10, 0, tzinfo=TZ), TZ) # Sun
assert is_open_at(wh, datetime(2026, 7, 18, 13, 59, tzinfo=TZ), TZ) # Sat 13:59
assert not is_open_at(wh, datetime(2026, 7, 18, 14, 0, tzinfo=TZ), TZ) # Sat 14:00
def test_hours_summary_bs():
s = hours_summary_bs(DEFAULT_WORKING_HOURS)
assert "ponedjeljak: 09:0018:00" in s
assert "nedjelja: zatvoreno" in s
def test_fmt_slot_bosnian():
dt = datetime(2026, 7, 15, 17, 0, tzinfo=TZ) # Wednesday
assert fmt_slot(dt) == "srijeda, 15.07. u 17:00"
def test_fmt_price():
assert fmt_price(60, 90) == "6090 KM"
assert fmt_price(25, 25) == "25 KM"
assert fmt_price(30, None) == "30 KM"
assert fmt_price(None, None) == "cijena na upit"
def make_tenant(**kw):
defaults = dict(name="Salon Merima", sms_templates={})
defaults.update(kw)
return Tenant(**defaults)
def test_sms_default_templates():
t = make_tenant()
body = render_sms(
t, "confirmation", usluga="Manikir", dan="srijeda", datum="15.07.2026.", vrijeme="17:00"
)
assert body == "Potvrđen termin: Manikir, srijeda 15.07.2026. u 17:00h — Salon Merima."
def test_sms_tenant_override():
t = make_tenant(sms_templates={"rejection": "{salon}: ne može, žao nam je."})
assert render_sms(t, "rejection") == "Salon Merima: ne može, žao nam je."
def test_sms_unknown_placeholder_left_intact():
t = make_tenant(sms_templates={"missed_call": "Pozovite {salon}{nepoznato}"})
assert render_sms(t, "missed_call", link="x") == "Pozovite Salon Merima — {nepoznato}"
def test_crypto_roundtrip():
assert crypto.decrypt(crypto.encrypt("tajna-lozinka")) == "tajna-lozinka"