diff --git a/__tests__/keyword-matcher.test.ts b/__tests__/keyword-matcher.test.ts index 453c91a37..06c954b79 100644 --- a/__tests__/keyword-matcher.test.ts +++ b/__tests__/keyword-matcher.test.ts @@ -148,6 +148,43 @@ describe("matchKeywords — partial matching", () => { }); }); +describe("matchKeywords — numeric keyword / letter-O homoglyph", () => { + // Production bug: a "08" campaign never fired for a real commenter who + // typed "O8" (capital letter O, not the digit zero) — visually identical + // on most fonts, and mobile autocapitalize compounds it on a leading "o". + // matchKeywords silently returned unmatched, indistinguishable from any + // other non-matching comment, so nothing surfaced until someone reported + // "commented and got no reply". + it("matches a numeric keyword when the comment uses letter O for zero", () => { + expect(matchKeywords("O8", ["08"], true).matched).toBe(true); + expect(matchKeywords("o8", ["08"], true).matched).toBe(true); + }); + + it("matches the digit form as before", () => { + expect(matchKeywords("08", ["08"], true).matched).toBe(true); + }); + + it("matches the O-typo embedded in a sentence", () => { + expect(matchKeywords("comentei O8 aqui", ["08"], true).matched).toBe(true); + }); + + it("returns the original keyword, not the folded comparison string", () => { + const result = matchKeywords("O8", ["08"], true); + expect(result.matchedKeyword).toBe("08"); + }); + + it("does not fold O/0 for a non-numeric (word) keyword", () => { + // "gordo" contains "o", but the keyword "adoro" is a word, not a number — + // the fold must never apply here. + expect(matchKeywords("eu gordo", ["adoro"], true).matched).toBe(false); + }); + + it("still requires a whole-word numeric match, not a substring", () => { + // "108" must not match keyword "08" just because folding lines up digits. + expect(matchKeywords("108", ["08"], true).matched).toBe(false); + }); +}); + describe("matchKeywords — edge cases", () => { it("should return false for empty comment text", () => { const result = matchKeywords("", ["link"], true); diff --git a/app/api/cron/attach-next-reel/route.ts b/app/api/cron/attach-next-reel/route.ts index 2bd0c3d62..84505fdbd 100644 --- a/app/api/cron/attach-next-reel/route.ts +++ b/app/api/cron/attach-next-reel/route.ts @@ -1,10 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db/client"; -import { getUserMedia, type InstagramMedia } from "@/lib/instagram/provider"; -import { - createInstagramContext, - hasInstagramCredentials, -} from "@/lib/instagram/provider"; +import { attachPendingNextReels } from "@/lib/automation/attach-next-reel"; /** * Binds "next reel" campaigns to a real post. @@ -16,10 +11,6 @@ import { * cron interval of the reel being posted. */ -function isReel(media: InstagramMedia): boolean { - return media.media_product_type === "REELS"; -} - export async function GET(request: NextRequest) { const authHeader = request.headers.get("authorization"); const cronSecret = process.env.CRON_SECRET || process.env.NEXTAUTH_SECRET; @@ -31,75 +22,10 @@ export async function GET(request: NextRequest) { ); } - const pending = await prisma.automation.findMany({ - where: { pendingNextReel: true }, - include: { instagramAccount: true }, - }); - - // Group by connected account so we fetch each account's media only once. - const byAccount = new Map< - string, - { - account: (typeof pending)[number]["instagramAccount"]; - automations: typeof pending; - } - >(); - for (const automation of pending) { - const key = automation.instagramAccountId; - const entry = byAccount.get(key); - if (entry) entry.automations.push(automation); - else - byAccount.set(key, { - account: automation.instagramAccount, - automations: [automation], - }); - } - - let bound = 0; - let checked = 0; - const failures: string[] = []; - - for (const { account, automations } of byAccount.values()) { - checked += automations.length; - if (!account || !hasInstagramCredentials(account)) continue; - - let reels: InstagramMedia[]; - try { - const token = await createInstagramContext(account); - const media = await getUserMedia({ context: token, limit: 25 }); - reels = media - .filter(isReel) - .sort( - (a, b) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - } catch (err) { - failures.push(account.id); - console.error("[attach-next-reel] media fetch failed", account.id, err); - continue; - } - - for (const automation of automations) { - // The "next" reel = the earliest one posted after the campaign was created. - const nextReel = reels.find( - (reel) => new Date(reel.timestamp) > automation.createdAt - ); - if (!nextReel) continue; - - await prisma.automation.update({ - where: { id: automation.id }, - data: { - postId: nextReel.id, - postUrl: nextReel.permalink ?? null, - pendingNextReel: false, - }, - }); - bound += 1; - } - } + const result = await attachPendingNextReels(); return NextResponse.json({ success: true, - data: { checked, bound, failedAccounts: failures.length }, + data: result, }); } diff --git a/lib/automation/attach-next-reel.ts b/lib/automation/attach-next-reel.ts new file mode 100644 index 000000000..44b3b65f3 --- /dev/null +++ b/lib/automation/attach-next-reel.ts @@ -0,0 +1,92 @@ +import { prisma } from "@/lib/db/client"; +import { + createInstagramContext, + hasInstagramCredentials, + getUserMedia, + type InstagramMedia, +} from "@/lib/instagram/provider"; + +function isReel(media: InstagramMedia): boolean { + return media.media_product_type === "REELS"; +} + +export type AttachNextReelResult = { + checked: number; + bound: number; + failedAccounts: number; +}; + +/** + * Bind each pending "next reel" campaign to the earliest reel published after + * it was created. Kept outside the HTTP route so the long-running worker can + * run the same check on every comment-poll interval. + */ +export async function attachPendingNextReels(): Promise { + const pending = await prisma.automation.findMany({ + where: { pendingNextReel: true }, + include: { instagramAccount: true }, + }); + + // Group by connected account so we fetch each account's media only once. + const byAccount = new Map< + string, + { + account: (typeof pending)[number]["instagramAccount"]; + automations: typeof pending; + } + >(); + for (const automation of pending) { + const key = automation.instagramAccountId; + const entry = byAccount.get(key); + if (entry) entry.automations.push(automation); + else + byAccount.set(key, { + account: automation.instagramAccount, + automations: [automation], + }); + } + + let checked = 0; + let bound = 0; + const failures: string[] = []; + + for (const { account, automations } of byAccount.values()) { + checked += automations.length; + if (!account || !hasInstagramCredentials(account)) continue; + + let reels: InstagramMedia[]; + try { + const context = await createInstagramContext(account); + const media = await getUserMedia({ context, limit: 25 }); + reels = media + .filter(isReel) + .sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + } catch (error) { + failures.push(account.id); + console.error("[attach-next-reel] media fetch failed", account.id, error); + continue; + } + + for (const automation of automations) { + // The "next" reel = the earliest one posted after the campaign was created. + const nextReel = reels.find( + (reel) => new Date(reel.timestamp) > automation.createdAt + ); + if (!nextReel) continue; + + await prisma.automation.update({ + where: { id: automation.id }, + data: { + postId: nextReel.id, + postUrl: nextReel.permalink ?? null, + pendingNextReel: false, + }, + }); + bound += 1; + } + } + + return { checked, bound, failedAccounts: failures.length }; +} diff --git a/lib/utils/keyword-matcher.ts b/lib/utils/keyword-matcher.ts index b7bca1f1e..59133fddf 100644 --- a/lib/utils/keyword-matcher.ts +++ b/lib/utils/keyword-matcher.ts @@ -76,6 +76,29 @@ export function normalizeArabicScript(text: string): string { }); } +/** + * A trigger keyword that is purely digits (optionally mixed with the letter + * "o"/"O", the character it gets confused with) — e.g. "08", "17". + */ +function isNumericLikeKeyword(cleanedKeyword: string): boolean { + return /^[0-9oO]+$/.test(cleanedKeyword); +} + +/** + * Fold the letter O into the digit 0. Commenters routinely type "O8" for a + * "08" trigger word — same glyph on most fonts, and mobile autocapitalize + * turns a leading "o" into "O" on top of that. Confirmed in production: a + * numeric campaign keyword silently dropped every comment spelled with the + * letter instead of the digit, with no error anywhere (matchKeywords just + * returns unmatched, same as any other non-matching comment). + * + * Scoped to numeric-like keywords only (see isNumericLikeKeyword) so a real + * word keyword ("love", "more info") is never affected by this fold. + */ +function foldNumericHomoglyphs(value: string): string { + return value.replace(/o/gi, "0"); +} + /** * Strip emojis and special characters from text, keeping only * letters (any script), numbers, and whitespace. @@ -169,8 +192,18 @@ export function matchKeywords( if (!cleanedKeyword) continue; + // Only numeric-like keywords get the O/0 fold — a word keyword compares + // exactly as before. + const numericLike = isNumericLikeKeyword(cleanedKeyword); + const compareText = numericLike + ? foldNumericHomoglyphs(cleanedText) + : cleanedText; + const compareKeyword = numericLike + ? foldNumericHomoglyphs(cleanedKeyword) + : cleanedKeyword; + if (wholeWordMatch) { - const escapedKeyword = cleanedKeyword.replace( + const escapedKeyword = compareKeyword.replace( /[.*+?^${}()|[\]\\]/g, "\\$&" ); @@ -181,11 +214,11 @@ export function matchKeywords( `(? void heartbeat(), HEARTBEAT_INTERVAL_MS async function poll() { try { + const attached = await attachPendingNextReels(); + if (attached.bound > 0 || attached.failedAccounts > 0) { + console.log("[DM Worker] Next-reel attachment:", attached); + } await reconcileComments(); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error";