feat: add whatsapp support? (#3356)
Maintain Release Merge PR / update-release-pr (push) Waiting to run
Notify HeyPuter / notify (push) Waiting to run
release-please / release-please (push) Waiting to run

This commit is contained in:
Daniel Salazar
2026-07-07 19:13:18 -04:00
committed by GitHub
parent 8a2264546d
commit d09aa11f2e
7 changed files with 84 additions and 16 deletions
+1
View File
@@ -0,0 +1 @@
FOLLOW ./AGENTS.md
@@ -86,6 +86,21 @@ describe('PreludeClient', () => {
});
});
it('returns the delivery channel sequence Prelude reports', async () => {
fetchMock.mockResolvedValue(
okJson({
id: 'vrf_1',
status: 'success',
channels: ['whatsapp', 'sms'],
}),
);
const client = makeClient('sk_test');
const res = await client.createVerification('+14155550123');
expect(res.channels).toEqual(['whatsapp', 'sms']);
});
it('forwards device_id and user_agent signals when supplied', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
+12 -13
View File
@@ -42,6 +42,12 @@ export type PreludeChannel =
| 'viber'
| 'zalo'
| 'telegram';
/**
* Channels Prelude reports in a verification's `channels` array — the ordered
* delivery sequence, first entry first. Superset of `PreludeChannel`: 'silent'
* and 'voice' can appear as delivery methods but can't be preferred.
*/
export type PreludeDeliveryChannel = PreludeChannel | 'silent' | 'voice';
/**
* Default per-SMS cost ceiling (EUR). Countries whose Prelude SMS rate exceeds
* this — or that have no SMS channel — are not offered phone verification. The
@@ -117,18 +123,6 @@ export class PreludeClient extends PuterClient {
return price.sms <= this.maxSmsCostEur;
}
/**
* Create (or retry) a verification: Prelude sends an OTP to `target`.
* @param target E.164 phone number, e.g. "+14155550123".
* @param signals Optional anti-fraud signals. The more we forward, the
* better Prelude's fraud model scores the attempt (per their docs IP +
* device + platform are the highest-value). We pass what the backend
* already has: the request `ip`, the client `device_id` (ThumbmarkJS
* hash), and the `user_agent` (Prelude infers platform/model/OS from it).
* `dispatch_id` carries the richer browser signals gathered by Prelude's
* frontend JS Signals SDK; it's a top-level field in the request, not part
* of the `signals` object, so it's pulled out below.
*/
async createVerification(
target: string,
signals: {
@@ -137,7 +131,11 @@ export class PreludeClient extends PuterClient {
user_agent?: string;
dispatch_id?: string;
} = {},
): Promise<{ id?: string; status: PreludeCreateStatus }> {
): Promise<{
id?: string;
status: PreludeCreateStatus;
channels?: PreludeDeliveryChannel[];
}> {
// Match the 6-box code UI (UIWindowPhoneVerificationRequired). Without
// code_size Prelude uses the dashboard default (4). preferred_channel
// prioritizes RCS (cheaper); Prelude falls back to SMS when unavailable.
@@ -171,6 +169,7 @@ export class PreludeClient extends PuterClient {
return this.#post('/verification', body) as Promise<{
id?: string;
status: PreludeCreateStatus;
channels?: PreludeDeliveryChannel[];
}>;
}
@@ -2047,6 +2047,38 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
user_agent: undefined,
});
});
it('returns the delivery channel Prelude picked so the client can point at the right app', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({
status: 'success',
channels: ['whatsapp', 'sms'],
})),
}),
async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toMatchObject({ channel: 'whatsapp' });
},
);
});
it('omits `channel` when Prelude reports no delivery sequence', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).not.toHaveProperty('channel');
});
});
});
describe('AuthController phone verification — staging & reuse', () => {
@@ -1358,6 +1358,10 @@ export class AuthController extends PuterController {
typeof req.headers['user-agent'] === 'string'
? req.headers['user-agent']
: undefined;
// First entry of Prelude's delivery sequence — where the code actually
// went. Returned to the client so it can point the user at the right
// app (e.g. "check WhatsApp" instead of "check your texts").
let deliveryChannel: string | undefined;
try {
const result = await this.clients.prelude.createVerification(
parsed.e164,
@@ -1368,6 +1372,7 @@ export class AuthController extends PuterController {
dispatch_id: dispatchId,
},
);
deliveryChannel = result.channels?.[0];
// Prelude rejected the attempt as abusive — surface as rate-limit.
if (
result.status === 'blocked' ||
@@ -1422,7 +1427,10 @@ export class AuthController extends PuterController {
} catch {
// ignore — best-effort velocity signal
}
res.json(fallbackAvailable ? { card_fallback_available: true } : {});
res.json({
...fallbackFields,
...(deliveryChannel ? { channel: deliveryChannel } : {}),
});
}
@Post('/confirm-phone', {
@@ -110,6 +110,7 @@ function UIWindowPhoneVerificationRequired(options) {
no_matches: i18n('phone_no_matches'),
select_country: i18n('phone_select_country'),
code_sent_to: i18n('phone_code_sent_to'),
code_sent_whatsapp: i18n('phone_code_sent_whatsapp'),
suggested: i18n('phone_suggested'),
all_countries: i18n('phone_all_countries'),
};
@@ -382,7 +383,9 @@ function UIWindowPhoneVerificationRequired(options) {
// -- Step 2: 6-digit code (hidden until a code is sent) --
h += '<form class="phone-step phone-step-2" style="display:none;">';
h += `<p class="phone-subtitle">${T.code_sent_to} <strong style="font-weight: 600; color:#3e5362;" class="phone-target"></strong></p>`;
// The prefix is swapped per send: WhatsApp wording when the backend
// reports the code went out over WhatsApp, SMS wording otherwise.
h += `<p class="phone-subtitle"><span class="phone-code-sent-msg">${T.code_sent_to}</span> <strong style="font-weight: 600; color:#3e5362;" class="phone-target"></strong></p>`;
h += '<div class="error" role="alert" aria-live="assertive"></div>';
h += ` <fieldset name="number-code" style="border: none; padding:0;" data-number-code-form>
<input class="digit-input" type="number" min='0' max='9' inputmode="numeric" autocomplete="one-time-code" name='number-code-0' data-number-code-input='0' required />
@@ -743,9 +746,18 @@ function UIWindowPhoneVerificationRequired(options) {
contentType: 'application/json',
headers: { Authorization: `Bearer ${window.auth_token}` },
statusCode: { 401: (xhr) => window.handle401(xhr) },
success: function () {
success: function (res) {
// Advance to the code-entry step with a clean slate.
$(el_window).find('.phone-target').text(phone);
// `channel` is where Prelude actually delivered the code;
// point the user at WhatsApp when it wasn't a plain text.
$(el_window)
.find('.phone-code-sent-msg')
.text(
res?.channel === 'whatsapp'
? T.code_sent_whatsapp
: T.code_sent_to,
);
numberCodeInputs.forEach((i) => {
i.value = '';
i.disabled = false;
+1
View File
@@ -250,6 +250,7 @@ const en = {
phone_all_countries: 'All countries',
phone_select_country: 'Select country',
phone_code_sent_to: 'Enter the 6-digit code sent to',
phone_code_sent_whatsapp: 'Enter the 6-digit code sent via WhatsApp to',
pick_name_for_website: 'Pick a name for your website:',
pick_name_for_worker: 'Pick a name for your worker:',
picture: 'Picture',