Skip to content

Commit f0bd8e6

Browse files
committed
feat: add bundled follow-up delivery
1 parent 0b1aefd commit f0bd8e6

21 files changed

Lines changed: 2004 additions & 108 deletions
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
import { POST } from "./route";
4+
5+
const { runBundledFollowUpsMock } = vi.hoisted(() => ({
6+
runBundledFollowUpsMock: vi.fn()
7+
}));
8+
9+
vi.mock("@/lib/server/follow-up", () => ({
10+
runBundledFollowUps: runBundledFollowUpsMock
11+
}));
12+
13+
describe("send followups cron route", () => {
14+
beforeEach(() => {
15+
delete process.env.CRON_SECRET;
16+
runBundledFollowUpsMock.mockReset();
17+
runBundledFollowUpsMock.mockResolvedValue({
18+
accepted: true,
19+
sentBundles: 0,
20+
skippedActiveTurns: 0
21+
});
22+
});
23+
24+
it("rejects unauthenticated cron requests", async () => {
25+
process.env.CRON_SECRET = "cron-secret";
26+
27+
const response = await POST(new Request("http://localhost/api/cron/send-followups", { method: "POST" }));
28+
29+
expect(response.status).toBe(401);
30+
await expect(response.json()).resolves.toMatchObject({
31+
accepted: false,
32+
error: "invalid_cron_secret"
33+
});
34+
});
35+
36+
it("fails closed when the cron secret is not configured", async () => {
37+
const response = await POST(new Request("http://localhost/api/cron/send-followups", { method: "POST" }));
38+
39+
expect(response.status).toBe(503);
40+
await expect(response.json()).resolves.toMatchObject({
41+
accepted: false,
42+
error: "cron_secret_not_configured"
43+
});
44+
});
45+
46+
it("runs followups for authorized cron requests", async () => {
47+
process.env.CRON_SECRET = "cron-secret";
48+
49+
const response = await POST(
50+
new Request("http://localhost/api/cron/send-followups", {
51+
method: "POST",
52+
headers: { authorization: "Bearer cron-secret" }
53+
})
54+
);
55+
56+
expect(response.status).toBe(200);
57+
await expect(response.json()).resolves.toMatchObject({
58+
accepted: true,
59+
sentBundles: 0
60+
});
61+
expect(runBundledFollowUpsMock).toHaveBeenCalledTimes(1);
62+
});
63+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { NextResponse } from "next/server";
2+
3+
import { jsonOk } from "@/lib/server/http";
4+
import { runBundledFollowUps } from "@/lib/server/follow-up";
5+
6+
function isAuthorized(request: Request) {
7+
const secret = process.env.CRON_SECRET;
8+
9+
if (!secret) {
10+
return {
11+
ok: false as const,
12+
status: 503,
13+
body: {
14+
accepted: false,
15+
error: "cron_secret_not_configured"
16+
}
17+
};
18+
}
19+
20+
if (request.headers.get("authorization") !== `Bearer ${secret}`) {
21+
return {
22+
ok: false as const,
23+
status: 401,
24+
body: {
25+
accepted: false,
26+
error: "invalid_cron_secret"
27+
}
28+
};
29+
}
30+
31+
return { ok: true as const };
32+
}
33+
34+
export async function POST(request: Request) {
35+
const auth = isAuthorized(request);
36+
37+
if (!auth.ok) {
38+
return NextResponse.json(auth.body, { status: auth.status });
39+
}
40+
41+
return jsonOk(await runBundledFollowUps());
42+
}

apps/web/src/app/api/telegram/webhook/route.test.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,102 @@ function buildRequest(body: unknown, secret = "test-webhook-secret") {
106106
});
107107
}
108108

