This commit is contained in:
debu-2 2026-10-13 02:01:13 +05:30
parent 9dbda69db1
commit a2b076e9a1
6 changed files with 122 additions and 8 deletions

View file

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

View file

@ -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(

View file

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

View file

@ -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."

View file

@ -7,6 +7,7 @@ export function WhatsAppTokenPage() {
const [token, setToken] = useState('')
const [wabaId, setWabaId] = useState('')
const [phoneNumberId, setPhoneNumberId] = useState('')
const [appSecret, setAppSecret] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState<WhatsAppSignupResult | null>(null)
@ -21,9 +22,11 @@ export function WhatsAppTokenPage() {
access_token: token.trim(),
waba_id: wabaId.trim(),
phone_number_id: phoneNumberId.trim() || null,
app_secret: appSecret.trim() || null,
})
setResult(payload)
setToken('')
setAppSecret('')
if (payload.status === 'failed' || payload.status === 'expired') setError(payload.message)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'This token could not be saved')
@ -61,6 +64,11 @@ export function WhatsAppTokenPage() {
<span>Optional. Use it when the account has more than one number.</span>
<input value={phoneNumberId} onChange={(event) => setPhoneNumberId(event.target.value)} inputMode="numeric" />
</label>
<label className="explorer-input">
<strong>App secret</strong>
<span>From the Meta app that created this token, under App settings → Basic. Replies are signed with it. Skip this when the token already belongs to the Urmi app.</span>
<input type="password" value={appSecret} onChange={(event) => setAppSecret(event.target.value)} autoComplete="off" />
</label>
<button className="primary-button" type="submit" disabled={busy}>
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
Save token

View file

@ -1263,7 +1263,7 @@ export const whatsappApi = {
events: () => jsonRequest<WhatsAppWebhookEvent[]>('/api/v1/whatsapp/events'),
signupConfig: () => jsonRequest<WhatsAppSignupConfig>('/api/v1/whatsapp/signup/config'),
accounts: () => jsonRequest<WhatsAppAccount[]>('/api/v1/whatsapp/accounts'),
connectToken: (payload: { access_token: string; waba_id: string; phone_number_id?: string | null }) =>
connectToken: (payload: { access_token: string; waba_id: string; phone_number_id?: string | null; app_secret?: string | null }) =>
jsonRequest<WhatsAppSignupResult>('/api/v1/whatsapp/connections/token', {
method: 'POST',
body: JSON.stringify(payload),