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
146 changes: 146 additions & 0 deletions __tests__/conversation-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getConversations, MetaApiError } from "@/lib/meta/client";

const token = "test-token-not-for-logging";
const expanded = "participants,updated_time,messages.limit(1){message,from,created_time}";
const ok = (body: unknown) => new Response(JSON.stringify(body), {
status: 200, headers: { "Content-Type": "application/json" },
});
const failure = (code = 1) => new Response(JSON.stringify({
error: { code, type: "OAuthException", message: "An unknown error has occurred.", fbtrace_id: "test-trace" },
}), { status: 400 });

/** Simulate the reported bad 42nd entry, including an unrelated message cursor. */
function installGraph(broken: number[] = [], total = 55, pageCap = 50) {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input));
expect(url.origin).toBe("https://graph.instagram.com");
expect(url.pathname).toMatch(/\/owner\/conversations$/);
expect(url.searchParams.has("access_token")).toBe(false);
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer " + token);
const cursor = url.searchParams.get("after");
expect(cursor).not.toBe("message-cursor");
const start = cursor ? Number(cursor.replace("cursor-", "")) : 0;
const count = Math.min(Number(url.searchParams.get("limit")), pageCap);
const end = Math.min(total, start + count);
const detailed = url.searchParams.get("fields") === expanded;
if (detailed && broken.some((index) => index >= start && index < end)) return failure();
const data = Array.from({ length: end - start }, (_, offset) => {
const index = start + offset;
return {
id: "conversation-" + index,
updated_time: "2026-09-14T12:00:00+0000",
...(detailed ? {
participants: { data: [{ id: "owner" }, { id: "contact-" + index }] },
messages: {
data: [{ id: "message-" + index, message: "preview", from: { id: "contact-" + index } }],
paging: { cursors: { after: "message-cursor" } },
},
} : {}),
};
});
return ok({
data,
...(end < total ? { paging: {
// Must not be followed; only the outer cursor is used.
next: "https://untrusted.example/never-fetch",
cursors: { after: "cursor-" + end },
} } : {}),
});
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe("Instagram conversation recovery (upstream #60)", () => {
it("keeps the healthy first 50 conversations on the one-request fast path", async () => {
const fetchMock = installGraph();
const result = await getConversations(token, "owner");
expect(result).toHaveLength(50);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.every((row) => !row.detailsUnavailable && row.participants)).toBe(true);
});

it("isolates the 42nd conversation and still includes entries 43 through 50", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = installGraph([41]);
const result = await getConversations(token, "owner");
expect(result.map((row) => row.id)).toEqual(
Array.from({ length: 50 }, (_, i) => "conversation-" + i)
);
expect(result[41]).toEqual({
id: "conversation-41", updated_time: "2026-09-14T12:00:00+0000", detailsUnavailable: true,
});
expect(result[42].participants).toBeDefined();
expect(result[49].messages).toBeDefined();
expect(fetchMock.mock.calls.length).toBeLessThan(30);
expect(warn).toHaveBeenCalledTimes(1);
expect(JSON.stringify(warn.mock.calls)).not.toContain(token);
expect(JSON.stringify(warn.mock.calls)).toContain("test-trace");
});

it("handles consecutive unavailable conversations without dropping or duplicating them", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
installGraph([0, 1, 2, 41, 42, 49]);
const result = await getConversations(token, "owner");
expect(result).toHaveLength(50);
expect(new Set(result.map((row) => row.id)).size).toBe(50);
expect(result.filter((row) => row.detailsUnavailable).map((row) => row.id))
.toEqual([0, 1, 2, 41, 42, 49].map((i) => "conversation-" + i));
});

it("follows short pages using the outer cursor and stops when exhausted", async () => {
const fetchMock = installGraph([], 13, 5);
const result = await getConversations(token, "owner");
expect(result).toHaveLength(13);
expect(fetchMock).toHaveBeenCalledTimes(3);
});

it("returns an actually empty inbox without manufacturing placeholders", async () => {
installGraph([], 0);
expect(await getConversations(token, "owner")).toEqual([]);
});