109+
async function seedOutstandingFollowUpBundle(titles: string[]) {
110+
const store = getDefaultInboxProcessingStore();
111+
112+
seedInboxItemForProcessingTests({
113+
id: "inbox-seed",
114+
userId: "123",
115+
sourceEventId: "seed-event",
116+
rawText: "schedule it",
117+
normalizedText: "schedule it",
118+
processingStatus: "received",
119+
linkedTaskIds: [],
120+
createdAt: "2026-03-20T16:00:00.000Z"
121+
});
122+
123+
await store.saveTaskCaptureResult({
124+
inboxItemId: "inbox-seed",
125+
confidence: 1,
126+
plannerRun: {
127+
id: "ignored",
128+
userId: "123",
129+
inboxItemId: "inbox-seed",
130+
version: "test",
131+
modelInput: {},
132+
modelOutput: {},
133+
confidence: 1
134+
} as never,
135+
tasks: titles.map((title, index) => ({
136+
alias: `task_${index + 1}`,
137+
task: {
138+
userId: "123",
139+
sourceInboxItemId: "inbox-seed",
140+
lastInboxItemId: "inbox-seed",
141+
title,
142+
lifecycleState: "pending_schedule",
143+
externalCalendarEventId: null,
144+
externalCalendarId: null,
145+
scheduledStartAt: null,
146+
scheduledEndAt: null,
147+
calendarSyncStatus: "in_sync",
148+
calendarSyncUpdatedAt: null,
149+
rescheduleCount: 0,
150+
lastFollowupAt: null,
151+
followupReminderSentAt: null,
152+
completedAt: null,
153+
archivedAt: null,
154+
priority: "medium",
155+
urgency: "medium"
156+
}
157+
})),
158+
scheduleBlocks: titles.map((_, index) => ({
159+
id: `event-${index + 1}`,
160+
userId: "123",
161+
taskId: `task_${index + 1}`,
162+
startAt: "2026-03-20T16:00:00.000Z",
163+
endAt: "2026-03-20T17:00:00.000Z",
164+
confidence: 1,
165+
reason: "test",
166+
rescheduleCount: 0,
167+
externalCalendarId: "primary"
168+
})),
169+
followUpMessage: "Scheduled it."
170+
});
171+
172+
const taskIds = listTasksForTests().map((task) => task.id);
173+
await (store as any).markFollowUpSent(taskIds, "2026-03-20T17:00:00.000Z");
174+
175+
await recordOutgoingTelegramMessageIfNew({
176+
userId: "123",
177+
eventType: "telegram_followup_message",
178+
idempotencyKey: "telegram:followup:bundle-seed",
179+
payload: {
180+
kind: "initial",
181+
taskIds,
182+
items: taskIds.map((taskId, index) => ({ number: index + 1, taskId, title: titles[index] ?? `Task ${index + 1}` })),
183+
text: titles.map((title, index) => `${index + 1}. ${title}`).join("\n")
184+
},
185+
retryState: "sending"
186+
});
187+
await updateOutgoingTelegramMessage({
188+
idempotencyKey: "telegram:followup:bundle-seed",
189+
payload: {
190+
kind: "initial",
191+
taskIds,
192+
items: taskIds.map((taskId, index) => ({ number: index + 1, taskId, title: titles[index] ?? `Task ${index + 1}` })),
193+
text: titles.map((title, index) => `${index + 1}. ${title}`).join("\n"),
194+
attempts: 1
195+
},
196+
retryState: "sent"
197+
});
198+
199+
return {
200+
store,
201+
taskIds
202+
};
203+
}
204+
109205
afterEach(() => {
110206
restoreEnv("DATABASE_URL");
111207
restoreEnv("OPENAI_API_KEY");
@@ -239,6 +335,152 @@ describe("telegram webhook route", () => {
239335
expect(listScheduleBlocksForTests()).toHaveLength(0);
240336
});
241337

338+
it("binds numbered followup replies and marks the selected task done", async () => {
339+
const { store } = await seedOutstandingFollowUpBundle(["Review launch checklist"]);
340+
341+
const response = await handleTelegramWebhook(
342+
buildRequest({
343+
update_id: 41,
344+
message: {
345+
message_id: 6,
346+
date: 1_700_000_000,
347+
text: "1 done",
348+
chat: { id: 999, type: "private" },
349+
from: { id: 123, is_bot: false, first_name: "Max" }
350+
}
351+
}),
352+
{
353+
store,
354+
followUpStore: store as any,
355+
primeProcessingStore: seedInboxItemForProcessingTests
356+
}
357+
);
358+
359+
expect(response.status).toBe(200);
360+
expect((response.body as { processing: { outcome: string } }).processing.outcome).toBe("completed_tasks");
361+
expect(listTasksForTests()[0]).toMatchObject({
362+
lifecycleState: "done"
363+
});
364+
});
365+
366+
it("accepts short natural-language followup replies that reference bundle numbers", async () => {
367+
const { store, taskIds } = await seedOutstandingFollowUpBundle(["Task 1", "Task 2"]);
368+
369+
const response = await handleTelegramWebhook(
370+
buildRequest({
371+
update_id: 142,
372+
message: {
373+
message_id: 107,
374+
date: 1_700_000_000,
375+
text: "finished 2",
376+
chat: { id: 999, type: "private" },
377+
from: { id: 123, is_bot: false, first_name: "Max" }
378+
}
379+
}),
380+
{
381+
store,
382+
followUpStore: store as any,
383+
primeProcessingStore: seedInboxItemForProcessingTests
384+
}
385+
);
386+
387+
expect(response.status).toBe(200);
388+
expect((response.body as { processing: { outcome: string } }).processing.outcome).toBe("completed_tasks");
389+
expect(listTasksForTests().find((task) => task.id === taskIds[0])).toMatchObject({
390+
lifecycleState: "awaiting_followup"
391+
});
392+
expect(listTasksForTests().find((task) => task.id === taskIds[1])).toMatchObject({
393+
lifecycleState: "done"
394+
});
395+
});
396+
397+
it("accepts ordinal followup archive replies", async () => {
398+
const { store, taskIds } = await seedOutstandingFollowUpBundle(["Task 1", "Task 2"]);
399+
400+
const response = await handleTelegramWebhook(
401+
buildRequest({
402+
update_id: 143,
403+
message: {
404+
message_id: 108,
405+
date: 1_700_000_000,
406+
text: "archive the second one",
407+
chat: { id: 999, type: "private" },
408+
from: { id: 123, is_bot: false, first_name: "Max" }
409+
}
410+
}),
411+
{
412+
store,
413+
followUpStore: store as any,
414+
primeProcessingStore: seedInboxItemForProcessingTests
415+
}
416+
);
417+
418+
expect(response.status).toBe(200);
419+
expect((response.body as { processing: { outcome: string } }).processing.outcome).toBe("archived_tasks");
420+
expect(listTasksForTests().find((task) => task.id === taskIds[0])).toMatchObject({
421+
lifecycleState: "awaiting_followup"
422+
});
423+
expect(listTasksForTests().find((task) => task.id === taskIds[1])).toMatchObject({
424+
lifecycleState: "archived"
425+
});
426+
});
427+
428+
it("asks for clarification on ambiguous followup replies with multiple unresolved items", async () => {
429+
const { store } = await seedOutstandingFollowUpBundle(["Task 1", "Task 2"]);
430+
431+
const response = await handleTelegramWebhook(
432+
buildRequest({
433+
update_id: 42,
434+
message: {
435+
message_id: 7,
436+
date: 1_700_000_000,
437+
text: "done",
438+
chat: { id: 999, type: "private" },
439+
from: { id: 123, is_bot: false, first_name: "Max" }
440+
}
441+
}),
442+
{
443+
store,
444+
followUpStore: store as any,
445+
primeProcessingStore: seedInboxItemForProcessingTests
446+
}
447+
);
448+
449+
expect(response.status).toBe(200);
450+
expect(sendTelegramMessageMock).toHaveBeenCalledWith({
451+
chatId: "999",
452+
text: "Which one do you mean? Reply with the number or numbers."
453+
});
454+
});
455+
456+
it("lets mixed-intent messages fall through to the normal router even with outstanding followups", async () => {
457+
const { store } = await seedOutstandingFollowUpBundle(["Review launch checklist", "Send investor update"]);
458+
459+
const response = await handleTelegramWebhook(
460+
buildRequest({
461+
update_id: 144,
462+
message: {
463+
message_id: 109,
464+
date: 1_700_000_000,
465+
text: "1 done and schedule dentist tomorrow",
466+
chat: { id: 999, type: "private" },
467+
from: { id: 123, is_bot: false, first_name: "Max" }
468+
}
469+
}),
470+
{
471+
store,
472+
followUpStore: store as any,
473+
calendar: getDefaultCalendarAdapter(),
474+
primeProcessingStore: seedInboxItemForProcessingTests
475+
}
476+
);
477+
478+
expect(response.status).toBe(200);
479+
expect(routeTurnWithResponsesMock).toHaveBeenCalledTimes(1);
480+
expect((response.body as { processing: { outcome: string } }).processing.outcome).toBe("planned");
481+
expect(listTasksForTests().filter((task) => task.lifecycleState === "done")).toHaveLength(0);
482+
});
483+
242484
it("also gates /start for unlinked users", async () => {
243485
resetGoogleCalendarConnectionStoreForTests();
244486

0 commit comments

Comments
 (0)