Skip to content

Commit bd1d570

Browse files
mainquegcruzdanilo
andcommitted
✨ server: implement chat hook and worker
co-authored-by: danilo neves cruz <cruzdanilo@gmail.com>
1 parent ab2e5f1 commit bd1d570

19 files changed

Lines changed: 1414 additions & 2 deletions

File tree

.changeset/chubby-melons-look.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@exactly/infra": minor
3+
---
4+
5+
🧱 setup chat hook and worker

.changeset/lucky-parrots-chat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@exactly/server": patch
3+
---
4+
5+
✨ implement chat hook and worker

infra/utils/modules.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
export default define({
22
common: ["redis-url", "sentry-dsn"],
33
crema: ["redis-address", "redis-password", "redis-username"],
4+
services: {
5+
chat: { secrets: ["whatsapp-app-secret", "whatsapp-verify-token"], shared: ["whatsapp-phone-number-id"] },
6+
},
47
workers: {
8+
chat: { secrets: ["anthropic-api-key", "whatsapp-access-token"], shared: ["whatsapp-phone-number-id"] },
59
hook: { secrets: ["panda-api-key", "postgres-url"], shared: ["panda-api-url"] },
610
refund: {
711
secrets: ["panda-api-key", "onesignal-api-key", "postgres-url", "sardine-api-key", "segment-write-key"],

server/hooks/bin/chat.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
2+
3+
import supervise, { own } from "../../supervise";
4+
import secret from "../../utils/secret";
5+
import { connect } from "../../workers/worker";
6+
import createChatHook from "../chat";
7+
8+
const secrets = new SecretManagerServiceClient();
9+
10+
supervise(
11+
"chat",
12+
Promise.all([
13+
secret("redis-url", secrets).then((redisUrl) => connect(redisUrl)),
14+
secret("whatsapp-phone-number-id", secrets),
15+
secret("chat-whatsapp-app-secret", secrets),
16+
secret("chat-whatsapp-verify-token", secrets),
17+
]).then(([bullmq, whatsappFrom, whatsappSecret, whatsappVerifyToken]) =>
18+
own(
19+
createChatHook({ bullmq, whatsappFrom, whatsappSecret, whatsappVerifyToken }),
20+
() => bullmq.quit(),
21+
() => secrets.close(),
22+
),
23+
),
24+
);

server/hooks/chat.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { vValidator } from "@hono/valibot-validator";
2+
import { captureException } from "@sentry/node";
3+
import { Hono } from "hono";
4+
import { validator } from "hono/validator";
5+
import { createHmac, timingSafeEqual } from "node:crypto";
6+
import * as v from "valibot";
7+
8+
import { own } from "../supervise";
9+
import validatorHook from "../utils/validatorHook";
10+
import createQueue from "../workers/chat/queue";
11+
12+
import type { Redis } from "ioredis";
13+
14+
export default function chat({
15+
bullmq,
16+
whatsappFrom,
17+
whatsappSecret,
18+
whatsappVerifyToken,
19+
}: {
20+
bullmq: Redis;
21+
whatsappFrom: string;
22+
whatsappSecret?: string;
23+
whatsappVerifyToken?: string;
24+
}) {
25+
bullmq.on("error", (error: unknown) => captureException(error));
26+
const queue = createQueue(bullmq);
27+
const app = new Hono()
28+
.get(
29+
"/",
30+
vValidator(
31+
"query",
32+
v.object({
33+
"hub.mode": v.literal("subscribe"),
34+
"hub.verify_token": v.string(),
35+
"hub.challenge": v.string(),
36+
}),
37+
validatorHook({ code: "bad verification" }),
38+
),
39+
(c) =>
40+
c.req.valid("query")["hub.verify_token"] === whatsappVerifyToken
41+
? c.text(c.req.valid("query")["hub.challenge"])
42+
: c.json({ code: "invalid verify token" }, 403),
43+
)
44+
.post(
45+
"/",
46+
validator("header", async ({ "x-hub-signature-256": signature }, c) => {
47+
if (!verify(await c.req.text(), signature, whatsappSecret)) return c.json({ code: "invalid signature" }, 401);
48+
}),
49+
vValidator("json", event, validatorHook({ code: "bad chat" })),
50+
async (c) => {
51+
const delivered = parse(c.req.valid("json"));
52+
const foreign = [...new Set(delivered.map(({ phoneNumberId }) => phoneNumberId))].filter(
53+
(phoneNumberId) => phoneNumberId !== whatsappFrom,
54+
);
55+
if (foreign.length > 0) {
56+
captureException(new Error("chat delivered to another business number"), {
57+
level: "error",
58+
extra: { expected: whatsappFrom, foreign },
59+
});
60+
}
61+
const messages = new Map(
62+
delivered
63+
.filter(({ phoneNumberId }) => phoneNumberId === whatsappFrom)
64+
.map((message) => [message.id, message] as const),
65+
);
66+
const threads = new Map<string, [(typeof delivered)[number], ...(typeof delivered)[number][]]>();
67+
for (const message of messages.values()) {
68+
const key = `${message.phoneNumberId}/${message.from}`;
69+
const thread = threads.get(key);
70+
if (thread) thread.push(message);
71+
else threads.set(key, [message]);
72+
}
73+
await Promise.all(
74+
[...threads.values()].map(([sender, ...tail]) =>
75+
queue
76+
.enqueue({
77+
id: sender.id,
78+
contact: sender.contact,
79+
from: sender.from,
80+
text: [sender, ...tail].map(({ text }) => text).join("\n"),
81+
})
82+
.catch((error: unknown) => {
83+
captureException(error, { extra: { sender }, tags: { job: "chat", queue: "chat" } });
84+
throw error;
85+
}),
86+
),
87+
);
88+
return c.json({ code: "ok" });
89+
},
90+
);
91+
return own({ app, ready: Promise.resolve() }, () => queue.close());
92+
}
93+
94+
function verify(body: string, signature?: string, secret?: string) {
95+
if (!secret) return true;
96+
if (!signature) return false;
97+
const expected = Buffer.from(`sha256=${createHmac("sha256", secret).update(body).digest("hex")}`);
98+
const received = Buffer.from(signature);
99+
return received.length === expected.length && timingSafeEqual(received, expected);
100+
}
101+
102+
function parse({ entry }: v.InferOutput<typeof event>) {
103+
return entry.flatMap(({ changes }) =>
104+
changes.flatMap(({ value: { contacts, messages, metadata } }) =>
105+
(messages ?? []).flatMap((message) =>
106+
message.text
107+
? [
108+
{
109+
id: message.id,
110+
from: message.from_user_id,
111+
text: message.text.body,
112+
contact: contacts?.find(({ user_id }) => user_id === message.from_user_id)?.profile?.name,
113+
phoneNumberId: metadata.phone_number_id,
114+
},
115+
]
116+
: [],
117+
),
118+
),
119+
);
120+
}
121+
122+
const event = v.object({
123+
entry: v.array(
124+
v.object({
125+
changes: v.array(
126+
v.object({
127+
value: v.object({
128+
metadata: v.object({ phone_number_id: v.string() }),
129+
contacts: v.optional(
130+
v.array(
131+
v.object({
132+
user_id: v.string(),
133+
profile: v.optional(v.object({ name: v.optional(v.string()) })),
134+
}),
135+
),
136+
),
137+
messages: v.optional(
138+
v.array(
139+
v.object({
140+
id: v.string(),
141+
from_user_id: v.string(),
142+
type: v.string(),
143+
text: v.optional(v.object({ body: v.string() })),
144+
}),
145+
),
146+
),
147+
}),
148+
}),
149+
),
150+
}),
151+
),
152+
});

server/instrument.cjs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ const stack = require("@exactly/common/stack");
66

77
const development = stack === "localhost";
88

9-
init({
9+
/** @type {import("@sentry/node").NodeOptions} */
10+
const config = {
1011
dsn: env.SENTRY_DSN,
1112
release: require("./generated/release"),
1213
environment: stack,
1314
tracesSampleRate: 1,
15+
strictTraceContinuation: true,
16+
streamGenAiSpans: false,
1417
profilesSampleRate: 1,
1518
attachStacktrace: true,
1619
maxValueLength: 8192,
@@ -50,4 +53,7 @@ init({
5053
return transaction;
5154
},
5255
spotlight: development,
53-
});
56+
};
57+
init(config);
58+
59+
module.exports = config;

server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"generate:broadcasts": "[ \"$CHAIN_ID\" != 31337 ] || NODE_ENV=development tsx -e 'require(\"./test/anvil\").default({ provide: () => undefined }).then((teardown) => teardown())'",
1313
"db:push": "drizzle-kit push",
1414
"e2e": "tsx script/e2e.ts",
15+
"eval:chat": "tsx test/workers/chat.eval.ts",
1516
"test": "nx test server",
1617
"test:ts": "tsc --pretty ${GITHUB_ACTIONS:+false}",
1718
"test:vi": "vitest run",

server/test/hooks/bin.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Hono } from "hono";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import type * as Supervise from "../../supervise";
5+
6+
const mocks = {
7+
close: vi.fn<() => Promise<void>>(),
8+
hook: vi.fn<(config: Record<string, unknown>) => Hook>(),
9+
secret: vi.fn<(name: string, secrets: object) => Promise<string>>(),
10+
supervise: vi.fn<(name: string, created: Promise<Hook>) => void>(),
11+
};
12+
13+
afterEach(() => {
14+
vi.restoreAllMocks();
15+
});
16+
17+
beforeEach(() => {
18+
vi.resetModules();
19+
mocks.close.mockReset().mockResolvedValue();
20+
mocks.hook.mockReset().mockReturnValue({
21+
app: new Hono().get("/", (c) => c.json({ status: "ok" })),
22+
close: mocks.close,
23+
ready: Promise.resolve(),
24+
});
25+
mocks.secret.mockReset().mockImplementation((name) => Promise.resolve(name));
26+
mocks.supervise.mockReset();
27+
vi.doMock("ioredis", () => ({
28+
Redis: class {
29+
constructor(
30+
readonly redisUrl: string,
31+
readonly options: { maxRetriesPerRequest: null },
32+
) {}
33+
},
34+
}));
35+
vi.doMock("../../hooks/chat", () => ({ default: mocks.hook }));
36+
vi.doMock("../../supervise", async (importOriginal) => ({
37+
...(await importOriginal<typeof Supervise>()),
38+
default: mocks.supervise,
39+
}));
40+
vi.doMock("../../utils/secret", () => ({ default: mocks.secret }));
41+
});
42+
43+
describe("hook bin", () => {
44+
it.each([
45+
{
46+
config: {
47+
bullmq: expect.objectContaining({ redisUrl: "redis-url", options: { maxRetriesPerRequest: null } }) as object,
48+
whatsappFrom: "whatsapp-phone-number-id",
49+
whatsappSecret: "chat-whatsapp-app-secret",
50+
whatsappVerifyToken: "chat-whatsapp-verify-token",
51+
},
52+
load: () => import("../../hooks/bin/chat"),
53+
name: "chat",
54+
secrets: ["redis-url", "whatsapp-phone-number-id", "chat-whatsapp-app-secret", "chat-whatsapp-verify-token"],
55+
},
56+
])(
57+
"resolves private config before constructing and supervising the $name hook",
58+
async ({ config, load, name, secrets: names }) => {
59+
await load();
60+
const created = mocks.supervise.mock.calls[0]?.[1];
61+
if (!created) throw new Error(`missing ${name} hook`);
62+
const createdHook = await created;
63+
64+
expect(mocks.secret.mock.calls.map(([secret]) => secret)).toStrictEqual(names);
65+
expect(new Set(mocks.secret.mock.calls.map(([, secrets]) => secrets)).size).toBe(1);
66+
const response = await createdHook.app.request("/");
67+
expect(response.status).toBe(200);
68+
await expect(response.json()).resolves.toStrictEqual({ status: "ok" });
69+
expect(mocks.hook).toHaveBeenCalledExactlyOnceWith(config);
70+
expect(mocks.supervise).toHaveBeenCalledExactlyOnceWith(name, created);
71+
},
72+
);
73+
});
74+
75+
type Hook = {
76+
app: Hono;
77+
close(): Promise<void>;
78+
ready: Promise<unknown>;
79+
};

0 commit comments

Comments
 (0)