-
Notifications
You must be signed in to change notification settings - Fork 5
✨ server: implement chat hook and worker #1252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/infra": minor | ||
| --- | ||
|
|
||
| 🧱 setup chat hook and worker | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/server": patch | ||
| --- | ||
|
|
||
| ✨ implement chat hook and worker |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/server": patch | ||
| --- | ||
|
|
||
| 🔧 build entrypoints for api and hooks |
| 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" |
| 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" |
| 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" }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding this environment mapping makes 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"], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -171,6 +171,9 @@ | |
| "overrides": { | ||
| "@hono/node-server": "catalog:", | ||
| "@opentelemetry/core@2": "^2.8.0", | ||
| "@sentry/core@10": "catalog:", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This override applies workspace-wide rather than only to the server: the generated lockfile now resolves 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", | ||
|
|
||
Large diffs are not rendered by default.
| 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)), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Redis is unavailable, this hook creates its queue connection through 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' serverRepository: 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\(' serverRepository: exactly/exa Length of output: 19009 Close clients on partial startup failure. If a later 📍 Affects 2 files
|
||
| ); | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Inline this map construction into the subsequent loop because 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a webhook batch contains multiple messages from one sender, the queued text includes every message but the job ID records only Useful? React with 👍 / 👎. |
||
| }) | ||
| .catch((error: unknown) => { | ||
| captureException(error, { extra: { sender }, tags: { job: "chat", queue: "chat" } }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Redis rejects an enqueue, this sends the complete Useful? React with 👍 / 👎. |
||
| throw error; | ||
|
Comment on lines
+82
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 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' serverRepository: 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")
PYRepository: 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.tsRepository: exactly/exa Length of output: 2730 Remove customer content from Sentry event extras.
📍 Affects 2 files
|
||
| }), | ||
| ), | ||
| ); | ||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the Secret Manager version exists but contains an empty value, 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(), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For actual WhatsApp Cloud API message callbacks, contacts identify the sender with Useful? React with 👍 / 👎. |
||
| type: v.string(), | ||
| text: v.optional(v.object({ body: v.string() })), | ||
|
Comment on lines
+102
to
+143
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 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.tsRepository: 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)
PYRepository: 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.tsRepository: exactly/exa Length of output: 5928 Use the WhatsApp Cloud API field names. The schema requires
📍 Affects 2 files
|
||
| }), | ||
| ), | ||
| ), | ||
| }), | ||
| }), | ||
| ), | ||
| }), | ||
| ), | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,11 +6,14 @@ const stack = require("@exactly/common/stack"); | |
|
|
||
| const development = stack === "localhost"; | ||
|
|
||
| init({ | ||
| /** @type {import("@sentry/node").NodeOptions} */ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This newly added type comment is JSDoc, which the repository-wide comment convention explicitly prohibits; only suppression annotations, 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, | ||
|
|
@@ -50,4 +53,7 @@ init({ | |
| return transaction; | ||
| }, | ||
| spotlight: development, | ||
| }); | ||
| }; | ||
| init(config); | ||
|
|
||
| module.exports = config; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Change
setupto the imperative verbset up; as written,setupis 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 👍 / 👎.