Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions __tests__/keyword-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
80 changes: 3 additions & 77 deletions app/api/cron/attach-next-reel/route.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -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,
});
}
92 changes: 92 additions & 0 deletions lib/automation/attach-next-reel.ts
Original file line number Diff line number Diff line change
@@ -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<AttachNextReelResult> {
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 };
}
39 changes: 36 additions & 3 deletions lib/utils/keyword-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
"\\$&"
);
Expand All @@ -181,11 +214,11 @@ export function matchKeywords(
`(?<![\\p{L}\\p{N}])${escapedKeyword}(?![\\p{L}\\p{N}])`,
"iu"
);
if (regex.test(cleanedText)) {
if (regex.test(compareText)) {
return { matched: true, matchedKeyword: keyword };
}
} else {
if (cleanedText.includes(cleanedKeyword)) {
if (compareText.includes(compareKeyword)) {
return { matched: true, matchedKeyword: keyword };
}
}
Expand Down
5 changes: 5 additions & 0 deletions worker/dm-worker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createDMWorker } from "@/lib/queue/dm-worker";
import { recordWorkerHeartbeat } from "@/lib/ops/worker-health";
import { reconcileComments } from "@/lib/polling/comment-reconciler";
import { attachPendingNextReels } from "@/lib/automation/attach-next-reel";
import os from "node:os";

const worker = createDMWorker();
Expand Down Expand Up @@ -32,6 +33,10 @@ const heartbeatTimer = setInterval(() => 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";
Expand Down
Loading