Skip to content

Commit 9a26bdc

Browse files
Add interactive gRPC subscribe smoke-test script
1 parent 18ed787 commit 9a26bdc

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"lint": "biome check .",
2525
"lint:fix": "biome check --fix .",
2626
"test": "bun test",
27+
"script:subscribe": "bun run scripts/subscribe.ts",
2728
"prepublishOnly": "bun run build"
2829
},
2930
"dependencies": {

scripts/subscribe.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/**
2+
* Interactive smoke-test client for Spectrum Cloud mode.
3+
*
4+
* Prompts for a project ID and phone_number_id, mints a LightAuth JWT with
5+
* sub=<projectId>, then opens `MessageService.SubscribeEvents` and prints
6+
* every event that arrives. Send a real WhatsApp message to the Business
7+
* number and it should show up here within ~100ms of Meta's webhook.
8+
*
9+
* Usage:
10+
* bun run script:subscribe
11+
*
12+
* Optional env:
13+
* GRPC_ADDRESS Default: staging-whatsapp-business-grpc.spectrum.photon.codes:443
14+
* LIGHTAUTH_ENDPOINT Default: http://lightauth.internal
15+
* SPECTRUM_CLOUD_ENDPOINT Default: http://staging.spectrum-cloud.internal
16+
* META_API_VERSION Default: v25.0
17+
* META_API_BASE_URL Default: https://graph.facebook.com
18+
*/
19+
20+
import { stdin as input, stdout as output } from "node:process";
21+
import { createInterface, type Interface } from "node:readline/promises";
22+
import { ChannelCredentials } from "@grpc/grpc-js";
23+
import { createChannel, createClient, Metadata } from "nice-grpc";
24+
import { MessageServiceDefinition } from "../src/generated/photon/whatsapp/v1/message_service";
25+
26+
const INBOUND_SERVICE = "codes.photon.spectrum.whatsapp-business";
27+
28+
const grpcAddress =
29+
process.env.GRPC_ADDRESS ??
30+
"staging-whatsapp-business-grpc.spectrum.photon.codes:443";
31+
const lightauthEndpoint =
32+
process.env.LIGHTAUTH_ENDPOINT ?? "http://lightauth.internal";
33+
const spectrumCloudEndpoint =
34+
process.env.SPECTRUM_CLOUD_ENDPOINT ??
35+
"http://staging.spectrum-cloud.internal";
36+
const metaApiVersion = process.env.META_API_VERSION ?? "v25.0";
37+
const metaApiBaseUrl =
38+
process.env.META_API_BASE_URL ?? "https://graph.facebook.com";
39+
40+
function isLocalAddress(address: string): boolean {
41+
const host = address.split(":")[0] ?? "";
42+
return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0";
43+
}
44+
45+
const credentials = isLocalAddress(grpcAddress)
46+
? ChannelCredentials.createInsecure()
47+
: ChannelCredentials.createSsl();
48+
49+
async function promptRequired(rl: Interface, label: string): Promise<string> {
50+
const value = (await rl.question(`${label}: `)).trim();
51+
if (!value) {
52+
console.error(`${label} is required`);
53+
process.exit(1);
54+
}
55+
return value;
56+
}
57+
58+
async function issueJwt(projectId: string): Promise<string> {
59+
const res = await fetch(`${lightauthEndpoint}/tokens/issue`, {
60+
method: "POST",
61+
headers: { "Content-Type": "application/json" },
62+
body: JSON.stringify({
63+
serviceName: INBOUND_SERVICE,
64+
subject: projectId,
65+
expiresIn: 3600,
66+
}),
67+
});
68+
if (!res.ok) {
69+
throw new Error(
70+
`LightAuth token issue failed: ${res.status} ${await res.text()}`
71+
);
72+
}
73+
const { token } = (await res.json()) as { token: string };
74+
return token;
75+
}
76+
77+
async function verifyPhoneNumber(
78+
projectId: string,
79+
phoneNumberId: string
80+
): Promise<string> {
81+
const url = `${spectrumCloudEndpoint}/projects/${projectId}/whatsapp-business/verify`;
82+
const res = await fetch(url, {
83+
method: "POST",
84+
headers: { "Content-Type": "application/json" },
85+
body: JSON.stringify({ phoneNumberId }),
86+
});
87+
if (!res.ok) {
88+
throw new Error(
89+
`Spectrum Cloud verify failed: ${res.status} ${await res.text()}`
90+
);
91+
}
92+
const body = (await res.json()) as {
93+
succeed: true;
94+
data: { verified: true; metaBusinessToken: string } | { verified: false };
95+
};
96+
if (!body.data.verified) {
97+
throw new Error("phone_number_id is not registered under this project");
98+
}
99+
return body.data.metaBusinessToken;
100+
}
101+
102+
async function fetchDisplayNumber(
103+
phoneNumberId: string,
104+
metaBusinessToken: string
105+
): Promise<{ displayPhoneNumber?: string; verifiedName?: string }> {
106+
const url = `${metaApiBaseUrl}/${metaApiVersion}/${phoneNumberId}?fields=display_phone_number,verified_name`;
107+
const res = await fetch(url, {
108+
headers: { Authorization: `Bearer ${metaBusinessToken}` },
109+
});
110+
if (!res.ok) {
111+
throw new Error(
112+
`Meta Graph fetch failed: ${res.status} ${await res.text()}`
113+
);
114+
}
115+
const body = (await res.json()) as {
116+
display_phone_number?: string;
117+
verified_name?: string;
118+
};
119+
return {
120+
displayPhoneNumber: body.display_phone_number,
121+
verifiedName: body.verified_name,
122+
};
123+
}
124+
125+
const rl = createInterface({ input, output });
126+
const projectId = await promptRequired(rl, "project ID");
127+
const phoneNumberId = await promptRequired(rl, "phone_number_id");
128+
rl.close();
129+
130+
console.log(`[subscribe] minting JWT via ${lightauthEndpoint} ...`);
131+
const accessToken = await issueJwt(projectId);
132+
console.log("[subscribe] JWT issued");
133+
134+
try {
135+
console.log(
136+
`[subscribe] resolving display number via ${spectrumCloudEndpoint} ...`
137+
);
138+
const metaBusinessToken = await verifyPhoneNumber(projectId, phoneNumberId);
139+
const { displayPhoneNumber, verifiedName } = await fetchDisplayNumber(
140+
phoneNumberId,
141+
metaBusinessToken
142+
);
143+
console.log(
144+
`[subscribe] phone: ${displayPhoneNumber ?? "?"} (${verifiedName ?? "no verified name"})`
145+
);
146+
} catch (err) {
147+
console.warn(
148+
"[subscribe] failed to resolve display number (continuing anyway):",
149+
err instanceof Error ? err.message : err
150+
);
151+
}
152+
153+
const channel = createChannel(grpcAddress, credentials);
154+
const client = createClient(MessageServiceDefinition, channel);
155+
156+
const metadata = Metadata({
157+
access_token: accessToken,
158+
phone_number_id: phoneNumberId,
159+
});
160+
161+
const abort = new AbortController();
162+
process.on("SIGINT", () => {
163+
console.log("\n[subscribe] SIGINT received, closing stream");
164+
abort.abort();
165+
});
166+
167+
console.log(
168+
`[subscribe] connected to ${grpcAddress}, streaming events for phone_number_id=${phoneNumberId}`
169+
);
170+
console.log("[subscribe] send a WhatsApp message to the Business number...");
171+
172+
try {
173+
for await (const event of client.subscribeEvents(
174+
{},
175+
{ metadata, signal: abort.signal }
176+
)) {
177+
if (event.heartbeat) {
178+
console.log(`[subscribe] heartbeat ${new Date().toISOString()}`);
179+
continue;
180+
}
181+
182+
if (event.message) {
183+
console.log(
184+
`[subscribe] message cursor=${event.cursor?.value ?? "?"} from=${event.message.from} type=${event.message.type}`
185+
);
186+
console.log(JSON.stringify(event.message, null, 2));
187+
continue;
188+
}
189+
190+
if (event.status) {
191+
console.log(
192+
`[subscribe] status cursor=${event.cursor?.value ?? "?"} id=${event.status.id} status=${event.status.status}`
193+
);
194+
console.log(JSON.stringify(event.status, null, 2));
195+
continue;
196+
}
197+
198+
console.log("[subscribe] unknown event", event);
199+
}
200+
console.log("[subscribe] stream ended");
201+
} catch (err) {
202+
if (abort.signal.aborted) {
203+
process.exit(0);
204+
}
205+
console.error("[subscribe] error", err);
206+
process.exit(1);
207+
} finally {
208+
channel.close();
209+
}

0 commit comments

Comments
 (0)