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
2 changes: 1 addition & 1 deletion app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function OPTIONS() {
* retained as a backward-compatible alias while consumers (chat,
* open-agents, API-key callers) migrate to `/api/chat`.
*
* Contract: https://developers.recoupable.com/api-reference/chat/workflow
* Contract: https://docs.recoupable.dev/api-reference/chat/workflow
*
* @param request - The incoming NextRequest.
* @returns A streaming Response (200) or a NextResponse error.
Expand Down
42 changes: 0 additions & 42 deletions app/api/notifications/route.ts

This file was deleted.

3 changes: 2 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Image from "next/image";
import { DOCS_BASE_URL } from "@/lib/const";

export default function Home() {
return (
Expand All @@ -24,7 +25,7 @@ export default function Home() {
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[200px]"
href="https://docs.recoupable.com"
href={DOCS_BASE_URL}
target="_blank"
rel="noopener noreferrer"
>
Expand Down
5 changes: 3 additions & 2 deletions lib/agents/__tests__/buildTaskCard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";

import { buildTaskCard } from "../buildTaskCard";
import { LinkButton } from "chat";
import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl";

vi.mock("chat", () => ({
Card: vi.fn(({ title, children }) => ({ type: "card", title, children })),
Expand All @@ -28,7 +29,7 @@ describe("buildTaskCard", () => {
buttons: [
{
type: "linkButton",
url: "https://chat.recoupable.com/tasks/run-abc-123",
url: `${getFrontendBaseUrl()}/tasks/run-abc-123`,
label: "View Task",
},
],
Expand All @@ -42,7 +43,7 @@ describe("buildTaskCard", () => {
buildTaskCard("Title", "Message", "my-run-id");

expect(LinkButton).toHaveBeenCalledWith({
url: "https://chat.recoupable.com/tasks/my-run-id",
url: `${getFrontendBaseUrl()}/tasks/my-run-id`,
label: "View Task",
});
});
Expand Down
5 changes: 2 additions & 3 deletions lib/agents/buildTaskCard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Card, CardText, Actions, LinkButton } from "chat";
import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl";

/**
* Builds a Card with a message and a View Task button.
Expand All @@ -13,9 +14,7 @@ export function buildTaskCard(title: string, message: string, runId: string) {
title,
children: [
CardText(message),
Actions([
LinkButton({ url: `https://chat.recoupable.com/tasks/${runId}`, label: "View Task" }),
]),
Actions([LinkButton({ url: `${getFrontendBaseUrl()}/tasks/${runId}`, label: "View Task" })]),
],
});
}
2 changes: 1 addition & 1 deletion lib/chat/recoupApiSkillPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@
* (recoupable/chat#1815).
*/
export const recoupApiSkillPrompt =
'If you\'re asked to do anything involving their Recoup account — artists, socials, orgs, research, tasks, chats, pulses, subscriptions, **sending an email or delivering a report**, or any other resource/action at recoup-api.vercel.app / developers.recoupable.com — load the right skill first instead of guessing or assuming you lack a tool. For live data or actions against the API (socials, posts, metrics, research, tasks, and **sending email via `POST /api/emails`** — e.g. "email X to Y", scheduled-report output) load `recoup-platform-api-access`; when `RECOUP_ORG_ID` is set in the env, scope list endpoints to that org (`/api/organizations/$RECOUP_ORG_ID/...`, `--org $RECOUP_ORG_ID`) so you get the sandbox\'s org, not every org the user belongs to. For inventory questions about this sandbox ("what artists / orgs do I have", "list my artists", "what\'s in here") load `recoup-roster-list-artists` — the `artists/{artist-slug}/RECOUP.md` tree is authoritative for this sandbox (it is already org-scoped — its repo IS the org — so artists live at the top level, not under an `orgs/` directory) and the API is not. For create-artist intents ("create artist", "onboard X", "add an artist") load `recoup-roster-add-artist`; to operate inside one artist\'s folder load `recoup-roster-manage-artist`; to scaffold the folder tree load `recoup-platform-build-workspace`. Treat ambiguous account-data questions as Recoup questions by default, not repo-level TODOs.';
'If you\'re asked to do anything involving their Recoup account — artists, socials, orgs, research, tasks, chats, pulses, subscriptions, **sending an email or delivering a report**, or any other resource/action at recoup-api.vercel.app / docs.recoupable.dev — load the right skill first instead of guessing or assuming you lack a tool. For live data or actions against the API (socials, posts, metrics, research, tasks, and **sending email via `POST /api/emails`** — e.g. "email X to Y", scheduled-report output) load `recoup-platform-api-access`; when `RECOUP_ORG_ID` is set in the env, scope list endpoints to that org (`/api/organizations/$RECOUP_ORG_ID/...`, `--org $RECOUP_ORG_ID`) so you get the sandbox\'s org, not every org the user belongs to. For inventory questions about this sandbox ("what artists / orgs do I have", "list my artists", "what\'s in here") load `recoup-roster-list-artists` — the `artists/{artist-slug}/RECOUP.md` tree is authoritative for this sandbox (it is already org-scoped — its repo IS the org — so artists live at the top level, not under an `orgs/` directory) and the API is not. For create-artist intents ("create artist", "onboard X", "add an artist") load `recoup-roster-add-artist`; to operate inside one artist\'s folder load `recoup-roster-manage-artist`; to scaffold the folder tree load `recoup-platform-build-workspace`. Treat ambiguous account-data questions as Recoup questions by default, not repo-level TODOs.';
3 changes: 2 additions & 1 deletion lib/coding-agent/handleGitHubWebhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { extractPRComment } from "./extractPRComment";
import { getCodingAgentPRState, setCodingAgentPRState } from "./prState";
import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR";
import { postGitHubComment } from "./postGitHubComment";
import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl";

const BOT_MENTION = "@recoup-coding-agent";

Expand Down Expand Up @@ -97,7 +98,7 @@ export async function handleGitHubWebhook(request: Request): Promise<NextRespons
await postGitHubComment(
fullRepo,
thread.prNumber,
`Got your feedback. Updating the PRs...\n\n[View Task](https://chat.recoupable.com/tasks/${handle.id})`,
`Got your feedback. Updating the PRs...\n\n[View Task](${getFrontendBaseUrl()}/tasks/${handle.id})`,
);

return NextResponse.json({ status: "update_triggered" }, { headers: getCorsHeaders() });
Expand Down
2 changes: 1 addition & 1 deletion lib/composio/getFrontendBaseUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const TEST_FRONTEND_URL = "https://test-recoup-chat.vercel.app";
*/
export function getFrontendBaseUrl(): string {
if (process.env.VERCEL_ENV === "production") {
return "https://chat.recoupable.com";
return "https://chat.recoupable.dev";
}

if (process.env.VERCEL_GIT_COMMIT_REF === "test") {
Expand Down
3 changes: 3 additions & 0 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export const IMAGE_GENERATE_PRICE = "0.15";
export const DEFAULT_MODEL = "openai/gpt-5.4-nano";
export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini";
export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET;
/** Base URL for the public API documentation site */
export const DOCS_BASE_URL = "https://docs.recoupable.dev";

/** Domain for receiving inbound emails (e.g., support@mail.recoupable.com) */
export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com";

Expand Down
5 changes: 3 additions & 2 deletions lib/emails/__tests__/getEmailFooter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { getEmailFooter } from "../getEmailFooter";
import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl";

describe("getEmailFooter", () => {
it("includes reply note in all cases", () => {
Expand All @@ -21,14 +22,14 @@ describe("getEmailFooter", () => {
it("includes chat link when roomId is provided", () => {
const roomId = "test-room-123";
const footer = getEmailFooter(roomId);
expect(footer).toContain(`https://chat.recoupable.com/chat/${roomId}`);
expect(footer).toContain(`${getFrontendBaseUrl()}/chat/${roomId}`);
expect(footer).toContain("Or continue the conversation on Recoup");
});

it("generates proper HTML with roomId", () => {
const roomId = "my-room-id";
const footer = getEmailFooter(roomId);
expect(footer).toContain(`href="https://chat.recoupable.com/chat/${roomId}"`);
expect(footer).toContain(`href="${getFrontendBaseUrl()}/chat/${roomId}"`);
expect(footer).toContain('target="_blank"');
expect(footer).toContain('rel="noopener noreferrer"');
});
Expand Down
7 changes: 5 additions & 2 deletions lib/emails/getEmailFooter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl";

/**
* Generates a standardized email footer HTML.
*
Expand All @@ -6,6 +8,7 @@
* @returns HTML string for the email footer.
*/
export function getEmailFooter(roomId?: string, artistName?: string): string {
const chatUrl = roomId ? `${getFrontendBaseUrl()}/chat/${roomId}` : "";
const artistLine = artistName
? `
<p style="font-size:12px;color:#6b7280;margin:0 0 4px;">
Expand All @@ -22,8 +25,8 @@ export function getEmailFooter(roomId?: string, artistName?: string): string {
? `
<p style="font-size:12px;color:#6b7280;margin:0;">
Or continue the conversation on Recoup:
<a href="https://chat.recoupable.com/chat/${roomId}" target="_blank" rel="noopener noreferrer">
https://chat.recoupable.com/chat/${roomId}
<a href="${chatUrl}" target="_blank" rel="noopener noreferrer">
${chatUrl}
</a>
</p>`.trim()
: "";
Expand Down
16 changes: 16 additions & 0 deletions lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ describe("extractRoomIdFromHtml", () => {
});
});

describe(".dev domain (post-migration links)", () => {
it("extracts roomId from a chat.recoupable.dev link", () => {
const html = `
<html>
<body>
<p>Continue the conversation: <a href="https://chat.recoupable.dev/chat/b2c3d4e5-f6a7-8901-bcde-f23456789012">https://chat.recoupable.dev/chat/b2c3d4e5-f6a7-8901-bcde-f23456789012</a></p>
</body>
</html>
`;

const result = extractRoomIdFromHtml(html);

expect(result).toBe("b2c3d4e5-f6a7-8901-bcde-f23456789012");
});
});

describe("Gmail reply with proper threading", () => {
it("extracts roomId from Gmail reply with quoted content", () => {
const html = `
Expand Down
9 changes: 9 additions & 0 deletions lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ describe("extractRoomIdFromText", () => {
expect(result).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
});

it("extracts roomId from a chat.recoupable.dev link (post-migration)", () => {
const text =
"Check out this chat: https://chat.recoupable.dev/chat/b2c3d4e5-f6a7-8901-bcde-f23456789012";

const result = extractRoomIdFromText(text);

expect(result).toBe("b2c3d4e5-f6a7-8901-bcde-f23456789012");
});

it("handles case-insensitive domain matching", () => {
const text = "Visit HTTPS://CHAT.RECOUPABLE.COM/CHAT/12345678-1234-1234-1234-123456789abc";

Expand Down
30 changes: 18 additions & 12 deletions lib/emails/inbound/extractRoomIdFromHtml.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";

// Matches chat.recoupable.com/chat/{uuid} in various formats:
// - Direct URL: https://chat.recoupable.com/chat/uuid
// - URL-encoded (in tracking redirects): chat.recoupable.com%2Fchat%2Fuuid
// Matches chat.recoupable.com or chat.recoupable.dev /chat/{uuid} in various formats.
// Both hosts are recognized so legacy .com links still in flight resolve alongside
// post-migration .dev links:
// - Direct URL: https://chat.recoupable.dev/chat/uuid
// - URL-encoded (in tracking redirects): chat.recoupable.dev%2Fchat%2Fuuid
const CHAT_LINK_PATTERNS = [
new RegExp(`https?://chat\\.recoupable\\.com/chat/(${UUID_PATTERN})`, "i"),
new RegExp(`chat\\.recoupable\\.com%2Fchat%2F(${UUID_PATTERN})`, "i"),
new RegExp(`https?://chat\\.recoupable\\.(com|dev)/chat/(${UUID_PATTERN})`, "i"),
new RegExp(`chat\\.recoupable\\.(com|dev)%2Fchat%2F(${UUID_PATTERN})`, "i"),
];

// Pattern to find UUID after /chat/ or %2Fchat%2F in link text that may contain <wbr /> tags
// The link text version: "https://<wbr />/<wbr />chat.<wbr />recoupable.<wbr />com/<wbr />chat/<wbr />uuid"
const WBR_STRIPPED_PATTERN = new RegExp(`chat\\.recoupable\\.com/chat/(${UUID_PATTERN})`, "i");
// The link text version: "https://<wbr />/<wbr />chat.<wbr />recoupable.<wbr />dev/<wbr />chat/<wbr />uuid"
const WBR_STRIPPED_PATTERN = new RegExp(
`chat\\.recoupable\\.(com|dev)/chat/(${UUID_PATTERN})`,
"i",
);

/**
* Extracts the roomId from email HTML by looking for a Recoup chat link.
Expand All @@ -25,20 +30,21 @@ const WBR_STRIPPED_PATTERN = new RegExp(`chat\\.recoupable\\.com/chat/(${UUID_PA
export function extractRoomIdFromHtml(html: string | undefined): string | undefined {
if (!html) return undefined;

// Try direct URL patterns first (most common case)
// Try direct URL patterns first (most common case).
// Group 1 is the host suffix (com|dev); group 2 is the UUID.
for (const pattern of CHAT_LINK_PATTERNS) {
const match = html.match(pattern);
if (match?.[1]) {
return match[1];
if (match?.[2]) {
return match[2];
}
}

// Fallback: strip <wbr /> tags and try again
// This handles Superhuman's link text formatting: "https:/<wbr />/<wbr />chat.<wbr />..."
const strippedHtml = html.replace(/<wbr\s*\/?>/gi, "");
const strippedMatch = strippedHtml.match(WBR_STRIPPED_PATTERN);
if (strippedMatch?.[1]) {
return strippedMatch[1];
if (strippedMatch?.[2]) {
return strippedMatch[2];
}

return undefined;
Expand Down
4 changes: 3 additions & 1 deletion lib/emails/inbound/extractRoomIdFromText.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const CHAT_LINK_REGEX = /https:\/\/chat\.recoupable\.com\/chat\/([0-9a-f-]{36})/i;
// Recognizes both the legacy .com host (links still in flight) and the
// post-migration .dev host. (?:com|dev) is non-capturing so the UUID stays group 1.
const CHAT_LINK_REGEX = /https:\/\/chat\.recoupable\.(?:com|dev)\/chat\/([0-9a-f-]{36})/i;

/**
* Extracts the roomId from the email text body by looking for a Recoup chat link.
Expand Down
2 changes: 1 addition & 1 deletion lib/emails/processAndSendEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export type ProcessAndSendEmailResult = ProcessAndSendEmailSuccess | ProcessAndS

/**
* Shared email processing and sending logic used by both the
* POST /api/notifications handler and the send_email MCP tool.
* POST /api/emails handler and the send_email MCP tool.
*
* Handles room lookup, footer generation, markdown-to-HTML conversion,
* and the Resend API call.
Expand Down
Loading
Loading