token
This commit is contained in:
parent
9dbda69db1
commit
a2b076e9a1
6 changed files with 122 additions and 8 deletions
|
|
@ -73,6 +73,8 @@ async def lifespan(_: FastAPI):
|
|||
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS user_id UUID"))
|
||||
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS phone_number_id VARCHAR(64)"))
|
||||
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS contact_name VARCHAR(200)"))
|
||||
conn.execute(text("ALTER TABLE whatsapp_meta_connections ADD COLUMN IF NOT EXISTS meta_app_id VARCHAR(64)"))
|
||||
conn.execute(text("ALTER TABLE whatsapp_meta_connections ADD COLUMN IF NOT EXISTS meta_app_secret TEXT"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_user_id ON whatsapp_messages (user_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_phone_number_id ON whatsapp_messages (phone_number_id)"))
|
||||
yield
|
||||
|
|
|
|||
|
|
@ -554,6 +554,8 @@ class WhatsAppMetaConnection(Base):
|
|||
business_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
granted_scopes: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
access_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
meta_app_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
meta_app_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ from sqlalchemy.orm import Session
|
|||
from .auth import current_user
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppPhoneAsset, WhatsAppWebhookEvent
|
||||
from .models import (
|
||||
UserAccount,
|
||||
WhatsAppBusinessAccount,
|
||||
WhatsAppMetaConnection,
|
||||
WhatsAppPhoneAsset,
|
||||
WhatsAppWebhookEvent,
|
||||
)
|
||||
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
||||
from .whatsapp_config import resolved_whatsapp_app_secret
|
||||
from .whatsapp_onboarding import (
|
||||
|
|
@ -40,6 +46,25 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp"])
|
||||
|
||||
|
||||
def _webhook_secrets(db: Session) -> list[str]:
|
||||
"""Platform secret for the admin app, plus each customer's own Meta app secret."""
|
||||
found: list[str] = []
|
||||
primary = resolved_whatsapp_app_secret(db).strip()
|
||||
if primary:
|
||||
found.append(primary)
|
||||
extras = db.scalars(
|
||||
select(WhatsAppMetaConnection.meta_app_secret).where(
|
||||
WhatsAppMetaConnection.meta_app_secret.is_not(None),
|
||||
WhatsAppMetaConnection.status != "disconnected",
|
||||
)
|
||||
).all()
|
||||
for item in extras:
|
||||
value = (item or "").strip()
|
||||
if value and value not in found:
|
||||
found.append(value)
|
||||
return found
|
||||
|
||||
|
||||
def _serialize_event(event: WhatsAppWebhookEvent) -> dict:
|
||||
return {
|
||||
"id": str(event.id),
|
||||
|
|
@ -84,10 +109,10 @@ async def receive_webhook(
|
|||
) -> dict[str, str]:
|
||||
"""Receive inbound WhatsApp Business Platform webhook payloads."""
|
||||
raw_body = await request.body()
|
||||
app_secret = resolved_whatsapp_app_secret(db)
|
||||
secrets = _webhook_secrets(db)
|
||||
|
||||
if app_secret:
|
||||
if not verify_meta_signature(app_secret, raw_body, x_hub_signature_256):
|
||||
if secrets:
|
||||
if not any(verify_meta_signature(secret, raw_body, x_hub_signature_256) for secret in secrets):
|
||||
logger.warning("WhatsApp webhook signature validation failed")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
|
||||
elif settings.environment == "production":
|
||||
|
|
@ -179,6 +204,7 @@ class ManualTokenRequest(BaseModel):
|
|||
access_token: str = Field(min_length=1, max_length=4096)
|
||||
waba_id: str = Field(min_length=1, max_length=64)
|
||||
phone_number_id: str | None = Field(default=None, max_length=64)
|
||||
app_secret: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class SignupCompleteRequest(BaseModel):
|
||||
|
|
@ -227,6 +253,7 @@ async def whatsapp_connect_token(
|
|||
payload.access_token,
|
||||
payload.waba_id,
|
||||
payload.phone_number_id,
|
||||
payload.app_secret,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -283,6 +283,47 @@ async def _subscribe(token: str, waba_id: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _customer_callback() -> dict[str, str] | None:
|
||||
"""Public address where a customer's own Meta app should deliver messages."""
|
||||
url = settings.whatsapp_webhook_url
|
||||
verify = settings.whatsapp_verify_token.strip()
|
||||
if not url.startswith("https://") or not verify or len(url) > 200:
|
||||
return None
|
||||
return {"override_callback_uri": url, "verify_token": verify}
|
||||
|
||||
|
||||
async def _token_app_id(token: str) -> str | None:
|
||||
try:
|
||||
data = await _graph("GET", "app", token=token, params={"fields": "id"})
|
||||
except HTTPException:
|
||||
return None
|
||||
app_id = str(data.get("id") or "").strip()
|
||||
return app_id or None
|
||||
|
||||
|
||||
async def _attach_customer_webhook(token: str, waba_id: str, phone_ids: list[str]) -> bool:
|
||||
"""Send this account's messages to the workspace, using the customer's token."""
|
||||
callback = _customer_callback()
|
||||
if callback is None:
|
||||
return False
|
||||
try:
|
||||
await _graph("POST", f"{waba_id}/subscribed_apps", token=token, json_body=callback)
|
||||
except HTTPException:
|
||||
logger.warning("WhatsApp webhook override failed for waba %s", waba_id[-4:])
|
||||
return False
|
||||
for phone_id in phone_ids:
|
||||
try:
|
||||
await _graph(
|
||||
"POST",
|
||||
phone_id,
|
||||
token=token,
|
||||
json_body={"webhook_configuration": callback},
|
||||
)
|
||||
except HTTPException:
|
||||
logger.info("Phone webhook override failed for %s", phone_id[-4:])
|
||||
return True
|
||||
|
||||
|
||||
async def _template_count(token: str, waba_id: str) -> tuple[int, bool]:
|
||||
try:
|
||||
data = await _graph(
|
||||
|
|
@ -732,7 +773,12 @@ async def _save_token_account(
|
|||
if previous_connection_id and previous_connection_id != connection.id:
|
||||
previous = db.get(WhatsAppMetaConnection, previous_connection_id)
|
||||
if previous is not None and previous.user_id == user.id:
|
||||
if not connection.meta_app_secret and previous.meta_app_secret:
|
||||
connection.meta_app_secret = previous.meta_app_secret
|
||||
if not connection.meta_app_id and previous.meta_app_id:
|
||||
connection.meta_app_id = previous.meta_app_id
|
||||
previous.access_token = None
|
||||
previous.meta_app_secret = None
|
||||
account.name = name
|
||||
account.last_error = None
|
||||
account.management_status = "active"
|
||||
|
|
@ -740,14 +786,35 @@ async def _save_token_account(
|
|||
phones = await _phones_for_token(token, waba_id, phone_id)
|
||||
if phones:
|
||||
_apply_phones(db, user, account, phones, phone_id)
|
||||
subscribed = await _subscribe(token, waba_id)
|
||||
app_id = await _token_app_id(token)
|
||||
if app_id:
|
||||
connection.meta_app_id = app_id
|
||||
platform_app = settings.meta_app_id.strip()
|
||||
same_app = bool(app_id and platform_app and app_id == platform_app)
|
||||
phone_ids = [phone.phone_number_id for phone in account.phones]
|
||||
if same_app or connection.meta_app_secret:
|
||||
receiving = await _attach_customer_webhook(token, waba_id, phone_ids)
|
||||
if not receiving and same_app:
|
||||
receiving = await _subscribe(token, waba_id)
|
||||
else:
|
||||
await _subscribe(token, waba_id)
|
||||
receiving = False
|
||||
template_count, templates_ok = await _template_count(token, waba_id)
|
||||
account.template_count = template_count
|
||||
account.templates_status = "active" if templates_ok else "unavailable"
|
||||
account.webhooks_status = "active" if subscribed else "unavailable"
|
||||
account.webhooks_status = "active" if receiving else "unavailable"
|
||||
if account.phones:
|
||||
account.status = "connected"
|
||||
account.messaging_status = "active"
|
||||
if receiving:
|
||||
account.last_error = None
|
||||
elif same_app:
|
||||
account.last_error = "Sending is on. Meta did not accept the delivery address for replies."
|
||||
else:
|
||||
account.last_error = (
|
||||
"Sending uses this token. Save it again with the App Secret from the Meta app that created the token "
|
||||
"so replies arrive in this workspace."
|
||||
)
|
||||
_audit(db, user, "token_connected", "WhatsApp token saved for this account", connection_id=connection.id, waba_id=waba_id)
|
||||
else:
|
||||
account.status = "incomplete"
|
||||
|
|
@ -763,6 +830,7 @@ async def connect_with_token(
|
|||
access_token: str,
|
||||
waba_id: str,
|
||||
phone_number_id: str | None = None,
|
||||
app_secret: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Store a customer-supplied WhatsApp token. Does not use Embedded Signup."""
|
||||
token = _clean_token(access_token)
|
||||
|
|
@ -780,12 +848,19 @@ async def connect_with_token(
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Phone number ID should be the number Meta shows for that phone.",
|
||||
)
|
||||
secret = "".join(str(app_secret or "").split())
|
||||
if secret and len(secret) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="App secret should be the value from the Meta app that created this token.",
|
||||
)
|
||||
|
||||
connection = WhatsAppMetaConnection(
|
||||
user_id=user.id,
|
||||
status="pending",
|
||||
granted_scopes="manual",
|
||||
access_token=token,
|
||||
meta_app_secret=secret or None,
|
||||
)
|
||||
db.add(connection)
|
||||
db.flush()
|
||||
|
|
@ -802,7 +877,7 @@ async def connect_with_token(
|
|||
db.refresh(account)
|
||||
if account.status == "connected":
|
||||
overall = "connected"
|
||||
message = "This WhatsApp account is saved for the workspace."
|
||||
message = account.last_error or "This WhatsApp account is saved. Messages send and arrive in this workspace."
|
||||
elif account.status == "incomplete":
|
||||
overall = "incomplete"
|
||||
message = account.last_error or "The token is saved. Add the phone number ID to finish."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue