Files
roa2web-service-auto/backend/modules/telegram/routers/internal_api.py
Claude Agent 30f55cf18b feat: Add A-Z filter for clients/suppliers in Telegram bot
- Add A-Z alphabetical filter keyboard for clients and suppliers lists
  (same pattern as company selection, without emoji)
- Increase clients/suppliers list pagination from 10 to 20 items per page
- Remove emoji from company A-Z filter button for consistency
- Add 6 new callback handlers: clients_alpha_menu, clients_alpha:LETTER,
  clients_alpha_page:PAGE:LETTER, and supplier equivalents
- Dashboard service and models updates
- Telegram bot: email handlers, auth, DB operations, internal API improvements
- Frontend: dashboard cards updates (CashFlow, Clienti, Furnizori, Treasury)
- Frontend: SolduriCompactCard and CollapsibleCard improvements
- DashboardView enhancements
- start.sh and run-with-restart.sh script updates
- IIS web.config and service worker updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:34:15 +00:00

359 lines
11 KiB
Python

"""
Internal API for Backend Communication
This FastAPI application provides internal endpoints for the ROA2WEB backend
to communicate with the Telegram bot service. Main purpose is to save
authentication codes generated in the web frontend.
This API runs alongside the Telegram bot and is accessible only internally
(not exposed to public internet).
"""
import logging
import os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from backend.modules.telegram.db.operations import create_auth_code, get_auth_code
from backend.modules.telegram.db.database import get_database_stats
logger = logging.getLogger(__name__)
# Initialize APIRouter (converted from FastAPI app for unified backend)
internal_api = APIRouter()
# ============================================================================
# REQUEST/RESPONSE MODELS
# ============================================================================
class SaveAuthCodeRequest(BaseModel):
"""
Request model for saving an authentication code.
"""
code: str = Field(
...,
description="8-character authentication code",
min_length=8,
max_length=8
)
telegram_user_id: int = Field(
...,
description="Telegram user ID (if known, otherwise 0)",
ge=0
)
oracle_username: str = Field(
...,
description="Oracle username to link"
)
expires_in_minutes: int = Field(
default=5,
description="Code expiration time in minutes",
ge=1,
le=60
)
server_id: Optional[str] = Field(
default=None,
description="Oracle server ID (for multi-server mode)"
)
class SaveAuthCodeResponse(BaseModel):
"""
Response model for save auth code endpoint.
"""
success: bool = Field(..., description="Whether the operation succeeded")
code: str = Field(..., description="The saved authentication code")
expires_at: Optional[str] = Field(None, description="Expiration timestamp (ISO format)")
message: Optional[str] = Field(None, description="Additional message")
class VerifyAuthCodeRequest(BaseModel):
"""
Request model for verifying an authentication code.
"""
code: str = Field(..., description="Authentication code to verify")
class VerifyAuthCodeResponse(BaseModel):
"""
Response model for verify auth code endpoint.
"""
valid: bool = Field(..., description="Whether the code is valid")
oracle_username: Optional[str] = Field(None, description="Oracle username if valid")
telegram_user_id: Optional[int] = Field(None, description="Telegram user ID if set")
message: Optional[str] = Field(None, description="Additional message")
class HealthResponse(BaseModel):
"""
Response model for health check endpoint.
"""
status: str = Field(..., description="Service status")
timestamp: str = Field(..., description="Current timestamp")
database_stats: Optional[dict] = Field(None, description="Database statistics")
# ============================================================================
# ENDPOINTS
# ============================================================================
@internal_api.post(
"/internal/save-code",
response_model=SaveAuthCodeResponse,
status_code=status.HTTP_201_CREATED,
summary="Save Authentication Code",
description="Save an authentication code for Telegram linking (called by backend)"
)
async def save_auth_code(request: SaveAuthCodeRequest):
"""
Save an authentication code to SQLite database.
This endpoint is called by the FastAPI backend when a user generates
a linking code in the web frontend.
**Flow:**
1. User logs in to web frontend
2. User clicks "Link Telegram Account"
3. Backend generates 8-character code
4. Backend calls this endpoint to save code
5. Backend returns code to user for display
6. User sends code to Telegram bot via /start command
Args:
request: SaveAuthCodeRequest with code, oracle_username, etc.
Returns:
SaveAuthCodeResponse with success status and code details
Raises:
HTTPException 400: If code already exists or invalid data
HTTPException 500: If database operation fails
"""
try:
logger.info(
f"Saving auth code for Oracle user: {request.oracle_username}, "
f"code: {request.code}"
)
# Check if code already exists
existing_code = await get_auth_code(request.code)
if existing_code:
logger.warning(f"Code {request.code} already exists")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Code {request.code} already exists. Generate a new unique code."
)
# Create auth code in database
success = await create_auth_code(
code=request.code,
telegram_user_id=request.telegram_user_id,
oracle_username=request.oracle_username,
expires_in_minutes=request.expires_in_minutes,
server_id=request.server_id
)
if not success:
logger.error(f"Failed to save auth code {request.code}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to save authentication code to database"
)
# Calculate expiration time
from datetime import timedelta
expires_at = (datetime.now() + timedelta(minutes=request.expires_in_minutes)).isoformat()
logger.info(f"Auth code {request.code} saved successfully")
return SaveAuthCodeResponse(
success=True,
code=request.code,
expires_at=expires_at,
message=f"Code saved successfully, expires in {request.expires_in_minutes} minutes"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error saving auth code: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal server error: {str(e)}"
)
@internal_api.post(
"/internal/verify-code",
response_model=VerifyAuthCodeResponse,
summary="Verify Authentication Code",
description="Verify if an authentication code is valid (without using it)"
)
async def verify_auth_code(request: VerifyAuthCodeRequest):
"""
Verify if an authentication code exists and is valid.
This is a read-only check that does NOT mark the code as used.
Useful for backend to verify codes before user links Telegram account.
Args:
request: VerifyAuthCodeRequest with code to verify
Returns:
VerifyAuthCodeResponse with validation status
Raises:
HTTPException 404: If code not found
"""
try:
logger.info(f"Verifying auth code: {request.code}")
code_data = await get_auth_code(request.code)
if not code_data:
return VerifyAuthCodeResponse(
valid=False,
message="Code not found"
)
# Check if code is expired
expires_at_str = code_data.get('expires_at')
expires_at = datetime.fromisoformat(expires_at_str) if expires_at_str else None
is_expired = expires_at and datetime.now() >= expires_at
is_used = code_data.get('used', 0) == 1
if is_expired:
return VerifyAuthCodeResponse(
valid=False,
oracle_username=code_data.get('oracle_username'),
message="Code expired"
)
if is_used:
return VerifyAuthCodeResponse(
valid=False,
oracle_username=code_data.get('oracle_username'),
message="Code already used"
)
# Code is valid
return VerifyAuthCodeResponse(
valid=True,
oracle_username=code_data.get('oracle_username'),
telegram_user_id=code_data.get('telegram_user_id'),
message="Code is valid"
)
except Exception as e:
logger.error(f"Error verifying auth code: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal server error: {str(e)}"
)
@internal_api.get(
"/internal/health",
response_model=HealthResponse,
summary="Health Check",
description="Check if the internal API and database are healthy"
)
async def health_check():
"""
Health check endpoint.
Returns service status and database statistics.
Returns:
HealthResponse with status and stats
"""
try:
# Get database stats
stats = await get_database_stats()
return HealthResponse(
status="healthy",
timestamp=datetime.now().isoformat(),
database_stats=stats
)
except Exception as e:
logger.error(f"Health check failed: {e}", exc_info=True)
return HealthResponse(
status="unhealthy",
timestamp=datetime.now().isoformat(),
database_stats={"error": str(e)}
)
@internal_api.get(
"/internal/stats",
summary="Database Statistics",
description="Get detailed database statistics"
)
async def get_stats():
"""
Get detailed database statistics.
Returns:
JSON with database statistics
"""
try:
stats = await get_database_stats()
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"success": True,
"timestamp": datetime.now().isoformat(),
"stats": stats
}
)
except Exception as e:
logger.error(f"Error getting stats: {e}", exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"success": False,
"error": str(e)
}
)
# ============================================================================
# EXCEPTION HANDLERS
# ============================================================================
# ============================================================================
# NOTE: Exception handlers and startup/shutdown events removed
# These are FastAPI-specific and don't work with APIRouter
# The unified backend (main.py) handles these at the app level
# ============================================================================
# @internal_api.exception_handler(Exception) - Not supported by APIRouter
# async def global_exception_handler(request, exc):
# """Global exception handler - moved to main.py"""
# pass
# @internal_api.on_event("startup") - Not supported by APIRouter
# async def startup_event():
# """Startup event - handled by main.py lifespan"""
# pass
# @internal_api.on_event("shutdown") - Not supported by APIRouter
# async def shutdown_event():
# """Shutdown event - handled by main.py lifespan"""
# pass
# Export the APIRouter
__all__ = ['internal_api']