@teamscala/service-boot 0.23.401 → 0.23.402
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/fn-routers/public-assessment-router.test.ts +44 -1
- package/src/fn-routers/public-assessment-router.ts +7 -1
- package/src/handlers/messaging-send-reconcile-logic.test.ts +81 -0
- package/src/handlers/messaging-send-reconcile-logic.ts +83 -0
- package/src/handlers/messaging-send-recording-watch.ts +164 -6
- package/src/handlers/messaging-send-sweep-selection.test.ts +45 -0
- package/src/handlers/messaging-send-sweep-selection.ts +35 -0
- package/src/handlers/whatsapp-liveness.ts +4 -1
package/package.json
CHANGED
|
@@ -28,6 +28,7 @@ let capturedEvent: { kind: string; payload: Record<string, unknown> } | null = n
|
|
|
28
28
|
/** Mutable so one test can stub a brand row with a relative logo path. */
|
|
29
29
|
let brandRowStub: {
|
|
30
30
|
companyName: string | null;
|
|
31
|
+
companyFriendlyName?: string | null;
|
|
31
32
|
primaryColor: string;
|
|
32
33
|
accentColor: string | null;
|
|
33
34
|
logo: { light: string | null; dark: string | null; brand: string | null };
|
|
@@ -58,7 +59,9 @@ mock.module("@teamscala/api-resend/send-email", () => ({
|
|
|
58
59
|
// applyTemplateVars stays the REAL implementation — only lookup is stubbed.
|
|
59
60
|
mock.module("@teamscala/email/templates/lookup", () => ({
|
|
60
61
|
lookupTemplate: async () => ({
|
|
61
|
-
|
|
62
|
+
// Mirrors the live authority-index-report row, which leads with {{orgName}}
|
|
63
|
+
// — a stub without it cannot catch a wrong org name in the subject.
|
|
64
|
+
subject: "{{orgName}}: your Authority Index — {{totalScore}}/100 ({{bandLabel}})",
|
|
62
65
|
bodyHtml:
|
|
63
66
|
"{{brandLogoHtml}}<h1>{{totalScore}}/100 — {{bandLabel}}</h1><h2>{{reportTitle}}</h2><p>{{tagline}}</p>{{narrativeHtml}}{{pillarNotesHtml}}{{priorityActionsHtml}}{{roadmapHtml}}{{strategicFocusHtml}}{{opportunitiesHtml}}{{twelveMonthTargetHtml}}",
|
|
64
67
|
bodyText:
|
|
@@ -287,6 +290,46 @@ describe("publicAssessment.submit — email band single-sourced from stage_bands
|
|
|
287
290
|
expect(capturedEvent?.payload.band).toBe("experimenting");
|
|
288
291
|
});
|
|
289
292
|
|
|
293
|
+
test("the subject and logo alt use the FRIENDLY name, not the legal entity", async () => {
|
|
294
|
+
// company_name is the legal entity (GMG APAC); company_friendly_name is
|
|
295
|
+
// what customers know the org as (launcha). Recipients were getting
|
|
296
|
+
// "GMG APAC: your Authority Index" and alt="GMG APAC".
|
|
297
|
+
brandRowStub = {
|
|
298
|
+
companyName: "GMG APAC",
|
|
299
|
+
companyFriendlyName: "launcha",
|
|
300
|
+
primaryColor: "#FF4500",
|
|
301
|
+
accentColor: null,
|
|
302
|
+
logo: { light: "/api/assets/5b742740/logos/launcha.png", dark: null, brand: null },
|
|
303
|
+
};
|
|
304
|
+
const emailPromise = new Promise<CapturedEmail>((resolve) => {
|
|
305
|
+
emailResolve = resolve;
|
|
306
|
+
});
|
|
307
|
+
await submitWith45(new Headers({ "x-forwarded-host": "quiz.scala.business" }));
|
|
308
|
+
const email = await emailPromise;
|
|
309
|
+
|
|
310
|
+
expect(email.subject).toContain("launcha:");
|
|
311
|
+
expect(email.subject).not.toContain("GMG APAC");
|
|
312
|
+
expect(email.html).toContain('alt="launcha"');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("an org with no friendly name still gets its legal name, unchanged", async () => {
|
|
316
|
+
// The fallback must stay intact: most orgs have never set a friendly name.
|
|
317
|
+
brandRowStub = {
|
|
318
|
+
companyName: "GMG APAC",
|
|
319
|
+
companyFriendlyName: null,
|
|
320
|
+
primaryColor: "#FF4500",
|
|
321
|
+
accentColor: null,
|
|
322
|
+
logo: { light: "/api/assets/5b742740/logos/launcha.png", dark: null, brand: null },
|
|
323
|
+
};
|
|
324
|
+
const emailPromise = new Promise<CapturedEmail>((resolve) => {
|
|
325
|
+
emailResolve = resolve;
|
|
326
|
+
});
|
|
327
|
+
await submitWith45(new Headers({ "x-forwarded-host": "quiz.scala.business" }));
|
|
328
|
+
const email = await emailPromise;
|
|
329
|
+
|
|
330
|
+
expect(email.subject).toContain("GMG APAC:");
|
|
331
|
+
});
|
|
332
|
+
|
|
290
333
|
test("a relative brand logo renders as an absolute img src from the serving host", async () => {
|
|
291
334
|
brandRowStub = {
|
|
292
335
|
companyName: "GMG APAC",
|
|
@@ -151,7 +151,13 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
151
151
|
const sender = await resolveEmailSender(opts.organisationId ?? null, {
|
|
152
152
|
brand: brandRow
|
|
153
153
|
? {
|
|
154
|
-
|
|
154
|
+
// The FRIENDLY name is the customer-facing one; company_name is the
|
|
155
|
+
// legal entity. Recipients were getting "GMG APAC: your Authority
|
|
156
|
+
// Index" and alt="GMG APAC" on the logo, for an org whose brand is
|
|
157
|
+
// Launcha. Same precedence the platform already uses in
|
|
158
|
+
// agreement-gate (friendly ?? legal), so an org that never set a
|
|
159
|
+
// friendly name is unchanged.
|
|
160
|
+
orgName: brandRow.companyFriendlyName || brandRow.companyName || "",
|
|
155
161
|
primaryColor: brandRow.primaryColor,
|
|
156
162
|
accentColor: brandRow.accentColor ?? null,
|
|
157
163
|
logoUrl: absolutizeAssetUrl(brandRow.logo.light),
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Fixture pair for the outbound-dispatch reconciliation logic: the clean
|
|
7
|
+
* fixture (every GOWA outbound id already recorded) and the violating fixture
|
|
8
|
+
* (a raw-dispatched send GOWA knows about that left no messaging_logs row —
|
|
9
|
+
* the 2026-08-26 measured class — which MUST come back as a backfill
|
|
10
|
+
* candidate).
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import {
|
|
14
|
+
buildBackfillRow,
|
|
15
|
+
diffOutboundSends,
|
|
16
|
+
type GowaOutboundMessage,
|
|
17
|
+
} from "./messaging-send-reconcile-logic.ts";
|
|
18
|
+
|
|
19
|
+
const NOW = new Date("2026-08-26T02:30:00Z");
|
|
20
|
+
const SETTLE_MS = 120_000;
|
|
21
|
+
|
|
22
|
+
describe("diffOutboundSends", () => {
|
|
23
|
+
const rawSend: GowaOutboundMessage = {
|
|
24
|
+
id: "3EB0F05AC9FAAF24BDA045",
|
|
25
|
+
chat_jid: "61428193498@s.whatsapp.net",
|
|
26
|
+
content: "Launcha Authority Index — report page header",
|
|
27
|
+
timestamp: "2026-08-25T16:13:57Z",
|
|
28
|
+
is_from_me: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test("CLEAN: every outbound id already recorded — nothing to backfill", () => {
|
|
32
|
+
const missing = diffOutboundSends(
|
|
33
|
+
[rawSend],
|
|
34
|
+
new Set(["3EB0F05AC9FAAF24BDA045"]),
|
|
35
|
+
NOW,
|
|
36
|
+
SETTLE_MS,
|
|
37
|
+
);
|
|
38
|
+
expect(missing).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("VIOLATING: a GOWA-outbound send with no recorded row is a backfill candidate", () => {
|
|
42
|
+
const missing = diffOutboundSends([rawSend], new Set(), NOW, SETTLE_MS);
|
|
43
|
+
expect(missing).toHaveLength(1);
|
|
44
|
+
expect(missing[0]).toEqual({
|
|
45
|
+
id: "3EB0F05AC9FAAF24BDA045",
|
|
46
|
+
chatJid: "61428193498@s.whatsapp.net",
|
|
47
|
+
content: "Launcha Authority Index — report page header",
|
|
48
|
+
timestamp: "2026-08-25T16:13:57.000Z",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("inbound messages are never candidates (they record themselves via webhook)", () => {
|
|
53
|
+
const inbound: GowaOutboundMessage = { ...rawSend, is_from_me: false };
|
|
54
|
+
expect(diffOutboundSends([inbound], new Set(), NOW, SETTLE_MS)).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a send younger than the settle window waits one tick for the recorder", () => {
|
|
58
|
+
const fresh: GowaOutboundMessage = {
|
|
59
|
+
...rawSend,
|
|
60
|
+
timestamp: "2026-08-26T02:29:30Z",
|
|
61
|
+
};
|
|
62
|
+
expect(diffOutboundSends([fresh], new Set(), NOW, SETTLE_MS)).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("buildBackfillRow", () => {
|
|
67
|
+
test("carries the provider id, the recipient, and the reconciled send_kind", () => {
|
|
68
|
+
const row = buildBackfillRow({
|
|
69
|
+
id: "3EB0AF4413C8A5A0694B38",
|
|
70
|
+
chatJid: "61428193498@s.whatsapp.net",
|
|
71
|
+
content: "The quiz report email as delivered",
|
|
72
|
+
timestamp: "2026-08-25T16:14:13Z",
|
|
73
|
+
});
|
|
74
|
+
expect(row.direction).toBe("outbound");
|
|
75
|
+
expect(row.event_type).toBe("sent");
|
|
76
|
+
expect(row.external_msg_id).toBe("3EB0AF4413C8A5A0694B38");
|
|
77
|
+
expect(row.external_chat_id).toBe("61428193498@s.whatsapp.net");
|
|
78
|
+
expect(row.user_phone).toBe("61428193498@s.whatsapp.net");
|
|
79
|
+
expect(row.payload.send_kind).toBe("gowa-reconcile");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Pure reconciliation logic for the outbound-dispatch coverage half of
|
|
7
|
+
* `messaging-send-recording-watch`: diff GOWA's outbound message list against
|
|
8
|
+
* the recorded provider ids and shape the backfill rows — a send dispatched
|
|
9
|
+
* outside every recording chokepoint exists only in GOWA's list (2026-08-26).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One outbound message from GOWA `getChatMessages` with is_from_me=true. */
|
|
13
|
+
export interface GowaOutboundMessage {
|
|
14
|
+
id: string;
|
|
15
|
+
chat_jid: string;
|
|
16
|
+
content: string | null;
|
|
17
|
+
timestamp: string | null;
|
|
18
|
+
is_from_me: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A GOWA-outbound message with no messaging_logs row — the backfill input. */
|
|
22
|
+
export interface MissingSend {
|
|
23
|
+
id: string;
|
|
24
|
+
chatJid: string;
|
|
25
|
+
content: string | null;
|
|
26
|
+
timestamp: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Diff GOWA's outbound truth against the recorded provider ids. Only
|
|
31
|
+
* `is_from_me` messages count (the bridge device's own sends — inbound arrives
|
|
32
|
+
* by webhook and records itself). Messages newer than the settle window are
|
|
33
|
+
* held back one tick so the dispatch-time recorder's own write can land.
|
|
34
|
+
*/
|
|
35
|
+
export function diffOutboundSends(
|
|
36
|
+
messages: GowaOutboundMessage[],
|
|
37
|
+
recordedIds: Set<string>,
|
|
38
|
+
now: Date,
|
|
39
|
+
settleMs: number,
|
|
40
|
+
): MissingSend[] {
|
|
41
|
+
const missing: MissingSend[] = [];
|
|
42
|
+
for (const message of messages) {
|
|
43
|
+
if (!message.is_from_me) continue;
|
|
44
|
+
if (!message.id || recordedIds.has(message.id)) continue;
|
|
45
|
+
const at = Date.parse(message.timestamp ?? "");
|
|
46
|
+
if (Number.isNaN(at)) continue;
|
|
47
|
+
if (at > now.getTime() - settleMs) continue;
|
|
48
|
+
missing.push({
|
|
49
|
+
id: message.id,
|
|
50
|
+
chatJid: message.chat_jid,
|
|
51
|
+
content: message.content,
|
|
52
|
+
timestamp: new Date(at).toISOString(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return missing;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The messaging_logs row that makes a bypassed send reconstructible. */
|
|
59
|
+
export function buildBackfillRow(missing: MissingSend): {
|
|
60
|
+
id: string;
|
|
61
|
+
platform: "whatsapp";
|
|
62
|
+
direction: "outbound";
|
|
63
|
+
event_type: "sent";
|
|
64
|
+
external_chat_id: string;
|
|
65
|
+
user_phone: string;
|
|
66
|
+
external_msg_id: string;
|
|
67
|
+
content: string | null;
|
|
68
|
+
payload: Record<string, unknown>;
|
|
69
|
+
timestampIso: string;
|
|
70
|
+
} {
|
|
71
|
+
return {
|
|
72
|
+
id: crypto.randomUUID(),
|
|
73
|
+
platform: "whatsapp",
|
|
74
|
+
direction: "outbound",
|
|
75
|
+
event_type: "sent",
|
|
76
|
+
external_chat_id: missing.chatJid,
|
|
77
|
+
user_phone: missing.chatJid,
|
|
78
|
+
external_msg_id: missing.id,
|
|
79
|
+
content: missing.content,
|
|
80
|
+
payload: { send_kind: "gowa-reconcile" },
|
|
81
|
+
timestampIso: missing.timestamp,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -30,10 +30,28 @@ import {
|
|
|
30
30
|
detectUnrecordedSends,
|
|
31
31
|
type UnrecordedSendRow,
|
|
32
32
|
} from "./messaging-send-recording-logic.ts";
|
|
33
|
+
import {
|
|
34
|
+
buildBackfillRow,
|
|
35
|
+
diffOutboundSends,
|
|
36
|
+
type GowaOutboundMessage,
|
|
37
|
+
} from "./messaging-send-reconcile-logic.ts";
|
|
38
|
+
import {
|
|
39
|
+
selectChatsToSweep,
|
|
40
|
+
type GowaChatSummary,
|
|
41
|
+
} from "./messaging-send-sweep-selection.ts";
|
|
42
|
+
import { getSystemIdentity } from "./whatsapp-liveness.ts";
|
|
33
43
|
|
|
34
44
|
const DEFAULT_SAMPLE_LIMIT = 5;
|
|
35
45
|
/** Bounds the per-tick read while a break runs for weeks; far above daily volume. */
|
|
36
46
|
const QUERY_ROW_CAP = 1000;
|
|
47
|
+
/** GOWA page size cap (the spec's maximum for both listChats and messages). */
|
|
48
|
+
const GOWA_PAGE_LIMIT = 100;
|
|
49
|
+
/** Upper bound on message pages walked per chat per tick. */
|
|
50
|
+
const MAX_MESSAGE_PAGES_PER_CHAT = 5;
|
|
51
|
+
/** A freshly-dispatched send waits this long before the reconcile sweep may
|
|
52
|
+
* claim it — the tool-dispatch recorder writes its row at dispatch, and
|
|
53
|
+
* sweeping sooner would backfill a duplicate of a row about to exist. */
|
|
54
|
+
const RECONCILE_SETTLE_MS = 120_000;
|
|
37
55
|
|
|
38
56
|
export async function messagingSendRecordingWatch({
|
|
39
57
|
config,
|
|
@@ -69,15 +87,155 @@ export async function messagingSendRecordingWatch({
|
|
|
69
87
|
log.info(
|
|
70
88
|
`[messaging-send-recording-watch] poll complete — every outbound send at/after ${recordedSince} recorded its provider id`,
|
|
71
89
|
);
|
|
72
|
-
|
|
90
|
+
} else {
|
|
91
|
+
const payload = buildUnrecordedPayload(finding, recordedSince);
|
|
92
|
+
recordEvent({ kind: "messaging.send.unrecorded", payload });
|
|
93
|
+
log.warn(
|
|
94
|
+
`[messaging-send-recording-watch] ${finding.unrecordedCount} outbound send(s) at/after ${recordedSince} recorded no provider message id — their delivery receipts can never correlate`,
|
|
95
|
+
payload,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Dispatch-coverage reconcile — the row-shape check above can only inspect
|
|
100
|
+
// rows that EXIST; a send dispatched to GOWA outside every recording
|
|
101
|
+
// chokepoint (measured 2026-08-26: raw multipart sends delivered with zero
|
|
102
|
+
// rows) is invisible to it. GOWA's own message list is the authoritative
|
|
103
|
+
// outbound record, so this sweep backfills what bypassed us. Failure here
|
|
104
|
+
// must never take the row-shape check down with it.
|
|
105
|
+
try {
|
|
106
|
+
const backfilled = await reconcileOutboundDispatches(recordedSince);
|
|
107
|
+
if (backfilled > 0) {
|
|
108
|
+
log.warn(
|
|
109
|
+
`[messaging-send-recording-watch] reconcile: backfilled ${backfilled} outbound send(s) GOWA delivered with no messaging_logs row (dispatched outside the recording surface)`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
log.warn(`[messaging-send-recording-watch] reconcile sweep failed: ${message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Dispatch one api_gowa_chats action through the central /mcp loopback — the
|
|
119
|
+
* same single sealed-credential surface whatsapp-liveness uses, as the system
|
|
120
|
+
* caller. */
|
|
121
|
+
async function dispatchGowaChats(
|
|
122
|
+
action: string,
|
|
123
|
+
params: Record<string, unknown>,
|
|
124
|
+
): Promise<unknown> {
|
|
125
|
+
const { forwardToolToCentralMcp } = await import(
|
|
126
|
+
"@teamscala/chat-completion/central-tool-forwarder"
|
|
127
|
+
);
|
|
128
|
+
const { userId, organisationId } = await getSystemIdentity();
|
|
129
|
+
return forwardToolToCentralMcp(
|
|
130
|
+
"api_gowa_chats",
|
|
131
|
+
{ action, params },
|
|
132
|
+
{ userId, organisationId } as Parameters<typeof forwardToolToCentralMcp>[2],
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface GowaPaged<T> {
|
|
137
|
+
results?: { data?: T[]; pagination?: { total?: number } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The convergent half of the watch: walk GOWA's chats newest-first for
|
|
142
|
+
* outbound activity at/after the baseline, read each qualifying chat's own
|
|
143
|
+
* sends, and backfill every provider id messaging_logs is missing. Idempotent
|
|
144
|
+
* by external_msg_id — a backfilled row can never be backfilled twice, and the
|
|
145
|
+
* settle window keeps it from racing the dispatch-time recorder.
|
|
146
|
+
*/
|
|
147
|
+
async function reconcileOutboundDispatches(recordedSince: string): Promise<number> {
|
|
148
|
+
const log = getAppLogger();
|
|
149
|
+
const horizonMs = Date.parse(recordedSince);
|
|
150
|
+
if (Number.isNaN(horizonMs)) return 0;
|
|
151
|
+
|
|
152
|
+
const chatJids: string[] = [];
|
|
153
|
+
for (let offset = 0; offset < 100 * 20; offset += GOWA_PAGE_LIMIT) {
|
|
154
|
+
const page = (await dispatchGowaChats("listChats", {
|
|
155
|
+
limit: GOWA_PAGE_LIMIT,
|
|
156
|
+
offset,
|
|
157
|
+
})) as GowaPaged<GowaChatSummary> | undefined;
|
|
158
|
+
const chats = (page?.results?.data ?? []) as GowaChatSummary[];
|
|
159
|
+
if (chats.length === 0) break;
|
|
160
|
+
const { toSweep, mayStopPaging } = selectChatsToSweep(chats, horizonMs);
|
|
161
|
+
chatJids.push(...toSweep);
|
|
162
|
+
if (mayStopPaging) break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const outbound: GowaOutboundMessage[] = [];
|
|
166
|
+
for (const chatJid of chatJids) {
|
|
167
|
+
for (let page = 0; page < MAX_MESSAGE_PAGES_PER_CHAT; page++) {
|
|
168
|
+
const messages = (await dispatchGowaChats("getChatMessages", {
|
|
169
|
+
chat_jid: chatJid,
|
|
170
|
+
is_from_me: true,
|
|
171
|
+
start_time: recordedSince,
|
|
172
|
+
limit: GOWA_PAGE_LIMIT,
|
|
173
|
+
offset: page * GOWA_PAGE_LIMIT,
|
|
174
|
+
})) as GowaPaged<GowaOutboundMessage> | undefined;
|
|
175
|
+
const batch = ((messages?.results?.data ?? []) as GowaOutboundMessage[]).filter(
|
|
176
|
+
(m) => m && typeof m.id === "string",
|
|
177
|
+
);
|
|
178
|
+
outbound.push(...batch);
|
|
179
|
+
if (batch.length < GOWA_PAGE_LIMIT) break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (outbound.length === 0) return 0;
|
|
183
|
+
|
|
184
|
+
const prisma = getPrisma();
|
|
185
|
+
const ids = outbound.map((m) => m.id);
|
|
186
|
+
const recorded = await prisma.$queryRawUnsafe<Array<{ external_msg_id: string }>>(
|
|
187
|
+
`SELECT external_msg_id FROM messaging_logs WHERE external_msg_id = ANY($1::text[])`,
|
|
188
|
+
ids,
|
|
189
|
+
);
|
|
190
|
+
const recordedIds = new Set(recorded.map((r) => r.external_msg_id));
|
|
191
|
+
const missing = diffOutboundSends(outbound, recordedIds, new Date(), RECONCILE_SETTLE_MS);
|
|
192
|
+
if (missing.length === 0) return 0;
|
|
193
|
+
|
|
194
|
+
for (const miss of missing) {
|
|
195
|
+
const row = buildBackfillRow(miss);
|
|
196
|
+
// createMany + skipDuplicates: the table's unique (agent_id,
|
|
197
|
+
// external_chat_id, external_msg_id) makes a double-backfill (a sweep
|
|
198
|
+
// racing the dispatch-time recorder) a no-op instead of a crash.
|
|
199
|
+
await prisma.messaging_logs.createMany({
|
|
200
|
+
skipDuplicates: true,
|
|
201
|
+
data: {
|
|
202
|
+
id: row.id,
|
|
203
|
+
organisation_id: "",
|
|
204
|
+
platform: row.platform,
|
|
205
|
+
agent_id: "",
|
|
206
|
+
external_chat_id: row.external_chat_id,
|
|
207
|
+
user_phone: row.user_phone,
|
|
208
|
+
external_msg_id: row.external_msg_id,
|
|
209
|
+
direction: row.direction,
|
|
210
|
+
event_type: row.event_type,
|
|
211
|
+
content: row.content,
|
|
212
|
+
payload: {
|
|
213
|
+
...row.payload,
|
|
214
|
+
send_at: row.timestampIso,
|
|
215
|
+
reconciled_at: new Date().toISOString(),
|
|
216
|
+
},
|
|
217
|
+
created_at: new Date(row.timestampIso),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
73
220
|
}
|
|
74
221
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
222
|
+
recordEvent({
|
|
223
|
+
kind: "messaging.send.reconciled",
|
|
224
|
+
payload: {
|
|
225
|
+
backfilledCount: missing.length,
|
|
226
|
+
baseline: recordedSince,
|
|
227
|
+
sample: missing.slice(0, DEFAULT_SAMPLE_LIMIT).map((m) => ({
|
|
228
|
+
external_msg_id: m.id,
|
|
229
|
+
chat_id: m.chatJid,
|
|
230
|
+
content: m.content?.slice(0, 120) ?? null,
|
|
231
|
+
sent_at: m.timestamp,
|
|
232
|
+
})),
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
log.info(
|
|
236
|
+
`[messaging-send-recording-watch] reconcile: ${missing.length} unrecorded outbound send(s) backfilled from GOWA truth`,
|
|
80
237
|
);
|
|
238
|
+
return missing.length;
|
|
81
239
|
}
|
|
82
240
|
|
|
83
241
|
pgBossHandlerRegistry.register(
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Tests for the reconcile sweep's chat-page selection.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
selectChatsToSweep,
|
|
11
|
+
type GowaChatSummary,
|
|
12
|
+
} from "./messaging-send-sweep-selection.ts";
|
|
13
|
+
|
|
14
|
+
describe("selectChatsToSweep", () => {
|
|
15
|
+
test("keeps chats at/after the horizon and lets paging stop once a page is older", () => {
|
|
16
|
+
const chats: GowaChatSummary[] = [
|
|
17
|
+
{ jid: "a@s.whatsapp.net", last_message_time: "2026-08-26T02:14:00Z" },
|
|
18
|
+
{ jid: "b@s.whatsapp.net", last_message_time: "2026-08-25T16:14:13Z" },
|
|
19
|
+
{ jid: "old@s.whatsapp.net", last_message_time: "2026-08-01T00:00:00Z" },
|
|
20
|
+
];
|
|
21
|
+
const { toSweep, mayStopPaging } = selectChatsToSweep(
|
|
22
|
+
chats,
|
|
23
|
+
Date.parse("2026-08-25T10:19:00Z"),
|
|
24
|
+
);
|
|
25
|
+
expect(toSweep).toEqual(["a@s.whatsapp.net", "b@s.whatsapp.net"]);
|
|
26
|
+
expect(mayStopPaging).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("keeps paging while the page's newest chats still qualify", () => {
|
|
30
|
+
const chats: GowaChatSummary[] = [
|
|
31
|
+
{ jid: "a@s.whatsapp.net", last_message_time: "2026-08-26T02:14:00Z" },
|
|
32
|
+
];
|
|
33
|
+
const { mayStopPaging } = selectChatsToSweep(chats, Date.parse("2026-08-25T10:19:00Z"));
|
|
34
|
+
expect(mayStopPaging).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a chat with no last_message_time never qualifies and stops paging", () => {
|
|
38
|
+
const { toSweep, mayStopPaging } = selectChatsToSweep(
|
|
39
|
+
[{ jid: "silent@s.whatsapp.net", last_message_time: null }],
|
|
40
|
+
0,
|
|
41
|
+
);
|
|
42
|
+
expect(toSweep).toEqual([]);
|
|
43
|
+
expect(mayStopPaging).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Chat-page selection for the messaging-send reconcile sweep: which paged
|
|
7
|
+
* GOWA chats (newest-first) still have activity at/after the baseline, and
|
|
8
|
+
* whether paging may stop. Pure and dependency-free, the same shape as the
|
|
9
|
+
* sibling *-logic units.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One chat from GOWA `listChats` — only the sweep-relevant fields. */
|
|
13
|
+
export interface GowaChatSummary {
|
|
14
|
+
jid: string;
|
|
15
|
+
last_message_time: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Chats worth sweeping: those with activity at/after the horizon. */
|
|
19
|
+
export function selectChatsToSweep(
|
|
20
|
+
chats: GowaChatSummary[],
|
|
21
|
+
horizonMs: number,
|
|
22
|
+
): { toSweep: string[]; mayStopPaging: boolean } {
|
|
23
|
+
const toSweep: string[] = [];
|
|
24
|
+
for (const chat of chats) {
|
|
25
|
+
const at = Date.parse(chat.last_message_time ?? "");
|
|
26
|
+
if (!Number.isNaN(at) && at >= horizonMs) toSweep.push(chat.jid);
|
|
27
|
+
}
|
|
28
|
+
const last = chats[chats.length - 1];
|
|
29
|
+
// A null/unparseable last_message_time means the chat never messaged (or
|
|
30
|
+
// GOWA answered something unexpected) — it cannot hide an outbound send, so
|
|
31
|
+
// it reads as "older than the horizon" for the paging decision.
|
|
32
|
+
const lastAt = Date.parse(last?.last_message_time ?? "");
|
|
33
|
+
const mayStopPaging = chats.length === 0 || Number.isNaN(lastAt) || lastAt < horizonMs;
|
|
34
|
+
return { toSweep, mayStopPaging };
|
|
35
|
+
}
|
|
@@ -47,7 +47,10 @@ interface GowaListDevicesResponse {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
let cachedSystemIdentity: { userId: string | undefined; organisationId: string | undefined } | undefined;
|
|
50
|
-
|
|
50
|
+
/** The system-caller identity for platform-action tool dispatches — shared by
|
|
51
|
+
* the sibling handlers that dispatch GOWA reads through the central /mcp
|
|
52
|
+
* (messaging-send-recording-watch reconcile sweep). */
|
|
53
|
+
export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
|
|
51
54
|
if (cachedSystemIdentity) return cachedSystemIdentity;
|
|
52
55
|
const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
|
|
53
56
|
cachedSystemIdentity = {
|