it.each([190, 10, 100, 200, 4, 17, 368])("propagates Meta code %s without fallback", async (code) => {
const fetchMock = vi.fn(async () => failure(code));
vi.stubGlobal("fetch", fetchMock);
await expect(getConversations(token, "owner")).rejects.toBeInstanceOf(MetaApiError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("does not disguise network failures as a partial inbox", async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError("Network unavailable"));
vi.stubGlobal("fetch", fetchMock);
await expect(getConversations(token, "owner")).rejects.toThrow("Network unavailable");
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("propagates failure of the minimal metadata request", async () => {
const fetchMock = vi.fn(async () => failure());
vi.stubGlobal("fetch", fetchMock);
await expect(getConversations(token, "owner")).rejects.toBeInstanceOf(MetaApiError);
expect(fetchMock.mock.calls.length).toBeLessThan(10);
});

it("rejects repeating outer cursors instead of looping or returning a misleading partial list", async () => {
const fetchMock = vi.fn(async () => ok({
data: [{ id: "same" }],
paging: { next: "https://untrusted.example", cursors: { after: "repeat" } },
}));
vi.stubGlobal("fetch", fetchMock);
await expect(getConversations(token, "owner")).rejects.toThrow("pagination did not advance");
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("rejects a next page without an outer cursor", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ok({
data: [{ id: "first" }], paging: { next: "https://untrusted.example" },
})));
await expect(getConversations(token, "owner")).rejects.toThrow("pagination did not advance");
});
});
25 changes: 18 additions & 7 deletions app/(dashboard)/inbox/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export default function InboxPage() {
const [sendError, setSendError] = useState<string | null>(null);

const scrollRef = useRef<HTMLDivElement>(null);
const conversationRequests = useRef(new Set<string>());

const active = conversations.find((c) => c.id === activeId) ?? null;

Expand Down Expand Up @@ -90,7 +91,8 @@ export default function InboxPage() {

const loadConversations = useCallback(
async (silent: boolean) => {
if (!selectedAccountId) return;
if (!selectedAccountId || conversationRequests.current.has(selectedAccountId)) return;
conversationRequests.current.add(selectedAccountId);
if (!silent) setConvLoading(true);
try {
const res = await fetch(
Expand All @@ -108,6 +110,7 @@ export default function InboxPage() {
} catch {
if (!silent) setConvError("Failed to load conversations");
} finally {
conversationRequests.current.delete(selectedAccountId);
if (!silent) setConvLoading(false);
}
},
Expand Down Expand Up @@ -165,7 +168,7 @@ export default function InboxPage() {
// Load + poll the open thread. Cached messages render instantly while a fresh
// copy loads silently; opening a thread never shows a blank pane on revisit.
useEffect(() => {
if (!activeId) return;
if (!activeId || active?.detailsUnavailable) return;
const cached = readCache<ThreadMessage[]>(
msgCacheKey(activeId),
CACHE_MAX_AGE_MS
Expand All @@ -185,7 +188,7 @@ export default function InboxPage() {
POLL_MS
);
return () => window.clearInterval(timer);
}, [activeId, loadMessages]);
}, [activeId, active?.detailsUnavailable, loadMessages]);

// Keep the thread pinned to the latest message.
useEffect(() => {
Expand Down Expand Up @@ -302,12 +305,15 @@ export default function InboxPage() {
>
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium text-foreground">
@{c.contact.username ?? "unknown"}
{c.detailsUnavailable ? "Details unavailable" : `@${c.contact.username ?? "unknown"}`}
</span>
<span className="shrink-0 text-[11px] text-zinc-500">
{formatTime(c.updatedTime)}
</span>
</div>
{c.detailsUnavailable && (
<p className="mt-0.5 text-xs text-muted">Instagram could not load this conversation.</p>
)}
{c.lastMessage && (
<p className="mt-0.5 truncate text-xs text-muted">
{c.lastMessage.fromMe ? "You: " : ""}
Expand Down Expand Up @@ -342,12 +348,16 @@ export default function InboxPage() {
Back
</button>
<span className="truncate">
@{active.contact.username ?? "unknown"}
{active.detailsUnavailable ? "Details unavailable" : `@${active.contact.username ?? "unknown"}`}
</span>
</div>

<div ref={scrollRef} className="min-h-0 flex-1 space-y-2 overflow-y-auto p-4">
{threadLoading && messages.length === 0 ? (
{active.detailsUnavailable ? (
<p role="status" className="text-sm text-muted">
Instagram could not load the details of this conversation. Other conversations are still available. You can check this chat in Instagram.
</p>
) : threadLoading && messages.length === 0 ? (
<p className="text-sm text-muted">Loading…</p>
) : messages.length === 0 ? (
<p className="text-sm text-muted">No messages.</p>
Expand Down Expand Up @@ -384,6 +394,7 @@ export default function InboxPage() {
)}
<div className="flex items-end gap-2">
<textarea
disabled={active.detailsUnavailable || !active.contact.id}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
Expand All @@ -394,7 +405,7 @@ export default function InboxPage() {
<button
type="button"
onClick={() => void handleSend()}
disabled={sending || !draft.trim()}
disabled={sending || !draft.trim() || !active.contact.id || active.detailsUnavailable}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50"
>
{sending ? "Sending…" : "Send"}
Expand Down
6 changes: 3 additions & 3 deletions app/api/instagram/conversations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createInstagramContext } from "@/lib/instagram/provider";

export interface ConversationListItem {
id: string;
detailsUnavailable?: boolean;
contact: { id: string; username: string | null };
updatedTime: string | null;
lastMessage: {
Expand Down Expand Up @@ -55,13 +56,12 @@ export async function GET(request: NextRequest) {
const conversations: ConversationListItem[] = raw.map((c) => {
const participants = c.participants?.data ?? [];
const contact =
participants.find((p) => p.id !== account.instagramId) ??
participants[0] ??
null;
participants.find((p) => p.id !== account.instagramId) ?? null;
const last = c.messages?.data?.[0] ?? null;

return {
id: c.id,
detailsUnavailable: c.detailsUnavailable,
contact: {
id: contact?.id ?? "",
username: contact?.username ?? null,
Expand Down
70 changes: 70 additions & 0 deletions docs/conversation-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Conversation detail failures (issue #60)

Issue: https://github.com/diwenne/openreply/issues/60

## Observed behavior

On the reporter's account, the expanded Instagram Login conversations request
succeeded with limit=41 and failed with limit=42 or 50. Using the outer cursor
after those 41 entries with limit=1:

| Fields | Result |
| --- | --- |
| id | Success |
| id,updated_time | Success |
| id,updated_time,participants | Meta OAuthException code 1 |
| id,messages.limit(1){message,from,created_time} | Meta OAuthException code 1 |

The reporter reproduced this in Postman with a newly issued token on v25. The
original inbox error also occurred on v26. The affected entries were identified
in the Instagram app as conversations with AIs from Instagram AI Studio. This
is an account-specific observation, not a claim about every AI Studio chat or
a universal 41-conversation limit. Meta's underlying reason is unknown.

## Recovery

Healthy accounts retain the normal expanded request. Only code-1 failures
reduce the page size, until an affected entry is isolated. That entry is read
with id,updated_time, marked detailsUnavailable and retained in the list.
Traversal continues using the OUTER conversation cursor; healthy pages grow
again. The existing scope remains up to 50 unique recent conversations.

The UI explains unavailable details and disables replies without a recipient.
An owner-only conversation no longer falls back to the owner as its recipient.
No participant or recipient is inferred from an unavailable entry.

Auth, permission, rate-limit, network and minimal-metadata errors propagate.
Requests have a 10-second timeout. Repeated cursors and excessive traversal
fail explicitly instead of silently returning a misleading partial list.
Recovery performs additional reads, so affected inboxes can load more slowly.
Only one conversation-list request per account runs in the same mounted inbox.
This is not a global cache or a guarantee against account-level rate limits.
The next refresh retries unavailable details, allowing transient failures to
recover. Warning logs contain only code/subcode/trace, not credentials or chats.

## Verification

The identical application code and regression tests passed TypeScript, lint,
all 247 tests (including 16 recovery tests), and the production build on the
reporter's test branch:
https://github.com/TylonHH/openreply/actions/runs/34864292076

The reporter deployed that build and confirmed the previously affected inbox
loads again. Runtime logs changed from whole-list errors to isolated detail
warnings. AI Studio chats were identified by checking Instagram itself. An
owner-only conversation has a disabled composer, accepted by the reporter.
No outbound message test was needed for this read-path change.

The clean upstream branch excludes fork deployment and test-image workflows.
No local test runtime was available while preparing it; the application/test
files were checked against the already validated versions. Upstream CI can
rerun the normal gates for the submitted commit.

Manual regression checks:

1. Refresh the affected inbox in a fresh browser session.
2. Confirm unavailable entries are explicitly labelled.
3. Check healthy entries following them remain present within the 50-entry scope.
4. Open healthy entries and inspect their messages.
5. Open an unavailable entry and verify its explanation and disabled composer.
6. Verify a healthy second account and repeated refreshes.
Loading