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 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 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_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_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)"))
|
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_phone_number_id ON whatsapp_messages (phone_number_id)"))
|
||||||
yield
|
yield
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,8 @@ class WhatsAppMetaConnection(Base):
|
||||||
business_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
business_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
granted_scopes: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
granted_scopes: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
access_token: Mapped[str | None] = mapped_column(Text, 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)
|
last_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,13 @@ from sqlalchemy.orm import Session
|
||||||
from .auth import current_user
|
from .auth import current_user
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import get_db
|
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_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
||||||
from .whatsapp_config import resolved_whatsapp_app_secret
|
from .whatsapp_config import resolved_whatsapp_app_secret
|
||||||
from .whatsapp_onboarding import (
|
from .whatsapp_onboarding import (
|
||||||
|
|
@ -40,6 +46,25 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp"])
|
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:
|
def _serialize_event(event: WhatsAppWebhookEvent) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(event.id),
|
"id": str(event.id),
|
||||||
|
|
@ -84,10 +109,10 @@ async def receive_webhook(
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Receive inbound WhatsApp Business Platform webhook payloads."""
|
"""Receive inbound WhatsApp Business Platform webhook payloads."""
|
||||||
raw_body = await request.body()
|
raw_body = await request.body()
|
||||||
app_secret = resolved_whatsapp_app_secret(db)
|
secrets = _webhook_secrets(db)
|
||||||
|
|
||||||
if app_secret:
|
if secrets:
|
||||||
if not verify_meta_signature(app_secret, raw_body, x_hub_signature_256):
|
if not any(verify_meta_signature(secret, raw_body, x_hub_signature_256) for secret in secrets):
|
||||||
logger.warning("WhatsApp webhook signature validation failed")
|
logger.warning("WhatsApp webhook signature validation failed")
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
|
||||||
elif settings.environment == "production":
|
elif settings.environment == "production":
|
||||||
|
|
@ -179,6 +204,7 @@ class ManualTokenRequest(BaseModel):
|
||||||
access_token: str = Field(min_length=1, max_length=4096)
|
access_token: str = Field(min_length=1, max_length=4096)
|
||||||
waba_id: str = Field(min_length=1, max_length=64)
|
waba_id: str = Field(min_length=1, max_length=64)
|
||||||
phone_number_id: str | None = Field(default=None, 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):
|
class SignupCompleteRequest(BaseModel):
|
||||||
|
|
@ -227,6 +253,7 @@ async def whatsapp_connect_token(
|
||||||
payload.access_token,
|
payload.access_token,
|
||||||
payload.waba_id,
|
payload.waba_id,
|
||||||
payload.phone_number_id,
|
payload.phone_number_id,
|
||||||
|
payload.app_secret,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,47 @@ async def _subscribe(token: str, waba_id: str) -> bool:
|
||||||
return False
|
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]:
|
async def _template_count(token: str, waba_id: str) -> tuple[int, bool]:
|
||||||
try:
|
try:
|
||||||
data = await _graph(
|
data = await _graph(
|
||||||
|
|
@ -732,7 +773,12 @@ async def _save_token_account(
|
||||||
if previous_connection_id and previous_connection_id != connection.id:
|
if previous_connection_id and previous_connection_id != connection.id:
|
||||||
previous = db.get(WhatsAppMetaConnection, previous_connection_id)
|
previous = db.get(WhatsAppMetaConnection, previous_connection_id)
|
||||||
if previous is not None and previous.user_id == user.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.access_token = None
|
||||||
|
previous.meta_app_secret = None
|
||||||
account.name = name
|
account.name = name
|
||||||
account.last_error = None
|
account.last_error = None
|
||||||
account.management_status = "active"
|
account.management_status = "active"
|
||||||
|
|
@ -740,14 +786,35 @@ async def _save_token_account(
|
||||||
phones = await _phones_for_token(token, waba_id, phone_id)
|
phones = await _phones_for_token(token, waba_id, phone_id)
|
||||||
if phones:
|
if phones:
|
||||||
_apply_phones(db, user, account, phones, phone_id)
|
_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)
|
template_count, templates_ok = await _template_count(token, waba_id)
|
||||||
account.template_count = template_count
|
account.template_count = template_count
|
||||||
account.templates_status = "active" if templates_ok else "unavailable"
|
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:
|
if account.phones:
|
||||||
account.status = "connected"
|
account.status = "connected"
|
||||||
account.messaging_status = "active"
|
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)
|
_audit(db, user, "token_connected", "WhatsApp token saved for this account", connection_id=connection.id, waba_id=waba_id)
|
||||||
else:
|
else:
|
||||||
account.status = "incomplete"
|
account.status = "incomplete"
|
||||||
|
|
@ -763,6 +830,7 @@ async def connect_with_token(
|
||||||
access_token: str,
|
access_token: str,
|
||||||
waba_id: str,
|
waba_id: str,
|
||||||
phone_number_id: str | None = None,
|
phone_number_id: str | None = None,
|
||||||
|
app_secret: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Store a customer-supplied WhatsApp token. Does not use Embedded Signup."""
|
"""Store a customer-supplied WhatsApp token. Does not use Embedded Signup."""
|
||||||
token = _clean_token(access_token)
|
token = _clean_token(access_token)
|
||||||
|
|
@ -780,12 +848,19 @@ async def connect_with_token(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Phone number ID should be the number Meta shows for that phone.",
|
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(
|
connection = WhatsAppMetaConnection(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
status="pending",
|
status="pending",
|
||||||
granted_scopes="manual",
|
granted_scopes="manual",
|
||||||
access_token=token,
|
access_token=token,
|
||||||
|
meta_app_secret=secret or None,
|
||||||
)
|
)
|
||||||
db.add(connection)
|
db.add(connection)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
@ -802,7 +877,7 @@ async def connect_with_token(
|
||||||
db.refresh(account)
|
db.refresh(account)
|
||||||
if account.status == "connected":
|
if account.status == "connected":
|
||||||
overall = "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":
|
elif account.status == "incomplete":
|
||||||
overall = "incomplete"
|
overall = "incomplete"
|
||||||
message = account.last_error or "The token is saved. Add the phone number ID to finish."
|
message = account.last_error or "The token is saved. Add the phone number ID to finish."
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export function WhatsAppTokenPage() {
|
||||||
const [token, setToken] = useState('')
|
const [token, setToken] = useState('')
|
||||||
const [wabaId, setWabaId] = useState('')
|
const [wabaId, setWabaId] = useState('')
|
||||||
const [phoneNumberId, setPhoneNumberId] = useState('')
|
const [phoneNumberId, setPhoneNumberId] = useState('')
|
||||||
|
const [appSecret, setAppSecret] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [result, setResult] = useState<WhatsAppSignupResult | null>(null)
|
const [result, setResult] = useState<WhatsAppSignupResult | null>(null)
|
||||||
|
|
@ -21,9 +22,11 @@ export function WhatsAppTokenPage() {
|
||||||
access_token: token.trim(),
|
access_token: token.trim(),
|
||||||
waba_id: wabaId.trim(),
|
waba_id: wabaId.trim(),
|
||||||
phone_number_id: phoneNumberId.trim() || null,
|
phone_number_id: phoneNumberId.trim() || null,
|
||||||
|
app_secret: appSecret.trim() || null,
|
||||||
})
|
})
|
||||||
setResult(payload)
|
setResult(payload)
|
||||||
setToken('')
|
setToken('')
|
||||||
|
setAppSecret('')
|
||||||
if (payload.status === 'failed' || payload.status === 'expired') setError(payload.message)
|
if (payload.status === 'failed' || payload.status === 'expired') setError(payload.message)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : 'This token could not be saved')
|
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>
|
<span>Optional. Use it when the account has more than one number.</span>
|
||||||
<input value={phoneNumberId} onChange={(event) => setPhoneNumberId(event.target.value)} inputMode="numeric" />
|
<input value={phoneNumberId} onChange={(event) => setPhoneNumberId(event.target.value)} inputMode="numeric" />
|
||||||
</label>
|
</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}>
|
<button className="primary-button" type="submit" disabled={busy}>
|
||||||
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
||||||
Save token
|
Save token
|
||||||
|
|
|
||||||
|
|
@ -1263,7 +1263,7 @@ export const whatsappApi = {
|
||||||
events: () => jsonRequest<WhatsAppWebhookEvent[]>('/api/v1/whatsapp/events'),
|
events: () => jsonRequest<WhatsAppWebhookEvent[]>('/api/v1/whatsapp/events'),
|
||||||
signupConfig: () => jsonRequest<WhatsAppSignupConfig>('/api/v1/whatsapp/signup/config'),
|
signupConfig: () => jsonRequest<WhatsAppSignupConfig>('/api/v1/whatsapp/signup/config'),
|
||||||
accounts: () => jsonRequest<WhatsAppAccount[]>('/api/v1/whatsapp/accounts'),
|
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', {
|
jsonRequest<WhatsAppSignupResult>('/api/v1/whatsapp/connections/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue