Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chubby-melons-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/infra": minor
---

🧱 setup chat hook and worker

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use an imperative verb in the changeset summary

Change setup to the imperative verb set up; as written, setup is a noun or adjective, while changeset summaries must be lowercase sentences in the imperative present tense.

AGENTS.md reference: AGENTS.md:L64-L64

Useful? React with 👍 / 👎.

5 changes: 5 additions & 0 deletions .changeset/lucky-parrots-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ implement chat hook and worker
5 changes: 5 additions & 0 deletions .changeset/nice-days-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

🔧 build entrypoints for api and hooks
2 changes: 2 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"encryptionsalt",
"erigon",
"esbuild",
"evals",
"facta",
"farcaster",
"favware",
Expand Down Expand Up @@ -111,6 +112,7 @@
"lucide",
"lusd",
"mainqueg",
"mastra",
"mateo-soso",
"mdpi",
"memester",
Expand Down
1 change: 1 addition & 0 deletions infra/Pulumi.base-sepolia.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
encryptionsalt: v1::v1:AAAAAAAAAAAAAAAb:rYjVO7/uF2+Qv1LhWq5c2S0ZR4rZaQ==
config:
gcp:project: exa-dev
exa:whatsappPhoneNumberId: "1284591438064923"
1 change: 1 addition & 0 deletions infra/Pulumi.production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ encryptionsalt: v1::v1:AAAAAAAAAAAAAAAb:rYjVO7/uF2+Qv1LhWq5c2S0ZR4rZaQ==
config:
gcp:project: eexxxaa
exa:domain: web.exactly.app
exa:whatsappPhoneNumberId: "1287864854409817"
1 change: 1 addition & 0 deletions infra/Pulumi.sandbox.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
encryptionsalt: v1::v1:AAAAAAAAAAAAAAAb:rYjVO7/uF2+Qv1LhWq5c2S0ZR4rZaQ==
config:
gcp:project: exa-dev
exa:whatsappPhoneNumberId: "1287864854409817"
10 changes: 10 additions & 0 deletions infra/utils/modules.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
export default define({
common: ["redis-url", "sentry-dsn"],
crema: ["redis-address", "redis-password", "redis-username"],
services: {
chat: {
env: { WHATSAPP_PHONE_NUMBER_ID: "whatsappPhoneNumberId" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Configure the WhatsApp ID for every deployed stack

Adding this environment mapping makes infra/index.ts call config.require("whatsappPhoneNumberId") while constructing both chat modules for every stack. Only the sandbox and base-sepolia stack files define that key; the checked infra/Pulumi.base.yaml and infra/Pulumi.production.yaml files do not, so their deployment workflows now fail during pulumi up before any resources can be updated. Add the configuration to those stacks or omit the chat modules where they are not intended to run.

Useful? React with 👍 / 👎.

secrets: ["whatsapp-app-secret", "whatsapp-verify-token"],
},
},
workers: {
chat: {
env: { WHATSAPP_PHONE_NUMBER_ID: "whatsappPhoneNumberId" },
secrets: ["anthropic-api-key", "whatsapp-access-token"],
},
hook: { secrets: ["panda-api-key", "postgres-url"], shared: ["panda-api-url"] },
refund: {
secrets: ["panda-api-key", "onesignal-api-key", "postgres-url", "sardine-api-key", "segment-write-key"],
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@
"overrides": {
"@hono/node-server": "catalog:",
"@opentelemetry/core@2": "^2.8.0",
"@sentry/core@10": "catalog:",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the mobile Sentry SDK on one version

This override applies workspace-wide rather than only to the server: the generated lockfile now resolves @sentry/browser@10.46.0, @sentry/react@10.46.0, and the components used by @sentry/react-native@8.6.0 against @sentry/core@10.70.0, replacing their exact 10.46.0 core dependency. Every mobile build consequently combines internals from different Sentry releases, so initialization and integrations can call APIs that do not match their packaged helpers. Scope the override to the server dependency chain or upgrade the complete mobile Sentry family together.

Useful? React with 👍 / 👎.

"@sentry/node@10": "catalog:",
"@sentry/profiling-node@10": "catalog:",
"@valora/viem-account-hsm-gcp@1.2.19>@google-cloud/kms": "^6.0.0",
"@wagmi/core": "catalog:",
"abitype>zod": "^4.0.0",
Expand Down
1,811 changes: 1,171 additions & 640 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ auditConfig:
- GHSA-5p4m-2wfm-xmqj # cspell:ignore xmqj
- GHSA-6g55-p6wh-862q
- GHSA-848j-6mx2-7j84
- GHSA-866g-f22w-33x8
- GHSA-fxqj-rqcc-2cmp # cspell:ignore fxqj rqcc
- GHSA-h67p-54hq-rp68
- GHSA-5p2g-fcmc-qvqq # cspell:ignore fcmc qvqq
Expand All @@ -29,6 +30,9 @@ catalog:
"@nx/eslint": *nx
"@nx/eslint-plugin": *nx
"@nx/js": *nx
"@sentry/core": &sentry ^10.71.0
"@sentry/node": *sentry
"@sentry/profiling-node": *sentry
tamagui: &tamagui ^1.144.4
"@tamagui/animations-moti": *tamagui
"@tamagui/babel-plugin": *tamagui
Expand Down
26 changes: 26 additions & 0 deletions server/hooks/bin/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { env } from "node:process";
import { nonEmpty, parse, pipe, string } from "valibot";

import supervise, { own } from "../../supervise";
import secret from "../../utils/secret";
import { connect } from "../../workers/worker";
import createChatHook from "../chat";

const secrets = new SecretManagerServiceClient();

supervise(
"chat",
Promise.all([
secret("redis-url", secrets).then((redisUrl) => connect(redisUrl)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit Redis retries for the webhook producer

When Redis is unavailable, this hook creates its queue connection through connect(), which sets maxRetriesPerRequest: null in server/workers/worker.ts. That setting is appropriate for BullMQ workers but causes the producer's Queue.add command to remain pending indefinitely; because the POST handler awaits every enqueue, webhook requests hang until the upstream or Cloud Run timeout instead of promptly returning a retryable failure, tying up the sole service instance. Use a producer-specific Redis connection with a finite retry limit.

Useful? React with 👍 / 👎.

Promise.resolve(parse(pipe(string("whatsapp id"), nonEmpty("whatsapp id")), env.WHATSAPP_PHONE_NUMBER_ID)),
secret("chat-whatsapp-app-secret", secrets),
secret("chat-whatsapp-verify-token", secrets),
]).then(([bullmq, whatsappFrom, whatsappSecret, whatsappVerifyToken]) =>
own(
createChatHook({ bullmq, whatsappFrom, whatsappSecret, whatsappVerifyToken }),
() => bullmq.quit(),
() => secrets.close(),
),
),
Comment on lines +14 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

printf '%s\n' '--- repository conventions ---'
find /tmp/coderabbit-repo-knowledge/exactly-exa-f26ca19b -mindepth 2 -maxdepth 2 -type f -name '*.md' -print -exec sh -c 'head -80 "$1"' _ {} \;
printf '%s\n' '--- target files ---'
sed -n '1,120p' server/hooks/bin/chat.ts
sed -n '1,120p' server/workers/chat/bin.ts
printf '%s\n' '--- directly bound symbols ---'
rg -n --glob '*.{ts,tsx,js,jsx}' 'function own|const own|export .*own|function secret|const secret|export .*secret|function connect|const connect|export .*connect|function parse|export .*parse' server

Repository: exactly/exa

Length of output: 49191


🏁 Script executed:

printf '%s\n' '--- supervision and ownership contracts ---'
sed -n '1,180p' server/supervise.ts
sed -n '1,140p' server/utils/secret.ts
sed -n '1,130p' server/workers/worker.ts
printf '%s\n' '--- analogous startup flows ---'
sed -n '1,100p' server/workers/hook/bin.ts
sed -n '1,100p' server/workers/refund/bin.ts
rg -n --glob 'server/**/*.ts' 'Promise\.all\(|\.finally\(|secrets\.close\(|bullmq\.quit\(' server

Repository: exactly/exa

Length of output: 19009


Close clients on partial startup failure.

If a later secret() call rejects after connect() resolves, own() never registers bullmq.quit() or secrets.close(). Add rejection cleanup in both startup files. Add a test that asserts each client closes once.

📍 Affects 2 files
  • server/hooks/bin/chat.ts#L14-L25 (this comment)
  • server/workers/chat/bin.ts#L16-L27

);
152 changes: 152 additions & 0 deletions server/hooks/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { vValidator } from "@hono/valibot-validator";
import { captureException } from "@sentry/node";
import { Hono } from "hono";
import { validator } from "hono/validator";
import { createHmac, timingSafeEqual } from "node:crypto";
import * as v from "valibot";

import { own } from "../supervise";
import validatorHook from "../utils/validatorHook";
import createQueue from "../workers/chat/queue";

import type { Redis } from "ioredis";

export default function chat({
bullmq,
whatsappFrom,
whatsappSecret,
whatsappVerifyToken,
}: {
bullmq: Redis;
whatsappFrom: string;
whatsappSecret?: string;
whatsappVerifyToken?: string;
}) {
bullmq.on("error", (error: unknown) => captureException(error));
const queue = createQueue(bullmq);
const app = new Hono()
.get(
"/",
vValidator(
"query",
v.object({
"hub.mode": v.literal("subscribe"),
"hub.verify_token": v.string(),
"hub.challenge": v.string(),
}),
validatorHook({ code: "bad verification" }),
),
(c) =>
c.req.valid("query")["hub.verify_token"] === whatsappVerifyToken
? c.text(c.req.valid("query")["hub.challenge"])
: c.json({ code: "invalid verify token" }, 403),
)
.post(
"/",
validator("header", async ({ "x-hub-signature-256": signature }, c) => {
if (!verify(await c.req.text(), signature, whatsappSecret)) return c.json({ code: "invalid signature" }, 401);
}),
vValidator("json", event, validatorHook({ code: "bad chat" })),
async (c) => {
const delivered = parse(c.req.valid("json"));
const foreign = [...new Set(delivered.map(({ phoneNumberId }) => phoneNumberId))].filter(
(phoneNumberId) => phoneNumberId !== whatsappFrom,
);
if (foreign.length > 0) {
captureException(new Error("chat delivered to another business number"), {
level: "error",
extra: { expected: whatsappFrom, foreign },
});
}
const messages = new Map(
delivered
.filter(({ phoneNumberId }) => phoneNumberId === whatsappFrom)
.map((message) => [message.id, message] as const),
);
Comment on lines +61 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inline the single-use messages map

Inline this map construction into the subsequent loop because messages is consumed only once by messages.values(); retaining the binding adds the exact single-use extraction that the repository convention prohibits, including when the extraction is intended only for clarity.

AGENTS.md reference: AGENTS.md:L113-L119

Useful? React with 👍 / 👎.

const threads = new Map<string, [(typeof delivered)[number], ...(typeof delivered)[number][]]>();
for (const message of messages.values()) {
const key = `${message.phoneNumberId}/${message.from}`;
const thread = threads.get(key);
if (thread) thread.push(message);
else threads.set(key, [message]);
}
await Promise.all(
[...threads.values()].map(([sender, ...tail]) =>
queue
.enqueue({
id: sender.id,
contact: sender.contact,
from: sender.from,
text: [sender, ...tail].map(({ text }) => text).join("\n"),
Comment on lines +77 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track every message ID in a merged thread

When a webhook batch contains multiple messages from one sender, the queued text includes every message but the job ID records only sender.id. If a retry delivers a tail message separately while the merged job is still retained, that tail ID is unknown and produces a duplicate reply; conversely, a batch whose first message already has an active job silently discards any new tail messages when BullMQ returns the existing job. Enqueue messages independently or reserve every included message ID before acknowledging the batch.

Useful? React with 👍 / 👎.

})
.catch((error: unknown) => {
captureException(error, { extra: { sender }, tags: { job: "chat", queue: "chat" } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact user content from queue errors

When Redis rejects an enqueue, this sends the complete sender object to Sentry, including the customer's WhatsApp identifier, profile name, and free-form message text. During a queue outage every attempted support message can therefore be copied into the error-monitoring system and retained outside the chat path; users may include credentials or financial details in those messages. Report only non-content identifiers needed to diagnose the queue failure.

Useful? React with 👍 / 👎.

throw error;
Comment on lines +82 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

How does the Sentry JavaScript SDK include captureException extra data in captured events?

💡 Result:

The Sentry JavaScript SDK provides two primary ways to include extra data when calling captureException: passing it directly as an argument or utilizing scope management [1][2][3]. Direct Inclusion via captureException You can include extra data directly in a single captureException call by passing a capture context object as the second argument [1][4][5]. This object accepts an extra property, which should be a key-value record of additional data [1][4]. Example: Sentry.captureException(error, { extra: { userId: 123, environment: 'production' } }); This data will be merged with existing data in the current scope [5][3]. Scope Management Alternatively, you can manage extra data using the SDK's scope-based methods. This is useful when you want to persist data across multiple events or limit the scope of the data to a specific block of code [2][3]. 1. Global/Current Scope Methods: You can use setExtra or setExtras to add data that will be included in all future events captured on that scope [1][6][7]. Sentry.setExtra('key', 'value'); Sentry.setExtras({ key1: 'value1', key2: 'value2' }); 2. Using withScope: To isolate extra data to a specific event or block of code, you can use withScope [2][8]. This creates a temporary scope that is automatically cleaned up after the callback executes [3]. Sentry.withScope((scope) => { scope.setExtra('key', 'value'); Sentry.captureException(error); }); While the SDK continues to support extra data for backward compatibility, Sentry documentation notes that unstructured "additional data" via setExtra is largely deprecated in favor of more structured Contexts (using setContext), which provide better organization in the Sentry UI [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- production hook ---'
sed -n '1,180p' server/hooks/chat.ts
printf '%s\n' '--- affected test sections ---'
sed -n '200,285p' server/test/hooks/chat.test.ts
printf '%s\n' '--- captureException definitions and usages ---'
rg -n --glob '*.{ts,tsx,js,jsx}' 'captureException|enqueue|sender' server

Repository: exactly/exa

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- Sentry configuration ---'
rg -n -C 4 --glob '*.{ts,tsx,js,jsx}' 'Sentry\.init|beforeSend|beforeBreadcrumb|sendDefaultPii|normalizeDepth|extra' server . --glob '!node_modules/**' --glob '!dist/**' | head -n 240
printf '%s\n' '--- additional chat test context ---'
sed -n '1,90p' server/test/hooks/chat.test.ts
sed -n '240,275p' server/test/hooks/chat.test.ts
printf '%s\n' '--- focused structural probe ---'
python3 - <<'PY'
from pathlib import Path

hook = Path("server/hooks/chat.ts").read_text()
test = Path("server/test/hooks/chat.test.ts").read_text()

required_sender_fields = ["id", "from", "text", "contact", "phoneNumberId"]
missing = [field for field in required_sender_fields if field not in hook]
assert not missing, f"missing sender fields in production source: {missing}"
assert 'extra: { sender }' in hook, "production source does not pass sender as Sentry extra"
assert 'extra: { sender: { id: "whatsapp-1", from: "US.12345678", text: "Hi!", contact: "Jhon", phoneNumberId: "321" } }' in test, "test does not assert the complete sender payload"

print("production sender fields:", ", ".join(required_sender_fields))
print("production Sentry extra: complete sender object")
print("test expectation: complete sender object")
PY

Repository: exactly/exa

Length of output: 19406


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- instrumentation candidates ---'
fd -HI -t f '(instrument|sentry|config)' . | head -n 120
printf '%s\n' '--- Sentry initialization ---'
rg -n -C 8 --glob '*.{ts,tsx,js,jsx}' 'init\s*\(|beforeSend|beforeSendTransaction|sendDefaultPii|Sentry' . --glob '!node_modules/**' --glob '!dist/**' | head -n 260
printf '%s\n' '--- relevant diff summary ---'
git diff --stat -- server/hooks/chat.ts server/test/hooks/chat.test.ts
git diff --unified=3 -- server/hooks/chat.ts server/test/hooks/chat.test.ts | sed -n '1,220p'

Repository: exactly/exa

Length of output: 17919


🏁 Script executed:

#!/bin/bash
set -eu
cat -n server/instrument.cjs
printf '%s\n' '--- Sentry mock ---'
cat -n server/test/mocks/sentry.ts

Repository: exactly/exa

Length of output: 2730


Remove customer content from Sentry event extras. sender includes contact, the WhatsApp user ID, and message text. beforeSend does not redact these fields before Sentry receives the event.

  • server/hooks/chat.ts#L82-L84: pass only allowlisted diagnostics, such as id and phoneNumberId.
  • server/test/hooks/chat.test.ts#L228-L237: assert the redacted payload.
📍 Affects 2 files
  • server/hooks/chat.ts#L82-L84 (this comment)
  • server/test/hooks/chat.test.ts#L228-L237

}),
),
);
return c.json({ code: "ok" });
},
);
return own({ app, ready: Promise.resolve() }, () => queue.close());
}

function verify(body: string, signature?: string, secret?: string) {
if (!secret) return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject an empty production app secret

If the Secret Manager version exists but contains an empty value, secret() returns "", so this branch accepts every request even when the signature header is missing. Because the chat Cloud Run service is public, that misconfiguration lets anyone submit schema-valid payloads and enqueue replies to attacker-selected numbers, consuming model/API quota and potentially sending spam; reserve unsigned mode for undefined or fail startup when the configured secret is empty.

Useful? React with 👍 / 👎.

if (!signature) return false;
const expected = Buffer.from(`sha256=${createHmac("sha256", secret).update(body).digest("hex")}`);
const received = Buffer.from(signature);
return received.length === expected.length && timingSafeEqual(received, expected);
}

function parse({ entry }: v.InferOutput<typeof event>) {
return entry.flatMap(({ changes }) =>
changes.flatMap(({ value: { contacts, messages, metadata } }) =>
(messages ?? []).flatMap((message) =>
message.text
? [
{
id: message.id,
from: message.from_user_id,
text: message.text.body,
contact: contacts?.find(({ user_id }) => user_id === message.from_user_id)?.profile?.name,
phoneNumberId: metadata.phone_number_id,
},
]
: [],
),
),
);
}

const event = v.object({
entry: v.array(
v.object({
changes: v.array(
v.object({
value: v.object({
metadata: v.object({ phone_number_id: v.string() }),
contacts: v.optional(
v.array(
v.object({
user_id: v.string(),
profile: v.optional(v.object({ name: v.optional(v.string()) })),
}),
),
),
messages: v.optional(
v.array(
v.object({
id: v.string(),
from_user_id: v.string(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate WhatsApp's actual sender fields

For actual WhatsApp Cloud API message callbacks, contacts identify the sender with wa_id and messages use from, but this schema requires user_id and from_user_id. Because the Valibot middleware runs before parse, every normal inbound text webhook is rejected with 400 bad chat; the tests currently mirror the incorrect field names instead of the external contract.

Useful? React with 👍 / 👎.

type: v.string(),
text: v.optional(v.object({ body: v.string() })),
Comment on lines +102 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What fields identify the sender and contact in Meta WhatsApp Cloud API inbound message webhooks?

💡 Result:

In Meta WhatsApp Cloud API inbound message webhooks, the sender and contact information are identified primarily through the following fields located within the entries object of the JSON payload [1][2]: Sender Identification The sender of the message is identified within the messages array object by the from field [1][3]. This field contains the WhatsApp ID (wa_id) of the user who sent the message, which is typically their phone number [1][2]. Contact Identification Additional contact details, such as the user's profile name, are provided in the contacts array, which is present at the same level as the messages array within the webhook value object [1][4][2]. Each entry in the contacts array typically includes: wa_id: The WhatsApp ID of the user, which corresponds to the from field in the message object [1][2]. profile: An object containing user-specific information, such as the name field, which provides the sender's WhatsApp profile name [1][2]. These fields allow you to link the incoming message (via the from field) to the detailed contact information (via the wa_id and profile fields) provided in the same webhook notification [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- production hook ---'
sed -n '70,155p' server/hooks/chat.ts
printf '%s\n' '--- test fixture and assertions ---'
sed -n '210,285p' server/test/hooks/chat.test.ts
printf '%s\n' '--- field references ---'
rg -n '"(user_id|wa_id|from_user_id|from)"|user_id|wa_id|from_user_id' server/hooks/chat.ts server/test/hooks/chat.test.ts

Repository: exactly/exa

Length of output: 6092


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path

hook = Path("server/hooks/chat.ts").read_text()
fixture = Path("server/test/hooks/chat.test.ts").read_text()

checks = {
    "production schema uses user_id": "user_id: v.string()" in hook,
    "production schema uses from_user_id": "from_user_id: v.string()" in hook,
    "production schema uses wa_id": "wa_id" in hook,
    "production schema uses from field": "from: v.string()" in hook,
    "test fixture uses user_id": "user_id:" in fixture,
    "test fixture uses from_user_id": "from_user_id:" in fixture,
    "test fixture uses wa_id": "wa_id:" in fixture,
    "test fixture uses from field": "from:" in fixture,
}

for name, value in checks.items():
    print(f"{name}: {value}")

# Model the relevant webhook transformation without importing repository code.
payload = {
    "entry": [{
        "changes": [{
            "value": {
                "metadata": {"phone_number_id": "321"},
                "contacts": [{"wa_id": "US.12345678", "profile": {"name": "Jhon"}}],
                "messages": [{
                    "id": "whatsapp-1",
                    "from": "US.12345678",
                    "type": "text",
                    "text": {"body": "Hi!"},
                }],
            },
        }],
    }],
}

value = payload["entry"][0]["changes"][0]["value"]
message = value["messages"][0]
schema_expected = {
    "contact identifier": value["contacts"][0]["wa_id"],
    "sender identifier": message["from"],
}
schema_current = {
    "contact identifier": value["contacts"][0].get("user_id"),
    "sender identifier": message.get("from_user_id"),
}
print("real webhook fields:", schema_expected)
print("current schema lookups:", schema_current)
PY

Repository: exactly/exa

Length of output: 615


🏁 Script executed:

#!/bin/bash
set -e
sed -n '1,75p' server/hooks/chat.ts
printf '%s\n' '--- all webhook parsing and validation calls ---'
rg -n -C 3 'parse\(|event|safeParse|parse\(' server/hooks/chat.ts server/test/hooks/chat.test.ts

Repository: exactly/exa

Length of output: 5928


Use the WhatsApp Cloud API field names. The schema requires contacts[].user_id and messages[].from_user_id, but inbound WhatsApp webhooks provide contacts[].wa_id and messages[].from. Text messages therefore fail validation before queue.enqueue.

  • server/hooks/chat.ts:102-143: validate and use wa_id and from.
  • server/test/hooks/chat.test.ts:248-269: update the fixture to use wa_id and from.
📍 Affects 2 files
  • server/hooks/chat.ts#L102-L143 (this comment)
  • server/test/hooks/chat.test.ts#L248-L269

}),
),
),
}),
}),
),
}),
),
});
10 changes: 8 additions & 2 deletions server/instrument.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ const stack = require("@exactly/common/stack");

const development = stack === "localhost";

init({
/** @type {import("@sentry/node").NodeOptions} */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the prohibited JSDoc annotation

This newly added type comment is JSDoc, which the repository-wide comment convention explicitly prohibits; only suppression annotations, cspell:ignore, and TODO-style markers are permitted. Restructure the configuration so its type is inferred or checked without adding JSDoc to this entrypoint.

AGENTS.md reference: AGENTS.md:L94-L98

Useful? React with 👍 / 👎.

const config = {
dsn: env.SENTRY_DSN,
release: require("./generated/release"),
environment: stack,
tracesSampleRate: 1,
strictTraceContinuation: true,
streamGenAiSpans: false,
profilesSampleRate: 1,
attachStacktrace: true,
maxValueLength: 8192,
Expand Down Expand Up @@ -50,4 +53,7 @@ init({
return transaction;
},
spotlight: development,
});
};
init(config);

module.exports = config;
17 changes: 13 additions & 4 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"generate:broadcasts": "[ \"$CHAIN_ID\" != 31337 ] || NODE_ENV=development tsx -e 'require(\"./test/anvil\").default({ provide: () => undefined }).then((teardown) => teardown())'",
"db:push": "drizzle-kit push",
"e2e": "tsx script/e2e.ts",
"eval:chat": "tsx test/workers/chat.eval.ts",
"test": "nx test server",
"test:ts": "tsc --pretty ${GITHUB_ACTIONS:+false}",
"test:vi": "vitest run",
Expand All @@ -27,6 +28,8 @@
"./api/*": {
"types": "./api/*.ts"
},
"./api/bin": "./dist/api/bin.cjs",
"./hooks/*/bin": "./dist/hooks/bin/*.cjs",
"./workers/*/bin": "./dist/workers/*/bin.cjs"
},
"files": [
Expand All @@ -46,12 +49,17 @@
"@hono/node-server": "catalog:",
"@hono/sentry": "^1.2.2",
"@hono/valibot-validator": "^0.5.3",
"@mastra/core": "^1.62.0",
"@mastra/memory": "^1.28.0",
"@mastra/observability": "^1.17.2",
"@mastra/redis": "^1.4.2",
"@mastra/sentry": "^1.2.12",
"@onesignal/node-onesignal": "^5.4.0",
"@react-pdf/renderer": "^4.3.2",
"@segment/analytics-node": "^3.0.0",
"@sentry/core": "^10.46.0",
"@sentry/node": "^10.46.0",
"@sentry/profiling-node": "^10.46.0",
"@sentry/core": "catalog:",
"@sentry/node": "catalog:",
"@sentry/profiling-node": "catalog:",
"@simplewebauthn/server": "^13.3.0",
"@types/debug": "^4.1.13",
"@valibot/to-json-schema": "^1.6.0",
Expand Down Expand Up @@ -81,7 +89,8 @@
"@exactly/common": "workspace:^0.0.18",
"@exactly/plugin": "workspace:^1.1.0",
"@exactly/substreams": "workspace:^0.1.0",
"@sentry/cli": "^3.3.4",
"@mastra/evals": "^1.9.0",
"@sentry/cli": "^3.6.2",
"@types/ioredis-mock": "^8.2.7",
"@types/node": "^25.5.0",
"@types/pg": "^8.20.0",
Expand Down
Loading