mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
merge: reconcile published native prerequisite and current master
Preserve canonical startup ownership, scoped evidence, current Storybook IDs, and the Codex form controls while carrying Claude effort and immutable source bindings. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
48ffca9438
415 files changed
+22501
-15340
No files matched your search
@@ -301,6 +301,39 @@ describeEmbeddedPostgres("reportRunFailure", () => {
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ agentAdapter: "unknown" }));
|
||||
});
|
||||
|
||||
function providerQuotaRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
|
||||
return buildRun({
|
||||
error: "ACP agent reported a terminal limit failure.\nYou've hit your session limit · resets 2am (UTC)",
|
||||
errorCode: "provider_quota", executionStage: "dispatching", exitCode: 1, signal: null,
|
||||
resultJson: { errorFamily: "provider_quota", stopReason: "adapter_failed" }, ...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["claude_local", "codex_local"])("keeps a classified %s provider quota wait local", async (adapterType) => {
|
||||
await seedCompanyAndAgent({ adapterType });
|
||||
const [run] = await db.insert(heartbeatRuns).values(providerQuotaRun()).returning();
|
||||
const before = structuredClone(run);
|
||||
for (const phase of [undefined, "execute"] as const) await reportRunFailure(db, run!, { phase });
|
||||
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(heartbeatRuns)).toEqual([before]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "timed_out" }, { status: "cancelled", startedAt: new Date(0) }, { signal: "SIGTERM" },
|
||||
{ errorCode: "adapter_failed" }, { errorCode: "claude_transient_upstream" },
|
||||
{ resultJson: {} }, { resultJson: null }, { resultJson: { errorFamily: "transient_upstream" } },
|
||||
])("reports a provider quota outcome without matching adapter evidence: %j", async (overrides) => {
|
||||
await seedCompanyAndAgent({ adapterType: "claude_local" });
|
||||
await reportRunFailure(db, providerQuotaRun(overrides), { phase: "execute" });
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a provider quota code reportable during setup", async () => {
|
||||
await seedCompanyAndAgent({ adapterType: "claude_local" });
|
||||
await reportRunFailure(db, providerQuotaRun(), { phase: "setup" });
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
function aiSelectionRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
|
||||
return buildRun({
|
||||
errorCode: "configuration_incomplete",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
captureAdapterStopOwnership,
|
||||
createAdapterExecutionControl,
|
||||
registerAdapterExecutionControl,
|
||||
reportAdapterStopIfUnsettled,
|
||||
waitForAdapterStop,
|
||||
} from "./adapter-execution-control.js";
|
||||
|
||||
@@ -204,3 +205,72 @@ it.each(["replaced", "wrong_run", "wrong_promise", "throwing_reader"])("omits pe
|
||||
other.finish();
|
||||
adapterExecutionControls.delete(runId);
|
||||
});
|
||||
|
||||
it("reports a Stop once, with its pending phase, only when it stays unsettled", async () => {
|
||||
vi.useFakeTimers();
|
||||
const runId = "11111111-1111-4111-8111-111111111111";
|
||||
const control = createAdapterExecutionControl();
|
||||
await registerAdapterExecutionControl(runId, control);
|
||||
const report = vi.fn();
|
||||
try {
|
||||
control.phases.enter("workspace_restore");
|
||||
reportAdapterStopIfUnsettled(control.settled, report, { runId, adapterType: "codex_local" }, control, { reportAfterMs: 5000 });
|
||||
// A repeated Stop for the same execution must not add another report.
|
||||
reportAdapterStopIfUnsettled(control.settled, report, { runId }, control, { reportAfterMs: 5000 });
|
||||
await vi.advanceTimersByTimeAsync(4999);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(report).toHaveBeenCalledOnce();
|
||||
const error = report.mock.calls[0]![0] as AdapterStopTimeoutError;
|
||||
expect(error).toBeInstanceOf(AdapterStopTimeoutError);
|
||||
expect(error.diagnostics).toMatchObject({
|
||||
runId, adapterType: "codex_local", timeoutMs: 5000, phase: "workspace_restore", phaseElapsedMs: expect.any(Number),
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(adapterExecutionControls.get(runId)).toBe(control);
|
||||
} finally {
|
||||
control.finish();
|
||||
adapterExecutionControls.delete(runId);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not report a Stop that settles before the report bound", async () => {
|
||||
vi.useFakeTimers();
|
||||
const control = createAdapterExecutionControl();
|
||||
const report = vi.fn();
|
||||
reportAdapterStopIfUnsettled(control.settled, report, undefined, control, { reportAfterMs: 5000 });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
control.finish();
|
||||
await control.settled;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("measures the report bound from the Stop request, including its response wait", async () => {
|
||||
vi.useFakeTimers();
|
||||
const control = createAdapterExecutionControl();
|
||||
const report = vi.fn();
|
||||
const stopRequestedAt = Date.now();
|
||||
// The Stop request's own response wait elapses before the watch starts.
|
||||
const responseWait = stopTimeout(waitForAdapterStop(control.settled, 2000));
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await responseWait;
|
||||
reportAdapterStopIfUnsettled(control.settled, report, undefined, control, { stopRequestedAt, reportAfterMs: 5000 });
|
||||
await vi.advanceTimersByTimeAsync(2999);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(report).toHaveBeenCalledOnce();
|
||||
expect((report.mock.calls[0]![0] as AdapterStopTimeoutError).diagnostics).toMatchObject({ timeoutMs: 5000 });
|
||||
control.finish();
|
||||
});
|
||||
|
||||
it("keeps a failing reporter from escaping its timer", async () => {
|
||||
vi.useFakeTimers();
|
||||
const control = createAdapterExecutionControl();
|
||||
const report = vi.fn(() => { throw new Error("reporter down"); });
|
||||
reportAdapterStopIfUnsettled(control.settled, report, undefined, undefined, { reportAfterMs: 1000 });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(report).toHaveBeenCalledOnce();
|
||||
control.finish();
|
||||
});
|
||||
@@ -68,9 +68,9 @@ export async function registerAdapterExecutionControl(
|
||||
adapterExecutionControls.set(runId, control);
|
||||
}
|
||||
|
||||
export async function waitForAdapterStop(
|
||||
function adapterStopTimeoutError(
|
||||
settled: Promise<void>,
|
||||
timeoutMs = 60_000,
|
||||
timeoutMs: number,
|
||||
diagnostics?: AdapterStopContext,
|
||||
owner?: ReturnType<typeof createAdapterExecutionControl>,
|
||||
) {
|
||||
@@ -83,21 +83,27 @@ export async function waitForAdapterStop(
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return new AdapterStopTimeoutError(timeoutMs, {
|
||||
...diagnostics,
|
||||
phase: undefined,
|
||||
phaseElapsedMs: undefined,
|
||||
...currentPhase(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForAdapterStop(
|
||||
settled: Promise<void>,
|
||||
timeoutMs = 60_000,
|
||||
diagnostics?: AdapterStopContext,
|
||||
owner?: ReturnType<typeof createAdapterExecutionControl>,
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
settled,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new AdapterStopTimeoutError(timeoutMs, {
|
||||
...diagnostics,
|
||||
phase: undefined,
|
||||
phaseElapsedMs: undefined,
|
||||
...currentPhase(),
|
||||
}),
|
||||
),
|
||||
() => reject(adapterStopTimeoutError(settled, timeoutMs, diagnostics, owner)),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
@@ -106,3 +112,42 @@ export async function waitForAdapterStop(
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// A Stop request's wait is a response budget, not the adapter's settlement
|
||||
// bound: workspace sync-back and cooperative provider cancellation routinely
|
||||
// settle a few minutes after it. Only a Stop still unsettled this long after
|
||||
// it was requested is an operational failure worth reporting.
|
||||
export const UNSETTLED_ADAPTER_STOP_REPORT_MS = 10 * 60_000;
|
||||
const watchedAdapterStops = new WeakSet<Promise<void>>();
|
||||
|
||||
/** Reports at most once per execution, and only if it never settles in time. */
|
||||
export function reportAdapterStopIfUnsettled(
|
||||
settled: Promise<void>,
|
||||
report: (error: AdapterStopTimeoutError) => void,
|
||||
diagnostics?: AdapterStopContext,
|
||||
owner?: ReturnType<typeof createAdapterExecutionControl>,
|
||||
{
|
||||
stopRequestedAt = Date.now(),
|
||||
reportAfterMs = UNSETTLED_ADAPTER_STOP_REPORT_MS,
|
||||
}: { stopRequestedAt?: number; reportAfterMs?: number } = {},
|
||||
) {
|
||||
if (watchedAdapterStops.has(settled)) return;
|
||||
watchedAdapterStops.add(settled);
|
||||
let done = false;
|
||||
// The bound runs from the Stop request, which already spent its response wait.
|
||||
const remainingMs = Math.max(0, stopRequestedAt + reportAfterMs - Date.now());
|
||||
const timer = setTimeout(() => {
|
||||
if (done) return;
|
||||
try {
|
||||
report(adapterStopTimeoutError(settled, reportAfterMs, diagnostics, owner));
|
||||
} catch {
|
||||
// Reporting is diagnostic only.
|
||||
}
|
||||
}, remainingMs);
|
||||
// Never hold process shutdown open for a diagnostic.
|
||||
timer.unref?.();
|
||||
void settled.then(() => {
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,616 @@
|
||||
import { providerEffectPayload } from "./provider-effects.js";
|
||||
|
||||
import { slackSlashTaskRecoveryPayload } from "./slack/task-start.js";
|
||||
import { GITHUB_RECOVERY_STATE_KEY } from "./github/webhook-ingress.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { alias, type AnyPgColumn } from "drizzle-orm/pg-core";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatPublications,
|
||||
chatSdkState,
|
||||
chatTeamsFileTransfers,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatPublicationSummary,
|
||||
SafeChatPublicationPayload,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
teamsFileTransferService,
|
||||
} from "../chat-teams-file-transfers.js";
|
||||
|
||||
import {
|
||||
chatFileTransferResolutionActions,
|
||||
projectChatFileTransfer,
|
||||
} from "../chat-publication-batches.js";
|
||||
|
||||
import {
|
||||
badRequest,
|
||||
notFound,
|
||||
} from "../../errors.js";
|
||||
|
||||
import { chatProviderConversationUrl } from "../chat-provider-links.js";
|
||||
|
||||
import type { EndpointRecord } from "./runtime-types.js";
|
||||
import { slackTaskStartActivityStatus } from "./slack/task-start.js";
|
||||
import { fileTransferProjectionColumns } from "./microsoft-teams/file-transfer-projection.js";
|
||||
|
||||
function publicationSummary(
|
||||
row: typeof chatPublications.$inferSelect,
|
||||
): ChatPublicationSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
state: row.state,
|
||||
providerUrl: row.providerUrl,
|
||||
attempts: row.attempts,
|
||||
redactedError: row.redactedError,
|
||||
nextAttemptAt: row.nextAttemptAt?.toISOString() ?? null,
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
export { publicationSummary };
|
||||
|
||||
type ChatActivityOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
teamsTransferProtocol: () => Pick<ReturnType<typeof teamsFileTransferService>, "canCancelConflict">;
|
||||
};
|
||||
|
||||
export function createChatActivity(db: Db, options: ChatActivityOptions) {
|
||||
const { endpointRecord, teamsTransferProtocol } = options;
|
||||
|
||||
|
||||
async function listConversations(endpointId: string) {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("Chat endpoint not found");
|
||||
const conversations = await db
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(eq(chatConversations.endpointId, endpointId))
|
||||
.orderBy(
|
||||
desc(chatConversations.lastActivityAt),
|
||||
desc(chatConversations.createdAt),
|
||||
);
|
||||
if (!conversations.length) return [];
|
||||
const issueRows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
inArray(
|
||||
issues.id,
|
||||
conversations.map((row) => row.issueId),
|
||||
),
|
||||
);
|
||||
const publications = await db
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(eq(chatPublications.endpointId, endpointId))
|
||||
.orderBy(desc(chatPublications.createdAt));
|
||||
const issueById = new Map(issueRows.map((row) => [row.id, row]));
|
||||
const lastPublication = new Map<
|
||||
string,
|
||||
typeof chatPublications.$inferSelect
|
||||
>();
|
||||
for (const publication of publications)
|
||||
if (!lastPublication.has(publication.conversationId))
|
||||
lastPublication.set(publication.conversationId, publication);
|
||||
return conversations.map((conversation) => {
|
||||
const issue = issueById.get(conversation.issueId);
|
||||
const externalUrl =
|
||||
record.endpoint.provider === "telegram" && conversation.isDirectMessage
|
||||
? chatProviderConversationUrl({
|
||||
provider: "telegram",
|
||||
botUsername: record.endpoint.botUsername,
|
||||
threadId: conversation.externalThreadId,
|
||||
providerMessageId: "",
|
||||
raw: { chat: { type: "private" } },
|
||||
})
|
||||
: (conversation.providerUrl ??
|
||||
(record.endpoint.provider === "slack"
|
||||
? chatProviderConversationUrl({
|
||||
provider: "slack",
|
||||
providerAccountId: record.endpoint.providerAccountId,
|
||||
threadId: conversation.externalThreadId,
|
||||
providerMessageId: "",
|
||||
})
|
||||
: null));
|
||||
return {
|
||||
id: conversation.id,
|
||||
companyId: conversation.companyId,
|
||||
endpointId: conversation.endpointId,
|
||||
resourceId: conversation.resourceId,
|
||||
externalLabel: conversation.externalLabel,
|
||||
externalUrl,
|
||||
externalConversationId: conversation.externalConversationId,
|
||||
externalThreadId: conversation.externalThreadId,
|
||||
sessionGeneration: conversation.sessionGeneration,
|
||||
issueId: conversation.issueId,
|
||||
issueIdentifier: issue?.identifier ?? null,
|
||||
issueTitle: issue?.title ?? null,
|
||||
isDirectMessage: conversation.isDirectMessage,
|
||||
state:
|
||||
record.endpoint.provider !== "imessage-photon" &&
|
||||
(issue?.status === "done" || issue?.status === "cancelled")
|
||||
? "completed"
|
||||
: conversation.state,
|
||||
lastActivityAt: conversation.lastActivityAt?.toISOString() ?? null,
|
||||
createdAt: conversation.createdAt.toISOString(),
|
||||
updatedAt: (
|
||||
conversation.lastActivityAt ?? conversation.updatedAt
|
||||
).toISOString(),
|
||||
lastPublicationStatus:
|
||||
lastPublication.get(conversation.id)?.state ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) {
|
||||
const limit = page ? page.limit + 1 : 100;
|
||||
// Match the millisecond precision of the public timestamp and use a UUID
|
||||
// tie-breaker so equal timestamps never skip records between pages.
|
||||
const before = (date: AnyPgColumn, id: AnyPgColumn) => page?.before
|
||||
? sql`(date_trunc('milliseconds', ${date}), ${id}) < (${page.before.createdAt}::timestamptz, ${page.before.id}::uuid)`
|
||||
: undefined;
|
||||
const recoveryIngress = alias(chatActions, "github_recovery_ingress");
|
||||
const [
|
||||
deliveries,
|
||||
publications,
|
||||
slashActions,
|
||||
providerEffects,
|
||||
githubIngressFailures,
|
||||
slackSessionActions,
|
||||
githubRecoveries,
|
||||
githubRecoveryScans,
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(chatDeliveries)
|
||||
.where(and(eq(chatDeliveries.endpointId, endpointId), before(chatDeliveries.createdAt, chatDeliveries.id)))
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatDeliveries.createdAt})`), desc(chatDeliveries.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(and(eq(chatPublications.endpointId, endpointId), before(chatPublications.createdAt, chatPublications.id)))
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatPublications.createdAt})`), desc(chatPublications.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
before(chatActions.createdAt, chatActions.id),
|
||||
eq(chatActions.kind, "slash_task_start"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
before(chatActions.createdAt, chatActions.id),
|
||||
eq(chatActions.kind, "provider_effect"),
|
||||
eq(chatActions.status, "delivery_unknown"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
before(chatActions.createdAt, chatActions.id),
|
||||
eq(chatActions.kind, "github_webhook_ingress"),
|
||||
eq(chatActions.status, "failed"),
|
||||
sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'false'`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
before(chatActions.updatedAt, chatActions.id),
|
||||
inArray(chatActions.kind, [
|
||||
"slack_session_sync",
|
||||
"slack_session_stop",
|
||||
]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select({
|
||||
action: chatActions,
|
||||
ingressStatus: recoveryIngress.status,
|
||||
})
|
||||
.from(chatActions)
|
||||
.leftJoin(
|
||||
recoveryIngress,
|
||||
and(
|
||||
eq(recoveryIngress.companyId, chatActions.companyId),
|
||||
eq(recoveryIngress.endpointId, chatActions.endpointId),
|
||||
eq(recoveryIngress.kind, "github_webhook_ingress"),
|
||||
sql`${recoveryIngress.providerActionId} = 'github_webhook_ingress:' || (${chatActions.payload}#>>'{original,guid}')`,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
before(chatActions.updatedAt, chatActions.id),
|
||||
eq(chatActions.kind, "github_webhook_recovery"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatSdkState)
|
||||
.where(
|
||||
and(
|
||||
eq(chatSdkState.endpointId, endpointId),
|
||||
before(chatSdkState.updatedAt, chatSdkState.id),
|
||||
eq(chatSdkState.stateKey, GITHUB_RECOVERY_STATE_KEY),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
]);
|
||||
// A provider effect can be on another page. Replay safety must still
|
||||
// consider every unresolved effect associated with these deliveries.
|
||||
const ambiguousEffects = deliveries.length ? await db.select({ deliveryId: chatActions.deliveryId })
|
||||
.from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
eq(chatActions.kind, "provider_effect"),
|
||||
eq(chatActions.status, "delivery_unknown"),
|
||||
inArray(chatActions.deliveryId, deliveries.map((row) => row.id)),
|
||||
)) : [];
|
||||
const ambiguousProviderEffectDeliveryIds = new Set(ambiguousEffects.map((row) => row.deliveryId));
|
||||
const transferRows = publications.length
|
||||
? await db
|
||||
.select(fileTransferProjectionColumns)
|
||||
.from(chatTeamsFileTransfers)
|
||||
.innerJoin(
|
||||
chatPublications,
|
||||
and(
|
||||
eq(chatPublications.id, chatTeamsFileTransfers.publicationId),
|
||||
eq(chatPublications.companyId, chatTeamsFileTransfers.companyId),
|
||||
eq(
|
||||
chatPublications.endpointId,
|
||||
chatTeamsFileTransfers.endpointId,
|
||||
),
|
||||
eq(
|
||||
chatPublications.conversationId,
|
||||
chatTeamsFileTransfers.conversationId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatTeamsFileTransfers.companyId, publications[0]!.companyId),
|
||||
eq(chatTeamsFileTransfers.endpointId, endpointId),
|
||||
inArray(
|
||||
chatTeamsFileTransfers.publicationId,
|
||||
publications.map((row) => row.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const transfers = new Map(
|
||||
transferRows.map((row) => [row.publicationId, row]),
|
||||
);
|
||||
const cancellableConflicts = new Map<string, number>();
|
||||
for (const transfer of transferRows) {
|
||||
if (transfer.phase !== "conflict") continue;
|
||||
const publication = publications.find(
|
||||
(row) => row.id === transfer.publicationId,
|
||||
);
|
||||
if (!publication?.conversationId) continue;
|
||||
if (
|
||||
await teamsTransferProtocol().canCancelConflict({
|
||||
companyId: publication.companyId,
|
||||
endpointId,
|
||||
conversationId: publication.conversationId,
|
||||
publicationId: publication.id,
|
||||
version: transfer.version,
|
||||
})
|
||||
)
|
||||
cancellableConflicts.set(publication.id, transfer.version);
|
||||
}
|
||||
return [
|
||||
...deliveries.map((row) => {
|
||||
const normalized =
|
||||
row.normalizedEvent &&
|
||||
typeof row.normalizedEvent === "object" &&
|
||||
!Array.isArray(row.normalizedEvent)
|
||||
? (row.normalizedEvent as Record<string, unknown>)
|
||||
: {};
|
||||
const deduplication =
|
||||
normalized.deduplication &&
|
||||
typeof normalized.deduplication === "object" &&
|
||||
!Array.isArray(normalized.deduplication)
|
||||
? (normalized.deduplication as Record<string, unknown>)
|
||||
: null;
|
||||
const duplicateCount =
|
||||
typeof deduplication?.duplicateCount === "number"
|
||||
? deduplication.duplicateCount
|
||||
: 0;
|
||||
const label = row.eventKind.replaceAll("_", " ");
|
||||
const outcome =
|
||||
row.state === "filtered"
|
||||
? "ignored"
|
||||
: row.state === "processed"
|
||||
? "processed"
|
||||
: row.state;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "delivery" as const,
|
||||
status: row.state,
|
||||
summary: `${label} ${outcome}${duplicateCount ? ` · ${duplicateCount} duplicate${duplicateCount === 1 ? "" : "s"} ignored` : ""}`,
|
||||
detail: row.redactedError,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
replayable:
|
||||
row.state === "failed" &&
|
||||
Boolean(row.conversationId) &&
|
||||
!ambiguousProviderEffectDeliveryIds.has(row.id),
|
||||
resolutionActions: [],
|
||||
};
|
||||
}),
|
||||
...publications.map((row) => {
|
||||
const transfer = transfers.get(row.id);
|
||||
const projected = projectChatFileTransfer(
|
||||
publicationSummary(row),
|
||||
transfer,
|
||||
);
|
||||
const payload = row.payload as Partial<SafeChatPublicationPayload>;
|
||||
const summary = payload.progressState
|
||||
? `${payload.progressState.replaceAll("_", " ")} update`
|
||||
: payload.interactionId
|
||||
? "Interactive question"
|
||||
: "Response to external conversation";
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "publication" as const,
|
||||
status: projected.state,
|
||||
summary,
|
||||
detail: projected.redactedError ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
replayable: !transfer && row.state === "failed",
|
||||
...(projected.fileTransfer
|
||||
? { fileTransfer: projected.fileTransfer }
|
||||
: {}),
|
||||
resolutionActions: transfer
|
||||
? chatFileTransferResolutionActions(
|
||||
projected,
|
||||
cancellableConflicts.has(row.id)
|
||||
? {
|
||||
publicationId: row.id,
|
||||
version: cancellableConflicts.get(row.id)!,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
: row.state === "delivery_unknown"
|
||||
? (["mark_delivered", "retry_anyway", "cancel"] as const)
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
...slashActions.map((row) => {
|
||||
const recovery = slackSlashTaskRecoveryPayload(row.payload);
|
||||
// Keep this GET read-only while still surfacing a crashed attempt as
|
||||
// actionable. The resolution POST persists the derived quarantine
|
||||
// before it applies the operator's audited decision.
|
||||
const status = slackTaskStartActivityStatus(row);
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "action" as const,
|
||||
actionType: "slash_task_start" as const,
|
||||
status,
|
||||
summary: `Slack slash-command task start ${status.replaceAll("_", " ")}`,
|
||||
detail:
|
||||
status === "delivery_unknown"
|
||||
? recovery
|
||||
? "Slack may have accepted the task-start message, so Paperclip will not replay it automatically. Check Slack first; an explicit retry can create a duplicate starter message and task."
|
||||
: "Slack may have accepted the task-start message. This older action lacks the context required for a safe explicit retry, so check Slack and cancel it here before submitting a new command."
|
||||
: status === "provider_confirmed"
|
||||
? "Slack accepted the task-start message. Paperclip is completing durable task admission without sending another Slack message."
|
||||
: status === "admitting"
|
||||
? "Slack accepted the task-start message. A Paperclip worker is admitting the task without replaying the Slack send."
|
||||
: status === "failed"
|
||||
? "Slack rejected the task-start message. Submit the command again to retry."
|
||||
: status === "cancelled"
|
||||
? "An operator cancelled this unconfirmed task start."
|
||||
: null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions:
|
||||
status === "delivery_unknown"
|
||||
? recovery
|
||||
? (["retry_anyway", "cancel"] as const)
|
||||
: (["cancel"] as const)
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
...providerEffects.map((row) => {
|
||||
const payload = providerEffectPayload(row.payload);
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "action" as const,
|
||||
actionType: "provider_effect" as const,
|
||||
status: row.status,
|
||||
summary: "Provider reply delivery unknown",
|
||||
detail: payload?.completeConversationId
|
||||
? "The provider may have accepted this reply, but Paperclip could not confirm it or close the task conversation. Check the provider first. Marking it delivered closes the conversation; retrying can create a duplicate message."
|
||||
: "The provider may have accepted this reply, but Paperclip could not confirm it. Check the provider first. Retrying can create a duplicate message.",
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions: [
|
||||
"mark_delivered",
|
||||
"retry_anyway",
|
||||
"cancel",
|
||||
] as const,
|
||||
};
|
||||
}),
|
||||
...slackSessionActions.map((row) => {
|
||||
const isSync = row.kind === "slack_session_sync";
|
||||
const retrying =
|
||||
row.status === "received" &&
|
||||
row.result?.code === "slack_session_sync_retry";
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "action" as const,
|
||||
actionType: isSync
|
||||
? ("slack_session_sync" as const)
|
||||
: ("slack_session_stop" as const),
|
||||
status: retrying ? "retry" : row.status,
|
||||
summary: isSync ? "Slack session status" : "Slack Stop request",
|
||||
detail: isSync
|
||||
? row.result?.outcome === "unavailable"
|
||||
? "Slack has not enabled native session status for this destination. Message delivery continues."
|
||||
: row.result?.code === "slack_session_sync_rejected"
|
||||
? "Slack rejected the session indicator update. Check app permissions and channel access; message delivery is tracked separately."
|
||||
: retrying
|
||||
? "The Slack session indicator is waiting to sync. Paperclip will not resend the response."
|
||||
: row.result?.sessionStatus === "processing"
|
||||
? "Working status is refreshed automatically while the run remains active."
|
||||
: null
|
||||
: row.status === "processed"
|
||||
? "Paperclip stopped the work authorized by this request."
|
||||
: row.status === "cancelled"
|
||||
? "No work was stopped: this request was no longer authorized or its target was no longer current."
|
||||
: row.status === "failed"
|
||||
? row.result?.retryable === true
|
||||
? "The Stop request could not finish yet. Paperclip will retry against the original work only."
|
||||
: "The Stop request could not be completed. Check the task's current run before trying again."
|
||||
: "Paperclip is processing this Stop request against its original task and run.",
|
||||
createdAt: row.updatedAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions: [],
|
||||
};
|
||||
}),
|
||||
...githubIngressFailures.map((row) => {
|
||||
const deliveryId =
|
||||
typeof row.payload.deliveryId === "string"
|
||||
? row.payload.deliveryId
|
||||
: null;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "action" as const,
|
||||
actionType: "github_webhook_ingress" as const,
|
||||
status: row.status,
|
||||
summary: "GitHub webhook could not be processed",
|
||||
detail: deliveryId
|
||||
? `GitHub delivery ${deliveryId} was authenticated, but Paperclip could not finish processing it. Fix the connection or destination, then redeliver this delivery from the GitHub App's Recent Deliveries page.`
|
||||
: "An authenticated GitHub webhook could not be processed. Fix the connection or destination, then redeliver it from the GitHub App's Recent Deliveries page.",
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions: [],
|
||||
};
|
||||
}),
|
||||
...githubRecoveries.map(({ action: row, ingressStatus }) => ({
|
||||
id: row.id,
|
||||
kind: "repair" as const,
|
||||
status:
|
||||
row.status === "cancelled"
|
||||
? "cancelled"
|
||||
: ingressStatus
|
||||
? "received"
|
||||
: "pending",
|
||||
summary:
|
||||
row.status === "cancelled"
|
||||
? "GitHub webhook recovery skipped"
|
||||
: ingressStatus
|
||||
? "GitHub webhook received after recovery request"
|
||||
: "GitHub webhook redelivery requested",
|
||||
detail:
|
||||
row.status === "cancelled"
|
||||
? row.result?.code === "source_changed_or_unavailable"
|
||||
? "The original comment changed or is no longer available. Paperclip did not replay its old contents."
|
||||
: "This callback was not an eligible current user comment. Paperclip did not replay it."
|
||||
: ingressStatus
|
||||
? "Paperclip received this callback. Its normal access checks and processing still apply."
|
||||
: "Paperclip asked GitHub to resend a recent missed message. This does not yet confirm receipt or a reply. Automatic requests are limited; if it remains unanswered, check the App's Recent Deliveries and send your request again in the current conversation.",
|
||||
createdAt: row.updatedAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions: [],
|
||||
})),
|
||||
...githubRecoveryScans.flatMap((row) => {
|
||||
const value = row.value as { outcome?: string } | null;
|
||||
const detail =
|
||||
value?.outcome === "history_limit_reached"
|
||||
? "Recent webhook volume exceeded automatic recovery's bounded scan. No partial history was replayed. Check the App's Recent Deliveries for missed messages."
|
||||
: value?.outcome === "callback_mismatch"
|
||||
? "The GitHub App's callback no longer matches this connection. Reconnect the App to repair it; repository access will not change."
|
||||
: value?.outcome === "scan_failed"
|
||||
? "Paperclip could not check GitHub's failed deliveries. The check will retry with backoff; normal callbacks are still processed."
|
||||
: null;
|
||||
return detail
|
||||
? [
|
||||
{
|
||||
id: row.id,
|
||||
kind: "health" as const,
|
||||
status: "attention",
|
||||
summary: "GitHub webhook recovery needs attention",
|
||||
detail,
|
||||
createdAt: row.updatedAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions: [],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
]
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function listActivityPage(endpointId: string, limit = 25, cursor?: string) {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw badRequest("Activity limit must be between 1 and 100");
|
||||
let before: { createdAt: string; id: string } | undefined;
|
||||
if (cursor !== undefined) {
|
||||
try {
|
||||
if (cursor.length > 256) throw new Error("Invalid cursor");
|
||||
const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
||||
if (!Array.isArray(value) || value.length !== 2
|
||||
|| typeof value[0] !== "string" || new Date(value[0]).toISOString() !== value[0]
|
||||
|| typeof value[1] !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value[1])) throw new Error("Invalid cursor");
|
||||
before = { createdAt: value[0], id: value[1] };
|
||||
} catch { throw badRequest("Invalid activity cursor"); }
|
||||
}
|
||||
const rows = await listActivity(endpointId, { limit, before });
|
||||
const items = rows.slice(0, limit);
|
||||
const last = items.at(-1);
|
||||
return {
|
||||
items,
|
||||
nextCursor: rows.length > limit && last
|
||||
? Buffer.from(JSON.stringify([last.createdAt, last.id])).toString("base64url")
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return { listConversations, listActivity, listActivityPage };
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence, InternalSetupState } from "../runtime-types.js";
|
||||
|
||||
import { notifyChatEndpointWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatConversations,
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEndpointSetupState,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
type DiscordGatewayCallbackEvent,
|
||||
} from "../../chat-sdk-runtime.js";
|
||||
|
||||
import {
|
||||
type ChatProviderLifecycleEffect,
|
||||
} from "../../chat-provider-lifecycle.js";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
type DiscordGatewayEventsOptions = {
|
||||
ensureDiscordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => Promise<boolean>;
|
||||
discordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => boolean;
|
||||
runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise<EndpointRecord | null>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
applyProviderLifecycleEffect: (endpoint: EndpointRow, effect: ChatProviderLifecycleEffect, context?: RuntimeContext) => Promise<boolean>;
|
||||
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
||||
discordGatewayEventBarrier?: (event: DiscordGatewayCallbackEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
export function createDiscordGatewayEvents(db: Db, options: DiscordGatewayEventsOptions) {
|
||||
const { ensureDiscordGatewayRuntimeIsCurrent, discordGatewayRuntimeIsCurrent, runtimeCallbackRecord, runtimeCallbackEndpoint, applyProviderLifecycleEffect, runtimeGeneration } = options;
|
||||
|
||||
|
||||
function discordGatewayFailureDetail(
|
||||
event: DiscordGatewayCallbackEvent["event"],
|
||||
): string {
|
||||
if (event.type === "guild_removed") {
|
||||
return "Discord bot was removed from the configured server";
|
||||
}
|
||||
if (event.type === "guild_unavailable") {
|
||||
return "Discord server is temporarily unavailable; reconnecting";
|
||||
}
|
||||
if (event.type === "disconnected") {
|
||||
return `Discord Gateway disconnected${event.code === undefined ? "" : ` (code ${event.code})`}; reconnecting`;
|
||||
}
|
||||
if (event.type === "failure") {
|
||||
const code = event.error.code ?? event.error.status;
|
||||
return `Discord Gateway failed${code === undefined ? "" : ` (${String(code).slice(0, 64)})`}; ${event.fatal ? "credentials or privileged intents need attention" : "reconnecting"}`;
|
||||
}
|
||||
return "Discord Gateway connection needs attention";
|
||||
}
|
||||
|
||||
function handleDiscordGatewayEvent(
|
||||
callback: DiscordGatewayCallbackEvent,
|
||||
context: RuntimeContext,
|
||||
): Promise<void> {
|
||||
const pending = (context.discordGatewayEventQueue ?? Promise.resolve())
|
||||
.catch(() => undefined)
|
||||
.then(() => processDiscordGatewayEvent(callback, context));
|
||||
context.discordGatewayEventQueue = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function processDiscordGatewayEvent(
|
||||
callback: DiscordGatewayCallbackEvent,
|
||||
context: RuntimeContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!(await ensureDiscordGatewayRuntimeIsCurrent(
|
||||
callback.endpointId,
|
||||
context,
|
||||
))
|
||||
)
|
||||
return;
|
||||
if (callback.sequence <= (context.discordGatewaySequence ?? 0)) return;
|
||||
context.discordGatewaySequence = callback.sequence;
|
||||
await options.discordGatewayEventBarrier?.(callback);
|
||||
const record = await runtimeCallbackRecord(callback.endpointId, context, [
|
||||
"verifying",
|
||||
"active",
|
||||
"attention",
|
||||
]);
|
||||
if (!record || record.endpoint.provider !== "discord") return;
|
||||
const event = callback.event;
|
||||
if (event.type === "channel_removed") {
|
||||
await applyProviderLifecycleEffect(
|
||||
record.endpoint,
|
||||
{
|
||||
kind: "resource",
|
||||
provider: "discord",
|
||||
providerEventId: `gateway:channel_removed:${event.channelId}`,
|
||||
providerResourceId: event.channelId,
|
||||
resourceType: "channel",
|
||||
label: event.label?.trim() || event.channelId,
|
||||
availability: "removed",
|
||||
metadata: { source: "discord_gateway" },
|
||||
},
|
||||
context,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const current = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
callback.endpointId,
|
||||
context,
|
||||
["verifying", "active", "attention"],
|
||||
);
|
||||
if (
|
||||
!current ||
|
||||
current.provider !== "discord" ||
|
||||
!discordGatewayRuntimeIsCurrent(callback.endpointId, context)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
if (event.type === "connecting") {
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
healthMessage: "Discord Gateway connecting",
|
||||
lastError: null,
|
||||
lastEventAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, current.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
healthStatus: "unchecked",
|
||||
healthMessage: "Discord Gateway connecting",
|
||||
lastError: null,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, current.connectionId));
|
||||
return;
|
||||
}
|
||||
if (event.type === "ready" || event.type === "guild_available") {
|
||||
const gatewayOwnedAttention =
|
||||
current.status === "attention" &&
|
||||
current.healthMessage?.startsWith("Discord ") === true;
|
||||
if (current.status === "attention" && !gatewayOwnedAttention) return;
|
||||
const status =
|
||||
current.setup.step === "complete" ? "active" : "verifying";
|
||||
if (status !== current.status) await notifyChatEndpointWork(tx);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status,
|
||||
healthMessage:
|
||||
status === "active"
|
||||
? "Connected"
|
||||
: "Waiting for a test conversation",
|
||||
lastError: null,
|
||||
lastEventAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, current.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "healthy",
|
||||
healthMessage: "Connected",
|
||||
lastError: null,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, current.connectionId));
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = discordGatewayFailureDetail(event);
|
||||
const transientGuildOutage = event.type === "guild_unavailable";
|
||||
const terminal =
|
||||
event.type === "guild_removed" ||
|
||||
((event.type === "failure" || event.type === "disconnected") &&
|
||||
event.fatal);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: transientGuildOutage
|
||||
? current.status
|
||||
: event.type === "guild_removed"
|
||||
? "revoked"
|
||||
: "attention",
|
||||
healthMessage: detail,
|
||||
lastError: detail,
|
||||
lastEventAt: now,
|
||||
...(terminal
|
||||
? {
|
||||
setup: {
|
||||
...current.setup,
|
||||
runtimeGeneration: runtimeGeneration(current.setup) + 1,
|
||||
} as InternalSetupState,
|
||||
}
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, current.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: terminal ? "disabled" : "active",
|
||||
enabled: !terminal,
|
||||
healthStatus: event.type === "guild_removed" ? "failed" : "degraded",
|
||||
healthMessage: detail,
|
||||
lastError: detail,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, current.connectionId));
|
||||
if (event.type === "guild_removed") {
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: "unavailable", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, current.companyId),
|
||||
eq(chatEndpointResources.endpointId, current.id),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "unavailable", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, current.companyId),
|
||||
eq(chatConversations.endpointId, current.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return { handleDiscordGatewayEvent };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FileUpload } from "chat";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
|
||||
@@ -28,3 +29,11 @@ export function discordLifecycleFilesDigest(raw: unknown): string {
|
||||
: [];
|
||||
return createHash("sha256").update(JSON.stringify(projection)).digest("hex");
|
||||
}
|
||||
|
||||
export function discordMarkdownAttachment(text: string): FileUpload {
|
||||
return {
|
||||
data: Buffer.from(text, "utf8"),
|
||||
filename: "paperclip-response.md",
|
||||
mimeType: "text/markdown; charset=utf-8",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "../runtime-types.js";
|
||||
|
||||
import { notifyChatDeliveryWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
|
||||
|
||||
import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
chatExternalPrincipals,
|
||||
companyMemberships,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
conflict,
|
||||
} from "../../../errors.js";
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
|
||||
import {
|
||||
DiscordAdapterCompatibilityError,
|
||||
type ChatSdkMessageCallbackEvent,
|
||||
type DiscordRootMentionAdmissionEvent,
|
||||
} from "../../chat-sdk-runtime.js";
|
||||
|
||||
import { classifyChatPublicationError } from "../../chat-publication-errors.js";
|
||||
|
||||
import type {
|
||||
Message,
|
||||
Thread,
|
||||
} from "chat";
|
||||
|
||||
type DeliveryRow = typeof chatDeliveries.$inferSelect;
|
||||
|
||||
type DiscordRootThreadsOptions = {
|
||||
ensureDiscordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => Promise<boolean>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; linkedDenied: boolean }>;
|
||||
processMessage: (endpoint: EndpointRow, thread: Thread, message: Message, trigger: ChatSdkMessageCallbackEvent["trigger"], ingressOnly?: boolean, recoveredProviderUrl?: string | null, runtimeContext?: InboundRuntimeContext, providerUpdateId?: number, admittedDeliveryId?: string | null, receiptReactionSupported?: boolean, preauthorizedUserId?: string | null, deferDrainUntilFollowup?: boolean) => Promise<void>;
|
||||
redactError: (error: unknown) => string;
|
||||
scheduleConversationDrain: (endpointId: string, threadId: string) => void;
|
||||
lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS: number;
|
||||
discordRootThreadTransportBarrier?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createDiscordRootThreads(db: Db, options: DiscordRootThreadsOptions) {
|
||||
const { ensureDiscordGatewayRuntimeIsCurrent, withCredentialMutationLease, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, processMessage, redactError, scheduleConversationDrain, lifecycleRuntimeFence, runtimeFor, DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS } = options;
|
||||
|
||||
|
||||
async function admitDiscordRootMention(
|
||||
configuredEndpoint: EndpointRow,
|
||||
event: DiscordRootMentionAdmissionEvent,
|
||||
context: RuntimeContext,
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!(await ensureDiscordGatewayRuntimeIsCurrent(
|
||||
configuredEndpoint.id,
|
||||
context,
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return withCredentialMutationLease(
|
||||
configuredEndpoint,
|
||||
async (credentialLease) => {
|
||||
const threadId = `discord:${event.guildId}:${event.channelId}:${event.messageId}`;
|
||||
const providerEventId = `${threadId}:${event.messageId}`;
|
||||
const allowed = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const endpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
configuredEndpoint.id,
|
||||
context,
|
||||
["verifying", "active"],
|
||||
);
|
||||
const existing = await tx
|
||||
.select({ state: chatDeliveries.state })
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, configuredEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
existing &&
|
||||
["processed", "filtered", "failed"].includes(existing.state)
|
||||
)
|
||||
return false;
|
||||
|
||||
let reason: string | null = null;
|
||||
if (!endpoint || endpoint.providerAccountId !== event.guildId) {
|
||||
reason = "Connection is not active";
|
||||
}
|
||||
const resource = endpoint
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, "channel"),
|
||||
eq(
|
||||
chatEndpointResources.providerResourceId,
|
||||
event.channelId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const enabledCount =
|
||||
endpoint?.status === "verifying"
|
||||
? await tx
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, "channel"),
|
||||
eq(chatEndpointResources.enabled, true),
|
||||
eq(chatEndpointResources.availability, "available"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.count ?? 0)
|
||||
: 0;
|
||||
const setupDestination =
|
||||
endpoint?.status === "verifying" &&
|
||||
enabledCount === 0 &&
|
||||
resource?.availability === "available";
|
||||
if (
|
||||
!reason &&
|
||||
!(
|
||||
endpoint?.allowGroupChats &&
|
||||
resource?.availability === "available" &&
|
||||
(resource.enabled || setupDestination)
|
||||
)
|
||||
) {
|
||||
reason = "Destination is not enabled in Paperclip";
|
||||
}
|
||||
|
||||
if (!reason && endpoint) {
|
||||
const principal = await tx
|
||||
.select({ id: chatExternalPrincipals.id })
|
||||
.from(chatExternalPrincipals)
|
||||
.where(
|
||||
and(
|
||||
eq(chatExternalPrincipals.companyId, endpoint.companyId),
|
||||
eq(chatExternalPrincipals.provider, "discord"),
|
||||
eq(chatExternalPrincipals.providerAccountId, event.guildId),
|
||||
eq(chatExternalPrincipals.externalId, event.userId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
let allowed: boolean;
|
||||
let linkedDenied = false;
|
||||
if (principal) {
|
||||
const authorization = await lockCurrentPrincipalAuthorization(
|
||||
tx,
|
||||
endpoint,
|
||||
principal.id,
|
||||
);
|
||||
allowed = authorization.allowed;
|
||||
linkedDenied = authorization.linkedDenied;
|
||||
} else if (!endpoint.allowUnlinkedPeople) {
|
||||
allowed = false;
|
||||
} else if (!endpoint.sponsorUserId) {
|
||||
allowed = true;
|
||||
} else {
|
||||
const sponsor = await tx
|
||||
.select({
|
||||
status: companyMemberships.status,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, endpoint.companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, endpoint.sponsorUserId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
allowed =
|
||||
sponsor?.status === "active" &&
|
||||
sponsor.membershipRole !== "viewer";
|
||||
}
|
||||
if (!allowed) {
|
||||
reason = linkedDenied
|
||||
? "Linked Paperclip account is not currently permitted"
|
||||
: endpoint.allowUnlinkedPeople
|
||||
? "Endpoint sponsor can no longer authorize external guests"
|
||||
: "External identity must be linked to a Paperclip account";
|
||||
}
|
||||
}
|
||||
if (!reason) {
|
||||
await credentialLease.assertOwned(tx);
|
||||
return true;
|
||||
}
|
||||
|
||||
const filteredAt = new Date();
|
||||
const normalizedEvent = {
|
||||
providerEventId,
|
||||
kind: "mention",
|
||||
trigger: "mention",
|
||||
resource: {
|
||||
type: "channel",
|
||||
providerResourceId: event.channelId,
|
||||
},
|
||||
conversation: { externalThreadId: threadId },
|
||||
message: { providerMessageId: event.messageId },
|
||||
filtering: { contentRetained: false, providerThreadCreated: false },
|
||||
};
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.insert(chatDeliveries)
|
||||
.values({
|
||||
companyId: configuredEndpoint.companyId,
|
||||
endpointId: configuredEndpoint.id,
|
||||
providerEventId,
|
||||
deduplicationKey: createHash("sha256")
|
||||
.update(providerEventId)
|
||||
.digest("hex"),
|
||||
eventKind: "mention",
|
||||
normalizedEvent,
|
||||
state: "filtered",
|
||||
redactedError: reason,
|
||||
processedAt: filteredAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
chatDeliveries.endpointId,
|
||||
chatDeliveries.providerEventId,
|
||||
],
|
||||
set: {
|
||||
normalizedEvent,
|
||||
principalId: null,
|
||||
state: "filtered",
|
||||
nextAttemptAt: null,
|
||||
redactedError: reason,
|
||||
processedAt: filteredAt,
|
||||
updatedAt: filteredAt,
|
||||
},
|
||||
});
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({ lastEventAt: filteredAt, updatedAt: filteredAt })
|
||||
.where(eq(chatEndpoints.id, configuredEndpoint.id));
|
||||
await credentialLease.assertOwned(tx);
|
||||
return false;
|
||||
});
|
||||
if (!allowed || !event.message || !context.endpointRuntime) {
|
||||
return false;
|
||||
}
|
||||
const thread = context.endpointRuntime.thread(event.threadId);
|
||||
await processMessage(
|
||||
configuredEndpoint,
|
||||
thread,
|
||||
event.message,
|
||||
"mention",
|
||||
true,
|
||||
null,
|
||||
context,
|
||||
undefined,
|
||||
null,
|
||||
true,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const staged = await db
|
||||
.select({ state: chatDeliveries.state })
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, configuredEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!staged ||
|
||||
!["received", "retry", "processing"].includes(staged.state)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await options.discordRootThreadTransportBarrier?.();
|
||||
await credentialLease.assertOwned();
|
||||
if (
|
||||
!(await ensureDiscordGatewayRuntimeIsCurrent(
|
||||
configuredEndpoint.id,
|
||||
context,
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await context.endpointRuntime.ensureDiscordRootThread({
|
||||
channelId: event.channelId,
|
||||
content: event.message.text,
|
||||
messageId: event.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
// The durable provisional receipt owns recovery. Do not let a
|
||||
// transient REST failure escape the Gateway event handler or ask the
|
||||
// adapter to create the same thread outside Paperclip's lifecycle
|
||||
// fence.
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: configuredEndpoint.id,
|
||||
error: redactError(error),
|
||||
providerEventId,
|
||||
},
|
||||
"Discord root thread creation will be retried from the durable receipt",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const promoted = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const current = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
configuredEndpoint.id,
|
||||
context,
|
||||
["verifying", "active"],
|
||||
);
|
||||
if (!current) return false;
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const [row] = await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "received",
|
||||
normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`,
|
||||
nextAttemptAt: null,
|
||||
redactedError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, configuredEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
eq(chatDeliveries.state, "retry"),
|
||||
sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id });
|
||||
await credentialLease.assertOwned(tx);
|
||||
return Boolean(row);
|
||||
});
|
||||
if (promoted)
|
||||
scheduleConversationDrain(configuredEndpoint.id, threadId);
|
||||
// Paperclip created the provider thread inside its credential/reach
|
||||
// lease. Returning false prevents the adapter from creating it again;
|
||||
// the durable drain owns the remaining task mutation.
|
||||
return false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function discordRootThreadPending(
|
||||
delivery: Pick<DeliveryRow, "normalizedEvent">,
|
||||
): boolean {
|
||||
return (
|
||||
Boolean(delivery.normalizedEvent) &&
|
||||
typeof delivery.normalizedEvent === "object" &&
|
||||
!Array.isArray(delivery.normalizedEvent) &&
|
||||
(delivery.normalizedEvent as { providerThreadPending?: unknown })
|
||||
.providerThreadPending === true
|
||||
);
|
||||
}
|
||||
|
||||
async function reconcileDiscordRootThread(
|
||||
endpoint: EndpointRow,
|
||||
delivery: DeliveryRow,
|
||||
threadId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const normalized = delivery.normalizedEvent as {
|
||||
message?: { providerMessageId?: unknown; text?: unknown };
|
||||
resource?: { providerResourceId?: unknown };
|
||||
};
|
||||
const discordThreadParts = threadId.split(":");
|
||||
const channelId =
|
||||
discordThreadParts[0] === "discord" && discordThreadParts.length >= 4
|
||||
? discordThreadParts[2]
|
||||
: normalized.resource?.providerResourceId;
|
||||
const messageId = normalized.message?.providerMessageId;
|
||||
if (typeof channelId !== "string" || typeof messageId !== "string") {
|
||||
throw new DiscordAdapterCompatibilityError(
|
||||
"the provisional root receipt is missing its channel or message id",
|
||||
);
|
||||
}
|
||||
const runtimeFence = lifecycleRuntimeFence(delivery);
|
||||
if (!runtimeFence) {
|
||||
throw new DiscordAdapterCompatibilityError(
|
||||
"the provisional root receipt is missing its runtime fence",
|
||||
);
|
||||
}
|
||||
return await withCredentialMutationLease(
|
||||
endpoint,
|
||||
async (credentialLease) => {
|
||||
const currentEndpoint = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const current = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
endpoint.id,
|
||||
runtimeFence,
|
||||
["verifying", "active"],
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
return current;
|
||||
});
|
||||
if (!currentEndpoint) {
|
||||
throw conflict(
|
||||
"Discord root thread credentials or connection state changed before recovery",
|
||||
{ code: "chat_endpoint_runtime_superseded" },
|
||||
);
|
||||
}
|
||||
const endpointRuntime = await runtimeFor(currentEndpoint);
|
||||
await credentialLease.assertOwned();
|
||||
await endpointRuntime.ensureDiscordRootThread({
|
||||
channelId,
|
||||
messageId,
|
||||
content:
|
||||
typeof normalized.message?.text === "string"
|
||||
? normalized.message.text
|
||||
: "Paperclip task",
|
||||
});
|
||||
return await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const current = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
endpoint.id,
|
||||
runtimeFence,
|
||||
["verifying", "active"],
|
||||
);
|
||||
if (!current) return false;
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const [promoted] = await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "received",
|
||||
normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`,
|
||||
nextAttemptAt: null,
|
||||
redactedError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, delivery.id),
|
||||
eq(chatDeliveries.state, "retry"),
|
||||
sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id });
|
||||
await credentialLease.assertOwned(tx);
|
||||
return Boolean(promoted);
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const disposition = classifyChatPublicationError(
|
||||
error,
|
||||
delivery.attempts + 1,
|
||||
);
|
||||
const terminal =
|
||||
disposition.kind === "resource_unavailable" ||
|
||||
disposition.kind === "failed";
|
||||
const retryAfterMs =
|
||||
disposition.kind === "retry"
|
||||
? disposition.retryAfterMs
|
||||
: DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS;
|
||||
const now = new Date();
|
||||
await db.transaction(async (tx) => {
|
||||
if (disposition.kind === "resource_unavailable") {
|
||||
const fence = lifecycleRuntimeFence(delivery);
|
||||
const normalized = delivery.normalizedEvent as {
|
||||
resource?: { providerResourceId?: unknown };
|
||||
};
|
||||
const providerResourceId = normalized.resource?.providerResourceId;
|
||||
const current = fence
|
||||
? await runtimeCallbackEndpoint(tx, endpoint.id, fence, [
|
||||
"verifying",
|
||||
"active",
|
||||
"attention",
|
||||
])
|
||||
: null;
|
||||
const resource =
|
||||
current && typeof providerResourceId === "string"
|
||||
? await tx
|
||||
.select({ id: chatEndpointResources.id })
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, current.companyId),
|
||||
eq(chatEndpointResources.endpointId, current.id),
|
||||
eq(
|
||||
chatEndpointResources.providerResourceId,
|
||||
providerResourceId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
if (resource) {
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: "unavailable", updatedAt: now })
|
||||
.where(eq(chatEndpointResources.id, resource.id));
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "unavailable", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, current!.companyId),
|
||||
eq(chatConversations.endpointId, current!.id),
|
||||
eq(chatConversations.resourceId, resource.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: terminal ? "filtered" : "retry",
|
||||
attempts: delivery.attempts + 1,
|
||||
nextAttemptAt: terminal
|
||||
? null
|
||||
: new Date(now.getTime() + retryAfterMs),
|
||||
processedAt: terminal ? now : null,
|
||||
redactedError: terminal
|
||||
? "Discord thread creation was not confirmed"
|
||||
: redactError(error),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, delivery.id),
|
||||
eq(chatDeliveries.state, "retry"),
|
||||
sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`,
|
||||
),
|
||||
);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return { admitDiscordRootMention, discordRootThreadPending, reconcileDiscordRootThread };
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "./runtime-types.js";
|
||||
|
||||
import type { ChatEndpointRuntime } from "../voice/voice-runtime.js";
|
||||
|
||||
import { takePhotonCompanion } from "../photon/attachments.js";
|
||||
|
||||
import { HEIF_CONTENT_TYPES, photonHeifPreview, validatePhotonImage } from "../photon/media.js";
|
||||
|
||||
import { PhotonError } from "../photon/cloud.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
ne,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
assets,
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
issueComments,
|
||||
issueAttachments,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
isAllowedContentType,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
normalizeContentType,
|
||||
normalizeUploadAttachmentContentType,
|
||||
} from "../../attachment-types.js";
|
||||
|
||||
import {
|
||||
hasTelegramMediaProvenance,
|
||||
identifyTelegramMedia,
|
||||
telegramMediaNeedsIdentification,
|
||||
} from "../chat-telegram-media-intake.js";
|
||||
|
||||
import {
|
||||
forbidden,
|
||||
} from "../../errors.js";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
|
||||
import type { StorageService } from "../../storage/types.js";
|
||||
|
||||
import { issueService } from "../issues.js";
|
||||
|
||||
import { isExternalChatWaitAuthorizationContention } from "../native-runtime/chat-attachment-reuse.js";
|
||||
|
||||
import {
|
||||
GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS,
|
||||
GitHubAttachmentUnavailableError,
|
||||
prepareGitHubPublicAttachment,
|
||||
} from "../chat-github-attachments.js";
|
||||
|
||||
import type {
|
||||
Attachment,
|
||||
} from "chat";
|
||||
|
||||
type DeliveryRow = typeof chatDeliveries.$inferSelect;
|
||||
|
||||
type InboundAttachmentsOptions = {
|
||||
storage?: StorageService;
|
||||
authorizeInboundWakeup: (tx: DbOrTransaction, action: typeof chatActions.$inferSelect) => Promise<{ delivery: DeliveryRow; conversation: typeof chatConversations.$inferSelect }>;
|
||||
lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null;
|
||||
sanitizeFilename: (value: string | undefined) => string | null;
|
||||
issuesSvc: Pick<ReturnType<typeof issueService>, "createAttachment">;
|
||||
isExternalActionAuthorizationChange: (error: unknown) => boolean;
|
||||
redactError: (error: unknown) => string;
|
||||
};
|
||||
|
||||
export function createInboundAttachments(db: Db, options: InboundAttachmentsOptions) {
|
||||
const { authorizeInboundWakeup, lifecycleRuntimeFence, sanitizeFilename, issuesSvc, isExternalActionAuthorizationChange, redactError } = options;
|
||||
|
||||
|
||||
async function ingestAttachments(input: {
|
||||
endpoint: EndpointRow;
|
||||
endpointRuntime: ChatEndpointRuntime;
|
||||
deliveryId: string;
|
||||
issueId: string;
|
||||
issueCommentId: string;
|
||||
attachments: Attachment[];
|
||||
attachmentLimitOmissions?: number;
|
||||
unavailableReferenceCount?: number;
|
||||
actorUserId: string | null;
|
||||
}): Promise<{
|
||||
storedIds: string[];
|
||||
omissionReasons: Record<string, number>;
|
||||
}> {
|
||||
const omissionReasons: Record<string, number> = {};
|
||||
const omit = (reason: string, count = 1) => {
|
||||
omissionReasons[reason] = (omissionReasons[reason] ?? 0) + count;
|
||||
};
|
||||
const boundedAttachments = input.attachments.slice(0, 20);
|
||||
const photonDerivatives = new Map<Attachment, { source: Attachment; sourceHash: string; kind: "heif_jpeg_preview" | "live_photo_video" }>();
|
||||
const originalPhotonAttachments = new Map<Attachment, string>();
|
||||
if (
|
||||
input.endpoint.provider === "microsoft-teams" &&
|
||||
input.unavailableReferenceCount
|
||||
) {
|
||||
// A channel/group reference is not a download capability. Still tell
|
||||
// the exact current-turn consumer that those bytes are unavailable,
|
||||
// including when there are no ingestible attachments at all.
|
||||
omit("download_unavailable", input.unavailableReferenceCount);
|
||||
}
|
||||
if (
|
||||
["github", "imessage-photon"].includes(input.endpoint.provider) &&
|
||||
input.attachmentLimitOmissions
|
||||
) {
|
||||
omit("attachment_limit", input.attachmentLimitOmissions);
|
||||
}
|
||||
if (input.attachments.length > boundedAttachments.length) {
|
||||
omit(
|
||||
"attachment_limit",
|
||||
input.attachments.length - boundedAttachments.length,
|
||||
);
|
||||
}
|
||||
if (!options.storage) {
|
||||
if (boundedAttachments.length)
|
||||
omit("storage_unavailable", boundedAttachments.length);
|
||||
return { storedIds: [], omissionReasons };
|
||||
}
|
||||
const existingByFingerprint = new Map<string, string[]>();
|
||||
const existingAttachments = await db
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
byteSize: assets.byteSize,
|
||||
contentType: assets.contentType,
|
||||
originalFilename: assets.originalFilename,
|
||||
sha256: assets.sha256,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(
|
||||
assets,
|
||||
and(
|
||||
eq(assets.companyId, issueAttachments.companyId),
|
||||
eq(assets.id, issueAttachments.assetId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(issueAttachments.companyId, input.endpoint.companyId),
|
||||
eq(issueAttachments.issueId, input.issueId),
|
||||
eq(issueAttachments.issueCommentId, input.issueCommentId),
|
||||
),
|
||||
);
|
||||
for (const existing of existingAttachments) {
|
||||
const fingerprint = JSON.stringify([
|
||||
existing.sha256,
|
||||
existing.byteSize,
|
||||
existing.contentType,
|
||||
existing.originalFilename,
|
||||
]);
|
||||
const ids = existingByFingerprint.get(fingerprint) ?? [];
|
||||
ids.push(existing.id);
|
||||
existingByFingerprint.set(fingerprint, ids);
|
||||
}
|
||||
const storedIds: string[] = [];
|
||||
const withCurrentAttachmentAuthorization = async <T>(
|
||||
work: (tx: DbTransaction) => Promise<T>,
|
||||
enforceUnchangedSource = false,
|
||||
telegramMedia?: Attachment,
|
||||
): Promise<T> => {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const action = await tx
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.companyId, input.endpoint.companyId),
|
||||
eq(chatActions.endpointId, input.endpoint.id),
|
||||
eq(chatActions.deliveryId, input.deliveryId),
|
||||
eq(chatActions.kind, "inbound_wakeup"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
if (
|
||||
!action ||
|
||||
!["preparing", "issued", "processing", "processed"].includes(
|
||||
action.status,
|
||||
) ||
|
||||
action.payload.issueId !== input.issueId ||
|
||||
action.payload.commentId !== input.issueCommentId
|
||||
)
|
||||
throw forbidden(
|
||||
"The attachment's current chat admission is unavailable",
|
||||
{ code: "chat_action_authorization_changed" },
|
||||
);
|
||||
const current = await authorizeInboundWakeup(tx, action);
|
||||
// Delivery settlement/replay can update these receipts without
|
||||
// taking the issue lock. Lock in delivery -> action order only
|
||||
// after policy authorization; NOWAIT breaks cross-worker lock
|
||||
// inversions and the outer loop retries the whole transaction.
|
||||
const lockedDelivery = await tx
|
||||
.select()
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.companyId, input.endpoint.companyId),
|
||||
eq(chatDeliveries.id, input.deliveryId),
|
||||
),
|
||||
)
|
||||
.for("update", { noWait: true })
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
const lockedAction = await tx
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.companyId, input.endpoint.companyId),
|
||||
eq(chatActions.id, action.id),
|
||||
),
|
||||
)
|
||||
.for("update", { noWait: true })
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
const scopeKeys = [
|
||||
"version",
|
||||
"issueId",
|
||||
"commentId",
|
||||
"agentId",
|
||||
"sessionGeneration",
|
||||
"requestedByActorType",
|
||||
"requestedByActorId",
|
||||
] as const;
|
||||
if (
|
||||
current.delivery.state !== "processing" ||
|
||||
!lockedDelivery ||
|
||||
!lockedAction ||
|
||||
lockedDelivery.state !== "processing" ||
|
||||
lockedDelivery.endpointId !== action.endpointId ||
|
||||
lockedDelivery.conversationId !== action.conversationId ||
|
||||
lockedDelivery.principalId !== action.principalId ||
|
||||
lockedDelivery.attempts !== current.delivery.attempts ||
|
||||
JSON.stringify(lifecycleRuntimeFence(lockedDelivery)) !==
|
||||
JSON.stringify(lifecycleRuntimeFence(current.delivery)) ||
|
||||
lockedAction.endpointId !== action.endpointId ||
|
||||
lockedAction.deliveryId !== action.deliveryId ||
|
||||
lockedAction.conversationId !== action.conversationId ||
|
||||
lockedAction.principalId !== action.principalId ||
|
||||
lockedAction.kind !== "inbound_wakeup" ||
|
||||
!["preparing", "issued", "processing", "processed"].includes(
|
||||
lockedAction.status,
|
||||
) ||
|
||||
scopeKeys.some(
|
||||
(key) => lockedAction.payload[key] !== action.payload[key],
|
||||
)
|
||||
)
|
||||
throw forbidden(
|
||||
"The attachment's current chat admission is unavailable",
|
||||
{ code: "chat_action_authorization_changed" },
|
||||
);
|
||||
if (enforceUnchangedSource) {
|
||||
if (telegramMedia) {
|
||||
const normalized = lockedDelivery.normalizedEvent as {
|
||||
principal?: { externalId?: string };
|
||||
message?: {
|
||||
providerMessageId?: string;
|
||||
attachments?: Array<{ recovery?: unknown }>;
|
||||
};
|
||||
};
|
||||
const fence = lifecycleRuntimeFence(lockedDelivery);
|
||||
const descriptor =
|
||||
input.endpointRuntime.attachmentRecoveryDescriptor(
|
||||
telegramMedia,
|
||||
{
|
||||
threadId: current.conversation.externalThreadId,
|
||||
messageId: normalized.message?.providerMessageId ?? "",
|
||||
principalExternalId: normalized.principal?.externalId,
|
||||
runtimeGeneration: fence?.generation,
|
||||
credentialFingerprint: fence?.credentialFingerprint,
|
||||
},
|
||||
);
|
||||
if (
|
||||
descriptor?.locator.kind !== "telegram_media" ||
|
||||
!normalized.message?.attachments?.some((item) => {
|
||||
const retained = item.recovery as {
|
||||
version?: unknown;
|
||||
provider?: unknown;
|
||||
locator?: Record<string, unknown>;
|
||||
attachment?: Record<string, unknown>;
|
||||
} | null;
|
||||
return (
|
||||
retained?.version === 1 &&
|
||||
retained.provider === "telegram" &&
|
||||
Object.entries(descriptor.locator).every(
|
||||
([key, value]) => retained.locator?.[key] === value,
|
||||
) &&
|
||||
Object.entries(descriptor.attachment).every(
|
||||
([key, value]) => retained.attachment?.[key] === value,
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
throw forbidden(
|
||||
"The media's admitted source is unavailable",
|
||||
{ code: "chat_action_authorization_changed" },
|
||||
);
|
||||
}
|
||||
const [source] = await tx
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.endpoint.companyId),
|
||||
eq(issueComments.issueId, input.issueId),
|
||||
eq(issueComments.id, input.issueCommentId),
|
||||
isNull(issueComments.deletedAt),
|
||||
sql`${issueComments.updatedAt} = ${issueComments.createdAt}`,
|
||||
),
|
||||
)
|
||||
.for("share", { noWait: true });
|
||||
// Lifecycle admission precedes its conversation drain. Pending
|
||||
// source changes already invalidate this image download, even
|
||||
// when the append-only original comment is still untouched.
|
||||
const [lifecycle] = await tx
|
||||
.select({ id: chatDeliveries.id })
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.companyId, input.endpoint.companyId),
|
||||
eq(chatDeliveries.endpointId, input.endpoint.id),
|
||||
or(
|
||||
isNull(chatDeliveries.conversationId),
|
||||
eq(
|
||||
chatDeliveries.conversationId,
|
||||
current.conversation.id,
|
||||
),
|
||||
),
|
||||
ne(chatDeliveries.state, "filtered"),
|
||||
inArray(chatDeliveries.eventKind, [
|
||||
"message_updated",
|
||||
"message_deleted",
|
||||
"message_restored",
|
||||
]),
|
||||
sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(lifecycleRuntimeFence(lockedDelivery))}::jsonb`,
|
||||
sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${lockedDelivery.providerEventId}`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!source || lifecycle)
|
||||
throw forbidden(
|
||||
"The image's admitted source changed before attachment registration",
|
||||
{
|
||||
code: "chat_action_authorization_changed",
|
||||
},
|
||||
);
|
||||
}
|
||||
return await work(tx);
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isExternalChatWaitAuthorizationContention(error) || attempt >= 4)
|
||||
throw error;
|
||||
// Retry the complete short transaction; no network/storage work or
|
||||
// backoff occurs while governance rows are locked.
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 20 * (attempt + 1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
// One shared deadline bounds the sequential batch; an expired batch never
|
||||
// starts another GitHub request. Other providers retain their own policy.
|
||||
const githubBatchSignal =
|
||||
input.endpoint.provider === "github"
|
||||
? AbortSignal.timeout(GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS)
|
||||
: undefined;
|
||||
// This new image-only lane shares one budget across token acquisition and
|
||||
// downloads. DB/storage commit semantics remain explicit and unchanged.
|
||||
const teamsInlineBatchSignal =
|
||||
input.endpoint.provider === "microsoft-teams"
|
||||
? AbortSignal.timeout(10_000)
|
||||
: undefined;
|
||||
for (let attachment of boundedAttachments) {
|
||||
const teamsInlineImage =
|
||||
input.endpoint.provider === "microsoft-teams" &&
|
||||
input.endpointRuntime.attachmentRecoveryDescriptor(attachment)?.locator
|
||||
.kind === "teams_inline_image";
|
||||
const telegramMedia =
|
||||
input.endpoint.provider === "telegram" &&
|
||||
hasTelegramMediaProvenance(attachment);
|
||||
const sourceBoundMedia =
|
||||
teamsInlineImage ||
|
||||
telegramMedia ||
|
||||
input.endpoint.provider === "imessage-photon";
|
||||
const requireCurrentAttachmentAuthorization =
|
||||
input.endpoint.provider === "github" || sourceBoundMedia;
|
||||
try {
|
||||
if (teamsInlineImage && teamsInlineBatchSignal?.aborted) {
|
||||
omit("download_unavailable");
|
||||
continue;
|
||||
}
|
||||
// GitHub's anonymized upload URLs carry no trustworthy MIME metadata.
|
||||
// Resolve public bytes only here, after durable comment admission, and
|
||||
// then apply the same storage/type policy as every native attachment.
|
||||
if (input.endpoint.provider === "github") {
|
||||
await withCurrentAttachmentAuthorization(async () => {});
|
||||
attachment = await prepareGitHubPublicAttachment(
|
||||
attachment,
|
||||
githubBatchSignal,
|
||||
async (request, signal) => {
|
||||
await withCurrentAttachmentAuthorization(async () => {});
|
||||
return (
|
||||
(await input.endpointRuntime.resolveGitHubAttachmentComment?.(
|
||||
request,
|
||||
signal,
|
||||
)) ?? null
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
requireCurrentAttachmentAuthorization &&
|
||||
input.endpoint.provider !== "github"
|
||||
)
|
||||
await withCurrentAttachmentAuthorization(
|
||||
async () => {},
|
||||
sourceBoundMedia,
|
||||
telegramMedia ? attachment : undefined,
|
||||
);
|
||||
if (
|
||||
attachment.size !== undefined &&
|
||||
attachment.size > MAX_ATTACHMENT_BYTES
|
||||
) {
|
||||
omit("declared_too_large");
|
||||
continue;
|
||||
}
|
||||
if (!attachment.fetchData) {
|
||||
omit("download_unavailable");
|
||||
continue;
|
||||
}
|
||||
const originalFilename = sanitizeFilename(attachment.name);
|
||||
let contentType = normalizeUploadAttachmentContentType({
|
||||
contentType: normalizeContentType(
|
||||
attachment.mimeType ?? "application/octet-stream",
|
||||
),
|
||||
originalFilename,
|
||||
isAllowedContentType,
|
||||
});
|
||||
// Reject a provider-declared type before allocating the downloaded
|
||||
// payload. The actual byte length is checked again after the adapter's
|
||||
// bounded fetch contract resolves.
|
||||
// A filename may infer an Office MIME for ordinary documents, but it
|
||||
// must not bypass byte identification for native media without MIME.
|
||||
const identifyTelegram =
|
||||
telegramMedia &&
|
||||
telegramMediaNeedsIdentification(attachment) &&
|
||||
normalizeContentType(
|
||||
attachment.mimeType ?? "application/octet-stream",
|
||||
) === "application/octet-stream";
|
||||
if (!isAllowedContentType(contentType) && !identifyTelegram) {
|
||||
omit("unsupported_type");
|
||||
continue;
|
||||
}
|
||||
const fetched = teamsInlineImage
|
||||
? await input.endpointRuntime.fetchTeamsInlineImage(
|
||||
attachment,
|
||||
teamsInlineBatchSignal!,
|
||||
)
|
||||
: await attachment.fetchData();
|
||||
const body = Buffer.isBuffer(fetched) ? fetched : Buffer.from(fetched);
|
||||
if (body.length === 0) {
|
||||
omit("empty_download");
|
||||
continue;
|
||||
}
|
||||
if (body.length > MAX_ATTACHMENT_BYTES) {
|
||||
omit("downloaded_too_large");
|
||||
continue;
|
||||
}
|
||||
if (identifyTelegram) {
|
||||
contentType =
|
||||
identifyTelegramMedia(attachment, body) ??
|
||||
"application/octet-stream";
|
||||
if (!isAllowedContentType(contentType)) {
|
||||
omit("unsupported_type");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const photonSourceHash = createHash("sha256")
|
||||
.update(body)
|
||||
.digest("hex");
|
||||
if (input.endpoint.provider === "imessage-photon") {
|
||||
await validatePhotonImage(body, contentType);
|
||||
const companion = takePhotonCompanion(body);
|
||||
if (companion?.unavailable) omit("companion_unavailable");
|
||||
else if (companion) {
|
||||
const video: Attachment = { type: "video", name: companion.fileName, mimeType: companion.mimeType, size: companion.data.length, fetchData: async () => companion.data };
|
||||
photonDerivatives.set(video, { source: attachment, sourceHash: photonSourceHash, kind: "live_photo_video" });
|
||||
boundedAttachments.push(video);
|
||||
}
|
||||
if (
|
||||
HEIF_CONTENT_TYPES.has(contentType) &&
|
||||
isAllowedContentType("image/jpeg")
|
||||
) {
|
||||
try {
|
||||
const preview = await photonHeifPreview(body);
|
||||
const derivative: Attachment = {
|
||||
type: "image",
|
||||
name: `${originalFilename} (JPEG preview).jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
size: preview.length,
|
||||
fetchData: async () => preview,
|
||||
};
|
||||
photonDerivatives.set(derivative, { source: attachment, sourceHash: photonSourceHash, kind: "heif_jpeg_preview" });
|
||||
boundedAttachments.push(derivative);
|
||||
} catch {
|
||||
omit("preview_unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
const fingerprint = JSON.stringify([
|
||||
photonSourceHash,
|
||||
body.length,
|
||||
contentType,
|
||||
originalFilename,
|
||||
]);
|
||||
const existingIds = existingByFingerprint.get(fingerprint);
|
||||
const existingId = existingIds?.shift();
|
||||
if (requireCurrentAttachmentAuthorization) {
|
||||
// Revoke during either API resolution or the anonymous byte download
|
||||
// must prevent storage and attachment registration for that input.
|
||||
await withCurrentAttachmentAuthorization(
|
||||
async () => {},
|
||||
sourceBoundMedia,
|
||||
telegramMedia ? attachment : undefined,
|
||||
);
|
||||
}
|
||||
const registerPhotonProvenance = async (attachmentId: string) => {
|
||||
if (input.endpoint.provider !== "imessage-photon") return;
|
||||
const relation = photonDerivatives.get(attachment);
|
||||
if (!relation) {
|
||||
originalPhotonAttachments.set(attachment, attachmentId);
|
||||
return;
|
||||
}
|
||||
const originalAttachmentId =
|
||||
originalPhotonAttachments.get(relation.source);
|
||||
if (!originalAttachmentId)
|
||||
throw new Error("Photon related attachment source is missing");
|
||||
await db
|
||||
.insert(chatActions)
|
||||
.values({
|
||||
companyId: input.endpoint.companyId,
|
||||
endpointId: input.endpoint.id,
|
||||
deliveryId: input.deliveryId,
|
||||
kind: relation.kind === "heif_jpeg_preview" ? "attachment_derivative" : "attachment_companion",
|
||||
providerActionId: `photon-${relation.kind}:${input.deliveryId}:${originalAttachmentId}`,
|
||||
payload: {
|
||||
originalAttachmentId,
|
||||
derivativeAttachmentId: attachmentId,
|
||||
originalSha256: relation.sourceHash,
|
||||
derivativeSha256: photonSourceHash,
|
||||
kind: relation.kind,
|
||||
},
|
||||
status: "processed",
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
};
|
||||
if (existingId) {
|
||||
storedIds.push(existingId);
|
||||
await registerPhotonProvenance(existingId);
|
||||
continue;
|
||||
}
|
||||
const stored = await options.storage.putFile({
|
||||
companyId: input.endpoint.companyId,
|
||||
namespace: `issues/${input.issueId}`,
|
||||
originalFilename,
|
||||
contentType,
|
||||
body,
|
||||
});
|
||||
const registration = {
|
||||
issueId: input.issueId,
|
||||
issueCommentId: input.issueCommentId,
|
||||
provider: stored.provider,
|
||||
objectKey: stored.objectKey,
|
||||
contentType: stored.contentType,
|
||||
byteSize: stored.byteSize,
|
||||
sha256: stored.sha256,
|
||||
originalFilename: stored.originalFilename,
|
||||
createdByUserId: input.actorUserId,
|
||||
};
|
||||
let row;
|
||||
try {
|
||||
row = requireCurrentAttachmentAuthorization
|
||||
? await withCurrentAttachmentAuthorization(
|
||||
(tx) =>
|
||||
issueService(tx as unknown as Db).createAttachment(
|
||||
registration,
|
||||
),
|
||||
sourceBoundMedia,
|
||||
telegramMedia ? attachment : undefined,
|
||||
)
|
||||
: await issuesSvc.createAttachment(registration);
|
||||
} catch (error) {
|
||||
// An explicit authorization denial happens before registration. Do
|
||||
// not delete on ambiguous database errors which may have committed.
|
||||
if (
|
||||
requireCurrentAttachmentAuthorization &&
|
||||
isExternalActionAuthorizationChange(error)
|
||||
)
|
||||
await options.storage
|
||||
.deleteObject(input.endpoint.companyId, stored.objectKey)
|
||||
.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
storedIds.push(row.id);
|
||||
await registerPhotonProvenance(row.id);
|
||||
} catch (error) {
|
||||
if (isExternalActionAuthorizationChange(error)) throw error;
|
||||
if (
|
||||
error instanceof PhotonError &&
|
||||
["attachment_not_ready", "network", "quota", "credentials"].includes(
|
||||
error.code,
|
||||
)
|
||||
)
|
||||
throw error;
|
||||
// Use the closed current-input omission vocabulary consumed by native
|
||||
// prompts; provider-specific diagnostics remain redacted log codes.
|
||||
omit(
|
||||
error instanceof GitHubAttachmentUnavailableError
|
||||
? "download_unavailable"
|
||||
: "processing_failed",
|
||||
);
|
||||
// A malformed or unavailable provider attachment must not strand the
|
||||
// durable text delivery. The rejected file is intentionally omitted;
|
||||
// the delivery remains auditable through its normalized attachment
|
||||
// metadata without persisting provider credentials or download URLs.
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: input.endpoint.id,
|
||||
issueId: input.issueId,
|
||||
...(error instanceof GitHubAttachmentUnavailableError
|
||||
? {
|
||||
deliveryId: input.deliveryId,
|
||||
attachmentDiagnosticCode: error.code,
|
||||
}
|
||||
: {}),
|
||||
attachmentName: sanitizeFilename(attachment.name),
|
||||
error: redactError(error),
|
||||
},
|
||||
"external chat attachment was rejected",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { storedIds, omissionReasons };
|
||||
}
|
||||
|
||||
function attachmentOmissionDetail(result: {
|
||||
omissionReasons: Record<string, number>;
|
||||
}): string | null {
|
||||
const entries = Object.entries(result.omissionReasons).filter(
|
||||
([, count]) => count > 0,
|
||||
);
|
||||
const omitted = entries.reduce((total, [, count]) => total + count, 0);
|
||||
if (!omitted) return null;
|
||||
const reasons = entries
|
||||
.map(([reason, count]) => `${reason.replaceAll("_", " ")}: ${count}`)
|
||||
.join(", ");
|
||||
return `${omitted} external attachment${omitted === 1 ? " was" : "s were"} omitted (${reasons})`;
|
||||
}
|
||||
return { ingestAttachments, attachmentOmissionDetail };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { chatTeamsFileTransfers } from "@paperclipai/db";
|
||||
|
||||
export const fileTransferProjectionColumns = {
|
||||
publicationId: chatTeamsFileTransfers.publicationId,
|
||||
phase: chatTeamsFileTransfers.phase,
|
||||
version: chatTeamsFileTransfers.version,
|
||||
filename: chatTeamsFileTransfers.filename,
|
||||
expiresAt: chatTeamsFileTransfers.expiresAt,
|
||||
consentMessageId: chatTeamsFileTransfers.consentMessageId,
|
||||
responseActivityId: chatTeamsFileTransfers.responseActivityId,
|
||||
fileInfoMessageId: chatTeamsFileTransfers.fileInfoMessageId,
|
||||
operatorConfirmed: sql<boolean>`
|
||||
coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,schema}', '') = 'paperclip.teams.file-resolution.v1'
|
||||
and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,action}', '') = 'mark_delivered'
|
||||
and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,fromPhase}', '') = 'file_info_unknown'
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
assets,
|
||||
chatPublications,
|
||||
issueAttachments,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
SafeChatPublicationPayload,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
isAllowedContentType,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
} from "../../attachment-types.js";
|
||||
|
||||
import type { StorageService } from "../../storage/types.js";
|
||||
|
||||
import {
|
||||
isTeamsInlineImageContentType,
|
||||
TEAMS_INLINE_IMAGE_MAX_BYTES,
|
||||
} from "../chat-teams-inline-images.js";
|
||||
|
||||
import type {
|
||||
FileUpload,
|
||||
} from "chat";
|
||||
|
||||
const OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS = 10_000;
|
||||
|
||||
class OutboundAttachmentHydrationError extends Error {
|
||||
readonly code = "CHAT_ATTACHMENT_HYDRATION_FAILED";
|
||||
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "OutboundAttachmentHydrationError";
|
||||
}
|
||||
}
|
||||
|
||||
class OutboundAttachmentValidationError extends Error {
|
||||
readonly code = "CHAT_ATTACHMENT_VALIDATION_FAILED";
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "OutboundAttachmentValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
function isOutboundAttachmentHydrationError(
|
||||
error: unknown,
|
||||
): error is OutboundAttachmentHydrationError {
|
||||
return error instanceof OutboundAttachmentHydrationError;
|
||||
}
|
||||
|
||||
function isOutboundAttachmentValidationError(
|
||||
error: unknown,
|
||||
): error is OutboundAttachmentValidationError {
|
||||
return error instanceof OutboundAttachmentValidationError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one already-authorized attachment before any provider transport starts.
|
||||
* The database digest, rather than mutable object-store metadata, is the
|
||||
* integrity boundary. Both acquisition and streaming are bounded so a stuck
|
||||
* storage backend cannot hold the publication lane indefinitely.
|
||||
*/
|
||||
export async function hydrateOutboundAttachment(input: {
|
||||
storage: StorageService;
|
||||
companyId: string;
|
||||
objectKey: string;
|
||||
byteSize: number;
|
||||
sha256: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<FileUpload> {
|
||||
const timeoutMs =
|
||||
typeof input.timeoutMs === "number" &&
|
||||
Number.isFinite(input.timeoutMs) &&
|
||||
input.timeoutMs > 0
|
||||
? input.timeoutMs
|
||||
: OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(input.byteSize) ||
|
||||
input.byteSize <= 0 ||
|
||||
input.byteSize > MAX_ATTACHMENT_BYTES ||
|
||||
!/^[0-9a-f]{64}$/iu.test(input.sha256)
|
||||
) {
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment metadata is invalid",
|
||||
);
|
||||
}
|
||||
const acquisitionTimeoutError = new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment storage read timed out",
|
||||
);
|
||||
let acquisitionTimedOut = false;
|
||||
let rejectAcquisition!: (error: Error) => void;
|
||||
const acquisitionTimeout = new Promise<never>((_resolve, reject) => {
|
||||
rejectAcquisition = reject;
|
||||
});
|
||||
const acquisitionTimer = setTimeout(() => {
|
||||
acquisitionTimedOut = true;
|
||||
rejectAcquisition(acquisitionTimeoutError);
|
||||
}, timeoutMs);
|
||||
acquisitionTimer.unref?.();
|
||||
const objectPromise = Promise.resolve()
|
||||
.then(() => input.storage.getObject(input.companyId, input.objectKey))
|
||||
.then((object) => {
|
||||
if (acquisitionTimedOut) object.stream.destroy();
|
||||
return object;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw isOutboundAttachmentHydrationError(error)
|
||||
? error
|
||||
: new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment storage read failed",
|
||||
error,
|
||||
);
|
||||
});
|
||||
let object: Awaited<ReturnType<StorageService["getObject"]>>;
|
||||
try {
|
||||
object = await Promise.race([objectPromise, acquisitionTimeout]);
|
||||
} finally {
|
||||
clearTimeout(acquisitionTimer);
|
||||
}
|
||||
|
||||
if (
|
||||
object.contentLength !== undefined &&
|
||||
object.contentLength !== input.byteSize
|
||||
) {
|
||||
object.stream.destroy();
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment size changed after registration",
|
||||
);
|
||||
}
|
||||
|
||||
const streamTimeoutError = new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment storage read timed out",
|
||||
);
|
||||
const streamTimer = setTimeout(() => {
|
||||
object.stream.destroy(streamTimeoutError);
|
||||
}, timeoutMs);
|
||||
streamTimer.unref?.();
|
||||
const chunks: Buffer[] = [];
|
||||
const digest = createHash("sha256");
|
||||
let bytes = 0;
|
||||
try {
|
||||
for await (const chunk of object.stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
bytes += buffer.length;
|
||||
if (bytes > input.byteSize || bytes > MAX_ATTACHMENT_BYTES) {
|
||||
object.stream.destroy();
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment exceeded its streaming byte limit",
|
||||
);
|
||||
}
|
||||
digest.update(buffer);
|
||||
chunks.push(buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
throw isOutboundAttachmentHydrationError(error)
|
||||
? error
|
||||
: new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment storage read failed",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(streamTimer);
|
||||
}
|
||||
|
||||
const actualDigest = digest.digest("hex");
|
||||
if (bytes !== input.byteSize || actualDigest !== input.sha256.toLowerCase()) {
|
||||
if (!object.stream.destroyed) object.stream.destroy();
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment integrity changed after registration",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
data: Buffer.concat(chunks, bytes),
|
||||
filename: input.filename,
|
||||
mimeType: input.mimeType,
|
||||
};
|
||||
}
|
||||
export { isOutboundAttachmentHydrationError, isOutboundAttachmentValidationError };
|
||||
|
||||
type OutboundAttachmentsOptions = { storage?: StorageService; };
|
||||
|
||||
export function createOutboundAttachments(db: Db, options: OutboundAttachmentsOptions) {
|
||||
|
||||
|
||||
|
||||
async function publicationFiles(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
payload: SafeChatPublicationPayload,
|
||||
preparation: { teamsInlineImagesOnly?: boolean } = {},
|
||||
): Promise<FileUpload[]> {
|
||||
if (!payload.attachmentIds?.length) return [];
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
issueId: issueAttachments.issueId,
|
||||
issueCommentId: issueAttachments.issueCommentId,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
sha256: assets.sha256,
|
||||
originalFilename: assets.originalFilename,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueAttachments.companyId, publication.companyId),
|
||||
eq(issueAttachments.issueId, publication.issueId),
|
||||
inArray(issueAttachments.id, payload.attachmentIds),
|
||||
),
|
||||
);
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
const orderedRows = payload.attachmentIds.map((attachmentId) => {
|
||||
const row = byId.get(attachmentId);
|
||||
if (
|
||||
!row ||
|
||||
(publication.commentId &&
|
||||
row.issueCommentId !== publication.commentId) ||
|
||||
row.byteSize <= 0 ||
|
||||
row.byteSize > MAX_ATTACHMENT_BYTES ||
|
||||
!row.objectKey.trim() ||
|
||||
!/^[0-9a-f]{64}$/iu.test(row.sha256) ||
|
||||
!isAllowedContentType(row.contentType)
|
||||
)
|
||||
throw new OutboundAttachmentValidationError(
|
||||
"Chat publication attachment is invalid or outside its authorized task comment",
|
||||
);
|
||||
return row;
|
||||
});
|
||||
// Picture messages have a different Teams contract from arbitrary files.
|
||||
// Preserve the normal scope/metadata checks above, but never hydrate a
|
||||
// document or oversized image just to discover it needs a task link.
|
||||
if (
|
||||
preparation.teamsInlineImagesOnly &&
|
||||
orderedRows.some(
|
||||
(row) =>
|
||||
row.byteSize > TEAMS_INLINE_IMAGE_MAX_BYTES ||
|
||||
!isTeamsInlineImageContentType(row.contentType),
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
// Authorization and persisted metadata must fail definitively even when
|
||||
// storage is unavailable; do not mask an invalid file as a transient outage.
|
||||
if (!options.storage)
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Attachment storage is unavailable for chat publication",
|
||||
);
|
||||
const files: FileUpload[] = [];
|
||||
for (const row of orderedRows) {
|
||||
files.push(
|
||||
await hydrateOutboundAttachment({
|
||||
storage: options.storage,
|
||||
companyId: publication.companyId,
|
||||
objectKey: row.objectKey,
|
||||
byteSize: row.byteSize,
|
||||
sha256: row.sha256,
|
||||
filename: row.originalFilename ?? `attachment-${row.id}`,
|
||||
mimeType: row.contentType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return files;
|
||||
} catch (error) {
|
||||
if (
|
||||
isOutboundAttachmentHydrationError(error) ||
|
||||
isOutboundAttachmentValidationError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment preparation failed",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { publicationFiles };
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
import { createTelegramCallbackNoticeAuthority } from "./telegram/callback-notices.js";
|
||||
|
||||
import { slackThreadChannelId } from "./slack/messages.js";
|
||||
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "./runtime-types.js";
|
||||
|
||||
import { baseTeamsConversationId } from "./microsoft-teams/resources.js";
|
||||
|
||||
import { dueChatAction, PROVIDER_EFFECT_STALE_MS } from "../chat-action-work.js";
|
||||
|
||||
import { notifyChatDeliveryWork, notifyChatActionWork } from "../chat-work-notifications.js";
|
||||
|
||||
import type { ChatEndpointRuntime } from "../voice/voice-runtime.js";
|
||||
|
||||
import { chatCredentialMutationLease } from "../chat-credential-mutation-lease.js";
|
||||
|
||||
import { projectSafeChatPublicationText } from "../chat-publication-projection.js";
|
||||
|
||||
import { PhotonChatAdapter } from "../photon/adapter.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
eq,
|
||||
inArray,
|
||||
lte,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import {
|
||||
teamsConversationId,
|
||||
} from "../chat-control-chronology.js";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatProvider,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
parseTelegramCallbackReceipt,
|
||||
telegramCallbackThreadId,
|
||||
TELEGRAM_PRIVATE_ACTION_UNAVAILABLE,
|
||||
type TelegramCallbackReceipt,
|
||||
} from "../chat-telegram-ephemeral.js";
|
||||
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import { redactSensitiveText } from "../../redaction.js";
|
||||
|
||||
import { classifyChatPublicationError } from "../chat-publication-errors.js";
|
||||
|
||||
import type {
|
||||
Thread,
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "./runtime-types.js";
|
||||
|
||||
type ProviderEffectTarget = {
|
||||
post: Thread["post"];
|
||||
postEphemeral?: Thread["postEphemeral"];
|
||||
};
|
||||
type ProviderEffectPayload = {
|
||||
version: 1;
|
||||
effect: "thread_message" | "ephemeral_message" | "telegram_callback_notice";
|
||||
telegramCallback?: TelegramCallbackReceipt;
|
||||
telegramAuthorizationSha256?: string;
|
||||
authorizationMode?: "principal" | "safe_notice";
|
||||
threadId: string;
|
||||
userId?: string;
|
||||
text: string;
|
||||
fallbackText?: string;
|
||||
settleDelivery: boolean;
|
||||
completeConversationId?: string;
|
||||
resourceId?: string;
|
||||
runtimeGeneration: number;
|
||||
credentialFingerprint: string;
|
||||
};
|
||||
|
||||
function providerEffectPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): ProviderEffectPayload | null {
|
||||
if (
|
||||
payload.version !== 1 ||
|
||||
(payload.effect !== "thread_message" &&
|
||||
payload.effect !== "ephemeral_message" &&
|
||||
payload.effect !== "telegram_callback_notice") ||
|
||||
typeof payload.threadId !== "string" ||
|
||||
!payload.threadId ||
|
||||
typeof payload.text !== "string" ||
|
||||
!payload.text ||
|
||||
typeof payload.settleDelivery !== "boolean" ||
|
||||
typeof payload.runtimeGeneration !== "number" ||
|
||||
!Number.isSafeInteger(payload.runtimeGeneration) ||
|
||||
typeof payload.credentialFingerprint !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
payload.effect === "ephemeral_message" &&
|
||||
(typeof payload.userId !== "string" || !payload.userId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
for (const key of [
|
||||
"authorizationMode",
|
||||
"userId",
|
||||
"fallbackText",
|
||||
"completeConversationId",
|
||||
"resourceId",
|
||||
] as const) {
|
||||
const value = payload[key];
|
||||
if (value !== undefined && (typeof value !== "string" || !value)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
payload.authorizationMode !== undefined &&
|
||||
payload.authorizationMode !== "principal" &&
|
||||
payload.authorizationMode !== "safe_notice"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (payload.effect === "telegram_callback_notice") {
|
||||
const receipt = parseTelegramCallbackReceipt(payload.telegramCallback);
|
||||
if (
|
||||
!receipt ||
|
||||
payload.authorizationMode !== "safe_notice" ||
|
||||
payload.text !== TELEGRAM_PRIVATE_ACTION_UNAVAILABLE ||
|
||||
payload.threadId !== telegramCallbackThreadId(receipt) ||
|
||||
payload.userId !== receipt.receiverUserId ||
|
||||
payload.settleDelivery !== false ||
|
||||
payload.fallbackText !== undefined ||
|
||||
payload.completeConversationId !== undefined ||
|
||||
typeof payload.telegramAuthorizationSha256 !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(payload.telegramAuthorizationSha256)
|
||||
)
|
||||
return null;
|
||||
} else if (
|
||||
payload.telegramCallback !== undefined ||
|
||||
payload.telegramAuthorizationSha256 !== undefined
|
||||
)
|
||||
return null;
|
||||
return payload as ProviderEffectPayload;
|
||||
}
|
||||
export type { ProviderEffectTarget, ProviderEffectPayload };
|
||||
export { providerEffectPayload };
|
||||
|
||||
type ProviderEffectsOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
invalidateRuntime: (endpointId: string) => Promise<boolean>;
|
||||
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>;
|
||||
redactError: (error: unknown) => string;
|
||||
MAX_ERROR_TEXT: number;
|
||||
nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean;
|
||||
lockTelegramCallbackNoticeAuthority: ReturnType<typeof createTelegramCallbackNoticeAuthority>["lockTelegramCallbackNoticeAuthority"];
|
||||
};
|
||||
|
||||
export function createProviderEffects(db: Db, options: ProviderEffectsOptions) {
|
||||
const { endpointRecord, runtimeCallbackEndpoint, withCredentialMutationLease, runtimeFor, invalidateRuntime, lockCurrentPrincipalAuthorization, redactError, MAX_ERROR_TEXT, nonDirectDestinationAllowed, lockTelegramCallbackNoticeAuthority } = options;
|
||||
|
||||
|
||||
async function stageProviderEffect(
|
||||
database: DbOrTransaction,
|
||||
input: {
|
||||
endpoint: EndpointRow;
|
||||
deliveryId?: string | null;
|
||||
conversationId?: string | null;
|
||||
principalId?: string | null;
|
||||
providerActionId: string;
|
||||
payload: Omit<
|
||||
ProviderEffectPayload,
|
||||
"runtimeGeneration" | "credentialFingerprint"
|
||||
>;
|
||||
runtimeContext: LifecycleRuntimeFence;
|
||||
},
|
||||
) {
|
||||
await notifyChatActionWork(database, "provider_effect");
|
||||
const [inserted] = await database
|
||||
.insert(chatActions)
|
||||
.values({
|
||||
companyId: input.endpoint.companyId,
|
||||
endpointId: input.endpoint.id,
|
||||
deliveryId: input.deliveryId ?? null,
|
||||
conversationId: input.conversationId ?? null,
|
||||
principalId: input.principalId ?? null,
|
||||
kind: "provider_effect",
|
||||
providerActionId: input.providerActionId,
|
||||
payload: {
|
||||
...input.payload,
|
||||
runtimeGeneration: input.runtimeContext.generation,
|
||||
credentialFingerprint: input.runtimeContext.credentialFingerprint,
|
||||
},
|
||||
status: "received",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (inserted) return inserted;
|
||||
return database
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, input.endpoint.id),
|
||||
eq(chatActions.providerActionId, input.providerActionId),
|
||||
eq(chatActions.kind, "provider_effect"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
function providerEffectDisposition(
|
||||
error: unknown,
|
||||
attempt: number,
|
||||
providerEffectCompleted: boolean,
|
||||
) {
|
||||
if (!providerEffectCompleted)
|
||||
return classifyChatPublicationError(error, attempt);
|
||||
return {
|
||||
kind: "delivery_unknown" as const,
|
||||
reason:
|
||||
"Provider effect completed, but Paperclip could not confirm its durable result",
|
||||
};
|
||||
}
|
||||
|
||||
async function finalizeProviderEffectFailure(input: {
|
||||
action: typeof chatActions.$inferSelect;
|
||||
endpoint: EndpointRow;
|
||||
payload: ProviderEffectPayload;
|
||||
disposition: ReturnType<typeof providerEffectDisposition>;
|
||||
attempt: number;
|
||||
}): Promise<boolean> {
|
||||
const failure = redactSensitiveText(input.disposition.reason).slice(
|
||||
0,
|
||||
MAX_ERROR_TEXT,
|
||||
);
|
||||
const retryable = input.disposition.kind === "retry";
|
||||
const retryAt =
|
||||
input.disposition.kind === "retry"
|
||||
? new Date(Date.now() + input.disposition.retryAfterMs)
|
||||
: null;
|
||||
let invalidateCurrentRuntime = false;
|
||||
const finalized = await db.transaction(async (tx) => {
|
||||
// Admission locks the endpoint before its delivery. Keep that order
|
||||
// when failure settlement also needs to change endpoint/resource health.
|
||||
const currentEndpoint =
|
||||
input.disposition.kind === "endpoint_attention" ||
|
||||
input.disposition.kind === "resource_unavailable"
|
||||
? await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
input.endpoint.id,
|
||||
{
|
||||
generation: input.payload.runtimeGeneration,
|
||||
credentialFingerprint: input.payload.credentialFingerprint,
|
||||
},
|
||||
["verifying", "active"],
|
||||
)
|
||||
: null;
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
const [ownedAction] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status:
|
||||
input.disposition.kind === "delivery_unknown"
|
||||
? "delivery_unknown"
|
||||
: "failed",
|
||||
result: {
|
||||
code: `provider_effect_${input.disposition.kind}`,
|
||||
attempts: input.attempt,
|
||||
redactedError: failure,
|
||||
retryable,
|
||||
retryAt: retryAt?.toISOString() ?? null,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, input.action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${input.attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!ownedAction) return false;
|
||||
if (input.payload.settleDelivery && input.action.deliveryId) {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: retryable ? "retry" : "failed",
|
||||
nextAttemptAt: retryAt,
|
||||
redactedError:
|
||||
input.disposition.kind === "delivery_unknown"
|
||||
? `Provider response is unconfirmed: ${failure}`.slice(
|
||||
0,
|
||||
MAX_ERROR_TEXT,
|
||||
)
|
||||
: failure,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, input.action.deliveryId),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (input.disposition.kind === "endpoint_attention" && currentEndpoint) {
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: "attention",
|
||||
healthMessage: "Provider credentials or permissions need attention",
|
||||
lastError: failure,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "disabled",
|
||||
enabled: false,
|
||||
healthStatus: "degraded",
|
||||
healthMessage: "Provider credentials or permissions need attention",
|
||||
lastError: failure,
|
||||
healthCheckedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(toolConnections.id, currentEndpoint.connectionId));
|
||||
invalidateCurrentRuntime = true;
|
||||
} else if (
|
||||
input.disposition.kind === "resource_unavailable" &&
|
||||
input.payload.resourceId &&
|
||||
currentEndpoint
|
||||
) {
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: "unavailable", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.id, input.payload.resourceId),
|
||||
eq(chatEndpointResources.endpointId, currentEndpoint.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (finalized && invalidateCurrentRuntime) {
|
||||
await invalidateRuntime(input.endpoint.id).catch(() => undefined);
|
||||
}
|
||||
return finalized;
|
||||
}
|
||||
|
||||
async function quarantineStaleProviderEffect(
|
||||
action: typeof chatActions.$inferSelect,
|
||||
) {
|
||||
const payload = providerEffectPayload(action.payload);
|
||||
const failure =
|
||||
"Provider delivery could not be confirmed after the worker stopped. The effect will not be replayed automatically.";
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
const [quarantined] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "delivery_unknown",
|
||||
result: {
|
||||
code: "provider_effect_delivery_unknown",
|
||||
redactedError: failure,
|
||||
retryable: false,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
eq(chatActions.updatedAt, action.updatedAt),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${
|
||||
typeof action.result?.attempts === "number"
|
||||
? action.result.attempts
|
||||
: -1
|
||||
}`,
|
||||
lte(
|
||||
chatActions.updatedAt,
|
||||
new Date(Date.now() - PROVIDER_EFFECT_STALE_MS),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (quarantined && payload?.settleDelivery && action.deliveryId) {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "failed",
|
||||
nextAttemptAt: null,
|
||||
redactedError: failure,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function providerEffectThreadResourceId(
|
||||
provider: ChatProvider,
|
||||
threadId: string,
|
||||
): string | null {
|
||||
if (provider === "slack") {
|
||||
return (
|
||||
slackThreadChannelId(threadId) ??
|
||||
(!threadId.includes(":") ? threadId : null)
|
||||
);
|
||||
}
|
||||
if (provider === "telegram") {
|
||||
return /^telegram:([^:]+)/.exec(threadId)?.[1] ?? null;
|
||||
}
|
||||
if (provider === "github") {
|
||||
return /^github:([^:]+)/.exec(threadId)?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
if (provider === "discord") {
|
||||
return /^discord:[^:]+:([^:]+)/.exec(threadId)?.[1] ?? null;
|
||||
}
|
||||
const conversationId = teamsConversationId(threadId);
|
||||
return conversationId ? baseTeamsConversationId(conversationId) : null;
|
||||
}
|
||||
|
||||
async function lockCurrentProviderEffectAuthorization(
|
||||
tx: DbTransaction,
|
||||
action: typeof chatActions.$inferSelect,
|
||||
endpoint: EndpointRow,
|
||||
payload: ProviderEffectPayload,
|
||||
): Promise<boolean> {
|
||||
if (payload.effect === "telegram_callback_notice") {
|
||||
const receipt = parseTelegramCallbackReceipt(payload.telegramCallback);
|
||||
if (!receipt || !action.deliveryId) return false;
|
||||
const delivery = await tx
|
||||
.select()
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.companyId, action.companyId),
|
||||
eq(chatDeliveries.endpointId, action.endpointId),
|
||||
eq(chatDeliveries.state, "filtered"),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const retained =
|
||||
delivery &&
|
||||
parseTelegramCallbackReceipt(delivery.normalizedEvent.telegramCallback);
|
||||
if (
|
||||
!delivery ||
|
||||
delivery.principalId !== action.principalId ||
|
||||
retained?.sourceSha256 !== receipt.sourceSha256 ||
|
||||
delivery.normalizedEvent.telegramAuthorizationSha256 !==
|
||||
payload.telegramAuthorizationSha256
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
(await lockTelegramCallbackNoticeAuthority(
|
||||
tx,
|
||||
endpoint,
|
||||
receipt,
|
||||
action.principalId,
|
||||
)) === payload.telegramAuthorizationSha256
|
||||
);
|
||||
}
|
||||
const conversation = action.conversationId
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.id, action.conversationId),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
if (action.conversationId && !conversation) return false;
|
||||
const inferredResourceId = providerEffectThreadResourceId(
|
||||
endpoint.provider,
|
||||
payload.threadId,
|
||||
);
|
||||
const resource = await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
payload.resourceId || conversation?.resourceId
|
||||
? eq(
|
||||
chatEndpointResources.id,
|
||||
payload.resourceId ?? conversation!.resourceId!,
|
||||
)
|
||||
: inferredResourceId
|
||||
? eq(chatEndpointResources.providerResourceId, inferredResourceId)
|
||||
: sql`false`,
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const slackChannelId =
|
||||
endpoint.provider === "slack"
|
||||
? slackThreadChannelId(payload.threadId)
|
||||
: null;
|
||||
const isDirectMessage =
|
||||
conversation?.isDirectMessage === true ||
|
||||
resource?.type === "direct_message" ||
|
||||
Boolean(slackChannelId && /^D[A-Z0-9]+$/i.test(slackChannelId));
|
||||
const destinationAllowed = isDirectMessage
|
||||
? endpoint.allowDirectMessages
|
||||
: nonDirectDestinationAllowed(endpoint, resource);
|
||||
if (payload.authorizationMode === "safe_notice") return true;
|
||||
if (!destinationAllowed) return false;
|
||||
if (!action.principalId) return false;
|
||||
return (
|
||||
await lockCurrentPrincipalAuthorization(tx, endpoint, action.principalId)
|
||||
).allowed;
|
||||
}
|
||||
|
||||
async function cancelUnauthorizedProviderEffect(
|
||||
tx: DbTransaction,
|
||||
action: typeof chatActions.$inferSelect,
|
||||
attempt: number,
|
||||
): Promise<boolean> {
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
const [cancelled] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code: "provider_effect_no_longer_authorized",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!cancelled) return false;
|
||||
if (action.deliveryId) {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "filtered",
|
||||
processedAt: new Date(),
|
||||
nextAttemptAt: null,
|
||||
redactedError:
|
||||
"Provider reply was suppressed because current chat authorization changed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function processProviderEffect(
|
||||
actionId: string,
|
||||
liveTarget?: ProviderEffectTarget,
|
||||
): Promise<"processed" | "pending" | "failed" | "delivery_unknown"> {
|
||||
const initial = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, actionId),
|
||||
eq(chatActions.kind, "provider_effect"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!initial) return "failed";
|
||||
const initialEndpoint = await endpointRecord(initial.endpointId);
|
||||
if (!initialEndpoint) return "failed";
|
||||
|
||||
const processClaimedEffect = async () => {
|
||||
let action = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(eq(chatActions.id, actionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!action) return "failed";
|
||||
const payload = providerEffectPayload(action.payload);
|
||||
if (!payload) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: { code: "provider_effect_payload_invalid" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatActions.id, action.id));
|
||||
});
|
||||
return "failed";
|
||||
}
|
||||
if (action.status === "processed") return "processed";
|
||||
if (action.status === "delivery_unknown") return "delivery_unknown";
|
||||
if (action.status === "processing") {
|
||||
if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS))
|
||||
return "pending";
|
||||
await quarantineStaleProviderEffect(action);
|
||||
return "delivery_unknown";
|
||||
}
|
||||
if (action.status === "failed") {
|
||||
if (action.result?.retryable !== true) return "failed";
|
||||
const retryAt =
|
||||
typeof action.result.retryAt === "string"
|
||||
? new Date(action.result.retryAt)
|
||||
: null;
|
||||
if (retryAt && retryAt > new Date()) return "pending";
|
||||
} else if (action.status !== "received") {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const attempt =
|
||||
(typeof action.result?.attempts === "number"
|
||||
? action.result.attempts
|
||||
: 0) + 1;
|
||||
const record = await endpointRecord(action.endpointId);
|
||||
let providerEffectCompleted = false;
|
||||
try {
|
||||
if (!record) {
|
||||
throw Object.assign(
|
||||
new Error("Chat endpoint is unavailable for this provider effect"),
|
||||
{ code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" },
|
||||
);
|
||||
}
|
||||
return await withCredentialMutationLease(
|
||||
record.endpoint,
|
||||
async (credentialLease) => {
|
||||
const claim = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const currentAction = await tx
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.kind, "provider_effect"),
|
||||
eq(chatActions.status, action!.status),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!currentAction) return null;
|
||||
const currentEndpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
currentAction.endpointId,
|
||||
{
|
||||
generation: payload.runtimeGeneration,
|
||||
credentialFingerprint: payload.credentialFingerprint,
|
||||
},
|
||||
["verifying", "active"],
|
||||
);
|
||||
const authorized = currentEndpoint
|
||||
? await lockCurrentProviderEffectAuthorization(
|
||||
tx,
|
||||
currentAction,
|
||||
currentEndpoint,
|
||||
payload,
|
||||
)
|
||||
: false;
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
const [transportClaim] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processing",
|
||||
result: { attempts: attempt },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, currentAction.id),
|
||||
eq(chatActions.status, currentAction.status),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!transportClaim) return null;
|
||||
if (!currentEndpoint || !authorized) {
|
||||
const cancelled = await cancelUnauthorizedProviderEffect(
|
||||
tx,
|
||||
transportClaim,
|
||||
attempt,
|
||||
);
|
||||
if (!cancelled) {
|
||||
throw new Error(
|
||||
"Provider effect ownership changed before authorization cancellation",
|
||||
);
|
||||
}
|
||||
return { outcome: "cancelled" as const };
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
return {
|
||||
action: transportClaim,
|
||||
endpoint: currentEndpoint,
|
||||
outcome: "claimed" as const,
|
||||
};
|
||||
});
|
||||
if (!claim) return "pending";
|
||||
if (claim.outcome === "cancelled") return "failed";
|
||||
action = claim.action;
|
||||
|
||||
// `processing` is the durable, single-owner transport claim and
|
||||
// authorization linearization point. Its transaction has committed
|
||||
// before provider I/O begins. The renewable credential lease keeps
|
||||
// runtime identity stable without holding database row locks.
|
||||
let sent: { id: string; threadId: string } | null = null;
|
||||
if (payload.effect === "telegram_callback_notice") {
|
||||
sent = await (
|
||||
await runtimeFor(claim.endpoint)
|
||||
).sendTelegramCallbackNotice(
|
||||
payload.telegramCallback!,
|
||||
payload.text,
|
||||
);
|
||||
} else {
|
||||
const target =
|
||||
liveTarget ??
|
||||
(await runtimeFor(claim.endpoint)).thread(payload.threadId);
|
||||
if (payload.effect === "ephemeral_message") {
|
||||
if (!target.postEphemeral) {
|
||||
if (!payload.fallbackText) {
|
||||
throw Object.assign(
|
||||
new Error("Provider does not support ephemeral messages"),
|
||||
{ code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" },
|
||||
);
|
||||
}
|
||||
sent = await target.post(payload.fallbackText);
|
||||
} else {
|
||||
try {
|
||||
sent = await target.postEphemeral(
|
||||
payload.userId!,
|
||||
payload.text,
|
||||
{ fallbackToDM: false },
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
!payload.fallbackText ||
|
||||
classifyChatPublicationError(error, attempt).kind !==
|
||||
"failed"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!sent && payload.fallbackText) {
|
||||
sent = await target.post(payload.fallbackText);
|
||||
}
|
||||
}
|
||||
} else if (claim.endpoint.provider === "imessage-photon") {
|
||||
const adapter = (await runtimeFor(claim.endpoint)).getProviderAdapter();
|
||||
if (!(adapter instanceof PhotonChatAdapter)) throw new Error("Photon runtime unavailable");
|
||||
const receipt = await adapter.publish(payload.threadId, `effect:${action.id}`, projectSafeChatPublicationText(payload.text), {
|
||||
assertCurrent: async () => { await credentialLease.assertOwned(); },
|
||||
});
|
||||
sent = { id: receipt.id, threadId: payload.threadId };
|
||||
} else {
|
||||
sent = await target.post(payload.text);
|
||||
}
|
||||
}
|
||||
if (!sent) {
|
||||
throw Object.assign(
|
||||
new Error("Provider did not accept the message"),
|
||||
{ code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" },
|
||||
);
|
||||
}
|
||||
providerEffectCompleted = true;
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
// A duplicate webhook holds the endpoint while waiting for the
|
||||
// delivery's unique-index entry. Lock this endpoint first too,
|
||||
// before settling the action/conversation/delivery, so neither
|
||||
// transaction can wait on a row already held by the other.
|
||||
const currentEndpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
claim.endpoint.id,
|
||||
{
|
||||
generation: payload.runtimeGeneration,
|
||||
credentialFingerprint: payload.credentialFingerprint,
|
||||
},
|
||||
["verifying", "active"],
|
||||
);
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
const [completed] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processed",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
providerMessageId: sent.id,
|
||||
threadId: sent.threadId,
|
||||
...(payload.effect === "telegram_callback_notice"
|
||||
? {
|
||||
code:
|
||||
payload.telegramCallback?.chatType === "private"
|
||||
? "telegram_private_api_accepted"
|
||||
: "telegram_ephemeral_api_accepted",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!completed) {
|
||||
throw new Error(
|
||||
"Provider effect ownership changed before commit",
|
||||
);
|
||||
}
|
||||
if (payload.completeConversationId) {
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "completed", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.id, payload.completeConversationId),
|
||||
eq(chatConversations.endpointId, claim.endpoint.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (payload.settleDelivery && action!.deliveryId) {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const [settled] = await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "processed",
|
||||
processedAt: new Date(),
|
||||
nextAttemptAt: null,
|
||||
redactedError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action!.deliveryId),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id });
|
||||
if (!settled) {
|
||||
throw new Error(
|
||||
"Provider effect delivery ownership changed before commit",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (currentEndpoint) {
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({ lastEventAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
return "processed";
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const disposition = providerEffectDisposition(
|
||||
error,
|
||||
attempt,
|
||||
providerEffectCompleted,
|
||||
);
|
||||
try {
|
||||
await finalizeProviderEffectFailure({
|
||||
action,
|
||||
endpoint: record?.endpoint ?? initialEndpoint.endpoint,
|
||||
payload,
|
||||
disposition,
|
||||
attempt,
|
||||
});
|
||||
} catch (finalizationError) {
|
||||
if (disposition.kind === "delivery_unknown") {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
"Provider effect is ambiguous and its durable quarantine failed",
|
||||
{ cause: finalizationError },
|
||||
),
|
||||
{ code: "CHAT_PROVIDER_EFFECT_AMBIGUOUS" },
|
||||
);
|
||||
}
|
||||
throw finalizationError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return processClaimedEffect();
|
||||
}
|
||||
|
||||
async function processPendingProviderEffects(limit = 25) {
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(now.getTime() - PROVIDER_EFFECT_STALE_MS);
|
||||
const actions = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(dueChatAction("provider_effect", now.getTime()))
|
||||
.orderBy(asc(chatActions.createdAt))
|
||||
.limit(limit);
|
||||
for (const action of actions) {
|
||||
if (action.status === "processing" && action.updatedAt <= staleBefore) {
|
||||
await quarantineStaleProviderEffect(action);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await processProviderEffect(action.id);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: action.endpointId,
|
||||
actionId: action.id,
|
||||
error: redactError(error),
|
||||
},
|
||||
"chat provider effect reconciliation failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
return actions.length;
|
||||
}
|
||||
return { stageProviderEffect, processProviderEffect, processPendingProviderEffects };
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "./runtime-types.js";
|
||||
|
||||
import {
|
||||
stableExternalPrincipalId,
|
||||
} from "./message-lifecycle.js";
|
||||
|
||||
import { notifyChatDeliveryWork } from "../chat-work-notifications.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
inArray,
|
||||
isNull,
|
||||
lt,
|
||||
lte,
|
||||
or,
|
||||
sql,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import {
|
||||
externalThreadIdentityCondition,
|
||||
} from "../chat-control-chronology.js";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpointResources,
|
||||
chatMessageLinks,
|
||||
chatPublications,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEventKind,
|
||||
ChatProvider,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
|
||||
import {
|
||||
type ChatSdkCallbackEvent,
|
||||
} from "../chat-sdk-runtime.js";
|
||||
|
||||
import type {
|
||||
Author,
|
||||
ReactionEvent,
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "./runtime-types.js";
|
||||
import { slackThreadChannelId } from "./slack/messages.js";
|
||||
|
||||
// Reactions can beat the transaction that persists a just-sent provider
|
||||
// message link. Keep that narrow gap durable, but never retain an unbound
|
||||
// reaction indefinitely or let it enter the ordinary inbound-message FIFO.
|
||||
const REACTION_LINK_RETRY_DELAY_MS = 250;
|
||||
const REACTION_LINK_MAX_DELAY_MS = 5_000;
|
||||
const REACTION_LINK_MAX_ATTEMPTS = 20;
|
||||
const REACTION_LINK_MAX_AGE_MS = 2 * 60_000;
|
||||
type DeliveryRow = typeof chatDeliveries.$inferSelect;
|
||||
type ConversationRow = typeof chatConversations.$inferSelect;
|
||||
|
||||
type InboundReactionsOptions = {
|
||||
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>;
|
||||
runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise<EndpointRecord | null>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise<{ principal: { id: string } }>;
|
||||
durableExternalThreadIdentity: (threadId: string) => string;
|
||||
recordCurrentMicrosoftTeamsRoute: (endpoint: EndpointRow, context: InboundRuntimeContext | undefined, threadId: string, raw: unknown) => Promise<void>;
|
||||
lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null;
|
||||
deliveryReady: (delivery: DeliveryRow, now: Date) => boolean;
|
||||
redactError: (error: unknown) => string;
|
||||
nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean;
|
||||
reactionLinkPreflightBarrier?: () => Promise<void>;
|
||||
reactionReplayEndpointLockBarrier?: () => Promise<void>;
|
||||
reactionReplayConversationLockBarrier?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createInboundReactions(db: Db, options: InboundReactionsOptions) {
|
||||
const { lockCurrentPrincipalAuthorization, runtimeCallbackRecord, runtimeCallbackEndpoint, ensurePrincipal, durableExternalThreadIdentity, recordCurrentMicrosoftTeamsRoute, lifecycleRuntimeFence, deliveryReady, redactError, nonDirectDestinationAllowed } = options;
|
||||
|
||||
|
||||
function reactionConversationThreadCondition(
|
||||
provider: ChatProvider,
|
||||
threadId: string,
|
||||
): SQL | undefined {
|
||||
const exact = externalThreadIdentityCondition(
|
||||
sql<string>`${chatConversations.externalThreadId}`,
|
||||
threadId,
|
||||
);
|
||||
const slackDmChannel =
|
||||
provider === "slack" ? slackThreadChannelId(threadId) : null;
|
||||
return slackDmChannel && /^D[A-Z0-9-]*$/i.test(slackDmChannel)
|
||||
? or(
|
||||
exact,
|
||||
and(
|
||||
eq(chatConversations.isDirectMessage, true),
|
||||
eq(chatConversations.externalThreadId, `slack:${slackDmChannel}:`),
|
||||
),
|
||||
)
|
||||
: exact;
|
||||
}
|
||||
|
||||
async function reactionConversationForMessage(
|
||||
database: DbOrTransaction,
|
||||
endpoint: EndpointRow,
|
||||
threadId: string,
|
||||
providerMessageId: string,
|
||||
direction?: "outbound",
|
||||
): Promise<ConversationRow | null> {
|
||||
return database
|
||||
.select({ conversation: chatConversations })
|
||||
.from(chatMessageLinks)
|
||||
.innerJoin(
|
||||
chatConversations,
|
||||
and(
|
||||
eq(chatConversations.companyId, chatMessageLinks.companyId),
|
||||
eq(chatConversations.endpointId, chatMessageLinks.endpointId),
|
||||
eq(chatConversations.id, chatMessageLinks.conversationId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, endpoint.companyId),
|
||||
eq(chatMessageLinks.endpointId, endpoint.id),
|
||||
eq(chatMessageLinks.providerMessageId, providerMessageId),
|
||||
direction ? eq(chatMessageLinks.direction, direction) : undefined,
|
||||
reactionConversationThreadCondition(endpoint.provider, threadId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatConversations.sessionGeneration))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]?.conversation ?? null);
|
||||
}
|
||||
|
||||
async function lockReactionConversationForMessage(
|
||||
tx: DbTransaction,
|
||||
endpoint: EndpointRow,
|
||||
threadId: string,
|
||||
providerMessageId: string,
|
||||
direction?: "outbound",
|
||||
): Promise<ConversationRow | null> {
|
||||
const candidate = await reactionConversationForMessage(
|
||||
tx,
|
||||
endpoint,
|
||||
threadId,
|
||||
providerMessageId,
|
||||
direction,
|
||||
);
|
||||
if (!candidate) return null;
|
||||
const current = await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.id, candidate.id),
|
||||
),
|
||||
)
|
||||
// Publication settlement inserts the outbound message link before it
|
||||
// updates the endpoint. NO KEY UPDATE serializes state changes while
|
||||
// remaining compatible with that link's conversation FK KEY SHARE.
|
||||
.for("no key update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!current ||
|
||||
!(current.isDirectMessage
|
||||
? ["active", "waiting", "completed"].includes(current.state)
|
||||
: ["active", "waiting"].includes(current.state))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const linkStillExists = await tx
|
||||
.select({ id: chatMessageLinks.id })
|
||||
.from(chatMessageLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, endpoint.companyId),
|
||||
eq(chatMessageLinks.endpointId, endpoint.id),
|
||||
eq(chatMessageLinks.conversationId, current.id),
|
||||
eq(chatMessageLinks.providerMessageId, providerMessageId),
|
||||
direction ? eq(chatMessageLinks.direction, direction) : undefined,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
return linkStillExists ? current : null;
|
||||
}
|
||||
|
||||
async function streamingReactionConversation(
|
||||
database: DbOrTransaction,
|
||||
endpoint: EndpointRow,
|
||||
threadId: string,
|
||||
): Promise<ConversationRow | null> {
|
||||
const candidates = await database
|
||||
.selectDistinct({
|
||||
conversation: chatConversations,
|
||||
sessionGeneration: chatConversations.sessionGeneration,
|
||||
})
|
||||
.from(chatConversations)
|
||||
.innerJoin(
|
||||
chatPublications,
|
||||
and(
|
||||
eq(chatPublications.companyId, chatConversations.companyId),
|
||||
eq(chatPublications.endpointId, chatConversations.endpointId),
|
||||
eq(chatPublications.conversationId, chatConversations.id),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
or(
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
and(
|
||||
eq(chatConversations.isDirectMessage, true),
|
||||
eq(chatConversations.state, "completed"),
|
||||
),
|
||||
),
|
||||
reactionConversationThreadCondition(endpoint.provider, threadId),
|
||||
eq(chatPublications.state, "streaming"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatConversations.sessionGeneration))
|
||||
.limit(2);
|
||||
if (candidates.length !== 1) return null;
|
||||
return candidates[0]!.conversation;
|
||||
}
|
||||
|
||||
async function lockStreamingReactionConversation(
|
||||
tx: DbTransaction,
|
||||
endpoint: EndpointRow,
|
||||
threadId: string,
|
||||
): Promise<ConversationRow | null> {
|
||||
const candidate = await streamingReactionConversation(
|
||||
tx,
|
||||
endpoint,
|
||||
threadId,
|
||||
);
|
||||
if (!candidate) return null;
|
||||
const current = await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.id, candidate.id),
|
||||
or(
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
and(
|
||||
eq(chatConversations.isDirectMessage, true),
|
||||
eq(chatConversations.state, "completed"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Keep the same lock strength/order as the exact-link path above so an
|
||||
// in-flight message-link insert cannot deadlock reaction admission.
|
||||
.for("no key update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!current) return null;
|
||||
const stillStreaming = await tx
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, endpoint.companyId),
|
||||
eq(chatPublications.endpointId, endpoint.id),
|
||||
eq(chatPublications.conversationId, current.id),
|
||||
eq(chatPublications.state, "streaming"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
return stillStreaming ? current : null;
|
||||
}
|
||||
|
||||
async function authorizeReactionConversation(
|
||||
tx: DbTransaction,
|
||||
endpoint: EndpointRow,
|
||||
conversation: ConversationRow,
|
||||
principalId: string,
|
||||
): Promise<boolean> {
|
||||
const resource =
|
||||
conversation.resourceId && !conversation.isDirectMessage
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.id, conversation.resourceId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const destinationAllowed = conversation.isDirectMessage
|
||||
? endpoint.allowDirectMessages
|
||||
: nonDirectDestinationAllowed(endpoint, resource);
|
||||
if (!destinationAllowed) return false;
|
||||
return (await lockCurrentPrincipalAuthorization(tx, endpoint, principalId))
|
||||
.allowed;
|
||||
}
|
||||
|
||||
async function handleReaction(
|
||||
event: ChatSdkCallbackEvent<ReactionEvent>,
|
||||
runtimeContext: RuntimeContext,
|
||||
) {
|
||||
const record = await runtimeCallbackRecord(
|
||||
event.endpointId,
|
||||
runtimeContext,
|
||||
["active"],
|
||||
);
|
||||
if (
|
||||
!record ||
|
||||
record.endpoint.status !== "active" ||
|
||||
record.endpoint.provider !== event.provider ||
|
||||
record.endpoint.capabilities.reactions !== true ||
|
||||
event.event.user.isMe ||
|
||||
event.event.user.isBot === true ||
|
||||
event.event.user.isSystem
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// A DM can have a newer task generation while this reaction still belongs
|
||||
// to an older linked message. Resolve its exact lineage before choosing a
|
||||
// generation; never attach it to the latest task merely sharing the chat.
|
||||
// A just-sent provider message can be visible before its publication/link
|
||||
// transaction commits. Only that exact thread's streaming publication is
|
||||
// enough evidence to stage an otherwise-unbound reaction for replay.
|
||||
let conversation = await reactionConversationForMessage(
|
||||
db,
|
||||
record.endpoint,
|
||||
event.event.threadId,
|
||||
event.event.messageId,
|
||||
);
|
||||
await options.reactionLinkPreflightBarrier?.();
|
||||
const plausibleStreamingConversation = conversation
|
||||
? null
|
||||
: await streamingReactionConversation(
|
||||
db,
|
||||
record.endpoint,
|
||||
event.event.threadId,
|
||||
);
|
||||
if (!conversation && !plausibleStreamingConversation) {
|
||||
// Settlement can commit between the first exact-link lookup and the
|
||||
// streaming-publication lookup. Recheck the durable link before treating
|
||||
// the callback as an arbitrary unknown message.
|
||||
conversation = await reactionConversationForMessage(
|
||||
db,
|
||||
record.endpoint,
|
||||
event.event.threadId,
|
||||
event.event.messageId,
|
||||
);
|
||||
if (!conversation) return;
|
||||
}
|
||||
|
||||
const principal = await ensurePrincipal(
|
||||
record.endpoint,
|
||||
event.event.user,
|
||||
event.event.raw,
|
||||
);
|
||||
let rawFingerprint: string;
|
||||
try {
|
||||
rawFingerprint = createHash("sha256")
|
||||
.update(JSON.stringify(event.event.raw ?? null))
|
||||
.digest("hex")
|
||||
.slice(0, 24);
|
||||
} catch {
|
||||
rawFingerprint = "unavailable";
|
||||
}
|
||||
const eventKind: ChatEventKind = event.event.added
|
||||
? "reaction_added"
|
||||
: "reaction_removed";
|
||||
const providerEventId = [
|
||||
eventKind,
|
||||
durableExternalThreadIdentity(event.event.threadId),
|
||||
event.event.messageId,
|
||||
stableExternalPrincipalId(
|
||||
record.endpoint.provider,
|
||||
event.event.user,
|
||||
event.event.raw,
|
||||
),
|
||||
event.event.rawEmoji,
|
||||
rawFingerprint,
|
||||
].join(":");
|
||||
const normalizedEvent = {
|
||||
providerEventId,
|
||||
kind: eventKind,
|
||||
conversation: { externalThreadId: event.event.threadId },
|
||||
message: { providerMessageId: event.event.messageId },
|
||||
reaction: {
|
||||
emoji: event.event.emoji.name,
|
||||
rawEmoji: event.event.rawEmoji,
|
||||
added: event.event.added,
|
||||
},
|
||||
runtimeContext: {
|
||||
generation: runtimeContext.generation,
|
||||
credentialFingerprint: runtimeContext.credentialFingerprint,
|
||||
},
|
||||
};
|
||||
const admitted = await db.transaction(async (tx) => {
|
||||
const currentEndpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
event.endpointId,
|
||||
runtimeContext,
|
||||
["active"],
|
||||
);
|
||||
if (!currentEndpoint) return false;
|
||||
const linkedConversation = await lockReactionConversationForMessage(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
event.event.threadId,
|
||||
event.event.messageId,
|
||||
);
|
||||
const currentConversation =
|
||||
linkedConversation ??
|
||||
(await lockStreamingReactionConversation(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
event.event.threadId,
|
||||
));
|
||||
if (
|
||||
!currentConversation ||
|
||||
!(await authorizeReactionConversation(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
currentConversation,
|
||||
principal.principal.id,
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const now = new Date();
|
||||
if (linkedConversation) {
|
||||
const expiryCutoff = new Date(now.getTime() - REACTION_LINK_MAX_AGE_MS);
|
||||
const hasOutboundLink = await tx
|
||||
.select({ id: chatMessageLinks.id })
|
||||
.from(chatMessageLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, currentEndpoint.companyId),
|
||||
eq(chatMessageLinks.endpointId, currentEndpoint.id),
|
||||
eq(chatMessageLinks.conversationId, linkedConversation.id),
|
||||
eq(chatMessageLinks.providerMessageId, event.event.messageId),
|
||||
eq(chatMessageLinks.direction, "outbound"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const reconciled = hasOutboundLink
|
||||
? await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
conversationId: linkedConversation.id,
|
||||
state: "processed",
|
||||
attempts: sql`${chatDeliveries.attempts} + 1`,
|
||||
nextAttemptAt: null,
|
||||
processedAt: now,
|
||||
redactedError: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, currentEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
inArray(chatDeliveries.state, ["received", "retry"]),
|
||||
gt(chatDeliveries.receivedAt, expiryCutoff),
|
||||
lt(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS),
|
||||
sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`,
|
||||
sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id })
|
||||
: [];
|
||||
if (reconciled.length > 0) return true;
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const expired = await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "filtered",
|
||||
nextAttemptAt: null,
|
||||
processedAt: now,
|
||||
redactedError:
|
||||
"Reaction target did not become linked before replay expiry",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, currentEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
inArray(chatDeliveries.state, ["received", "retry"]),
|
||||
or(
|
||||
lte(chatDeliveries.receivedAt, expiryCutoff),
|
||||
gte(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS),
|
||||
),
|
||||
sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`,
|
||||
sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id });
|
||||
if (expired.length > 0) return false;
|
||||
}
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const inserted = await tx
|
||||
.insert(chatDeliveries)
|
||||
.values({
|
||||
companyId: currentEndpoint.companyId,
|
||||
endpointId: currentEndpoint.id,
|
||||
conversationId: linkedConversation?.id ?? null,
|
||||
principalId: principal.principal.id,
|
||||
providerEventId,
|
||||
deduplicationKey: createHash("sha256")
|
||||
.update(providerEventId)
|
||||
.digest("hex"),
|
||||
eventKind,
|
||||
normalizedEvent,
|
||||
state: linkedConversation ? "processed" : "received",
|
||||
attempts: linkedConversation ? 1 : 0,
|
||||
nextAttemptAt: linkedConversation
|
||||
? null
|
||||
: new Date(now.getTime() + REACTION_LINK_RETRY_DELAY_MS),
|
||||
processedAt: linkedConversation ? now : null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: chatDeliveries.id });
|
||||
if (inserted.length > 0) return true;
|
||||
return tx
|
||||
.select({ id: chatDeliveries.id })
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, currentEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
inArray(chatDeliveries.state, ["received", "retry", "processed"]),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
});
|
||||
if (admitted) {
|
||||
await recordCurrentMicrosoftTeamsRoute(
|
||||
record.endpoint,
|
||||
runtimeContext,
|
||||
event.event.threadId,
|
||||
event.event.raw,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedPendingReaction(delivery: DeliveryRow): {
|
||||
added: boolean;
|
||||
emoji: string;
|
||||
messageId: string;
|
||||
rawEmoji: string;
|
||||
runtimeContext: LifecycleRuntimeFence;
|
||||
threadId: string;
|
||||
} | null {
|
||||
if (
|
||||
delivery.eventKind !== "reaction_added" &&
|
||||
delivery.eventKind !== "reaction_removed"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalized = delivery.normalizedEvent;
|
||||
if (
|
||||
!normalized ||
|
||||
typeof normalized !== "object" ||
|
||||
Array.isArray(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const value = normalized as Record<string, unknown>;
|
||||
const conversation = value.conversation;
|
||||
const message = value.message;
|
||||
const reaction = value.reaction;
|
||||
const runtimeContext = lifecycleRuntimeFence(delivery);
|
||||
if (
|
||||
!conversation ||
|
||||
typeof conversation !== "object" ||
|
||||
Array.isArray(conversation) ||
|
||||
!message ||
|
||||
typeof message !== "object" ||
|
||||
Array.isArray(message) ||
|
||||
!reaction ||
|
||||
typeof reaction !== "object" ||
|
||||
Array.isArray(reaction) ||
|
||||
!runtimeContext
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const threadId = (conversation as Record<string, unknown>).externalThreadId;
|
||||
const messageId = (message as Record<string, unknown>).providerMessageId;
|
||||
const reactionRecord = reaction as Record<string, unknown>;
|
||||
const added = reactionRecord.added;
|
||||
const emoji = reactionRecord.emoji;
|
||||
const rawEmoji = reactionRecord.rawEmoji;
|
||||
return typeof threadId === "string" &&
|
||||
threadId.length > 0 &&
|
||||
typeof messageId === "string" &&
|
||||
messageId.length > 0 &&
|
||||
typeof added === "boolean" &&
|
||||
added === (delivery.eventKind === "reaction_added") &&
|
||||
typeof emoji === "string" &&
|
||||
emoji.length > 0 &&
|
||||
typeof rawEmoji === "string" &&
|
||||
rawEmoji.length > 0
|
||||
? { added, emoji, messageId, rawEmoji, runtimeContext, threadId }
|
||||
: null;
|
||||
}
|
||||
|
||||
async function processPendingReactionDelivery(
|
||||
candidate: DeliveryRow,
|
||||
): Promise<void> {
|
||||
const reaction = normalizedPendingReaction(candidate);
|
||||
if (!reaction || !candidate.principalId) {
|
||||
const now = new Date();
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
return tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "failed",
|
||||
nextAttemptAt: null,
|
||||
processedAt: now,
|
||||
redactedError: "Normalized reaction delivery is incomplete",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, candidate.id),
|
||||
inArray(chatDeliveries.state, ["received", "retry"]),
|
||||
),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const principalId = candidate.principalId;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const currentEndpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
candidate.endpointId,
|
||||
reaction.runtimeContext,
|
||||
["active"],
|
||||
);
|
||||
let conversation: ConversationRow | null = null;
|
||||
let disposition:
|
||||
| { kind: "processed"; conversationId: string }
|
||||
| { kind: "retry" }
|
||||
| { kind: "filtered"; reason: string };
|
||||
|
||||
if (!currentEndpoint) {
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction callback belonged to an unavailable runtime",
|
||||
};
|
||||
} else {
|
||||
await options.reactionReplayEndpointLockBarrier?.();
|
||||
conversation = await lockReactionConversationForMessage(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
reaction.threadId,
|
||||
reaction.messageId,
|
||||
"outbound",
|
||||
);
|
||||
if (conversation) {
|
||||
await options.reactionReplayConversationLockBarrier?.();
|
||||
}
|
||||
if (conversation) {
|
||||
disposition = (await authorizeReactionConversation(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
conversation,
|
||||
principalId,
|
||||
))
|
||||
? { kind: "processed", conversationId: conversation.id }
|
||||
: {
|
||||
kind: "filtered",
|
||||
reason:
|
||||
"Reaction destination or principal is no longer authorized",
|
||||
};
|
||||
} else {
|
||||
conversation = await lockStreamingReactionConversation(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
reaction.threadId,
|
||||
);
|
||||
if (conversation) {
|
||||
await options.reactionReplayConversationLockBarrier?.();
|
||||
}
|
||||
if (!conversation) {
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction target was not linked to a sent message",
|
||||
};
|
||||
} else {
|
||||
disposition = (await authorizeReactionConversation(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
conversation,
|
||||
principalId,
|
||||
))
|
||||
? { kind: "retry" }
|
||||
: {
|
||||
kind: "filtered",
|
||||
reason:
|
||||
"Reaction destination or principal is no longer authorized",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoint, conversation, destination and identity locks are acquired
|
||||
// before the delivery row so pause/revoke and replay use one lock order.
|
||||
const locked = await tx
|
||||
.select()
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, candidate.id),
|
||||
inArray(chatDeliveries.state, ["received", "retry"]),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
// Authorization locks may wait behind a concurrent lifecycle mutation.
|
||||
// Sample the expiry clock only after this delivery is exclusively held.
|
||||
const decisionAt = new Date();
|
||||
if (!locked || !deliveryReady(locked, decisionAt)) return;
|
||||
const lockedReaction = normalizedPendingReaction(locked);
|
||||
if (
|
||||
!lockedReaction ||
|
||||
lockedReaction.runtimeContext.generation !==
|
||||
reaction.runtimeContext.generation ||
|
||||
lockedReaction.runtimeContext.credentialFingerprint !==
|
||||
reaction.runtimeContext.credentialFingerprint
|
||||
) {
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction delivery identity changed before replay",
|
||||
};
|
||||
}
|
||||
|
||||
const nextAttempts = locked.attempts + 1;
|
||||
const expired =
|
||||
decisionAt.getTime() - locked.receivedAt.getTime() >=
|
||||
REACTION_LINK_MAX_AGE_MS;
|
||||
if (expired || locked.attempts >= REACTION_LINK_MAX_ATTEMPTS) {
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction target did not become linked before replay expiry",
|
||||
};
|
||||
}
|
||||
if (disposition.kind === "retry" && !expired) {
|
||||
if (nextAttempts < REACTION_LINK_MAX_ATTEMPTS) {
|
||||
const delay = Math.min(
|
||||
REACTION_LINK_RETRY_DELAY_MS *
|
||||
2 ** Math.min(Math.max(locked.attempts, 0), 5),
|
||||
REACTION_LINK_MAX_DELAY_MS,
|
||||
);
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "retry",
|
||||
attempts: nextAttempts,
|
||||
nextAttemptAt: new Date(decisionAt.getTime() + delay),
|
||||
redactedError: "Waiting for the sent message link",
|
||||
updatedAt: decisionAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, locked.id),
|
||||
eq(chatDeliveries.state, locked.state),
|
||||
eq(chatDeliveries.attempts, locked.attempts),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction target did not become linked before retry expiry",
|
||||
};
|
||||
} else if (disposition.kind === "retry") {
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction target did not become linked before age expiry",
|
||||
};
|
||||
}
|
||||
|
||||
const processedAt = new Date();
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set(
|
||||
disposition.kind === "processed"
|
||||
? {
|
||||
conversationId: disposition.conversationId,
|
||||
state: "processed",
|
||||
attempts: nextAttempts,
|
||||
nextAttemptAt: null,
|
||||
processedAt,
|
||||
redactedError: null,
|
||||
updatedAt: processedAt,
|
||||
}
|
||||
: {
|
||||
state: "filtered",
|
||||
attempts: nextAttempts,
|
||||
nextAttemptAt: null,
|
||||
processedAt,
|
||||
redactedError: disposition.reason,
|
||||
updatedAt: processedAt,
|
||||
},
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, locked.id),
|
||||
eq(chatDeliveries.state, locked.state),
|
||||
eq(chatDeliveries.attempts, locked.attempts),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function processPendingReactionDeliveries(
|
||||
limit: number,
|
||||
onlyDeliveryId?: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.provider = 'agentmail')`,
|
||||
onlyDeliveryId ? eq(chatDeliveries.id, onlyDeliveryId) : undefined,
|
||||
inArray(chatDeliveries.eventKind, [
|
||||
"reaction_added",
|
||||
"reaction_removed",
|
||||
]),
|
||||
or(
|
||||
and(
|
||||
eq(chatDeliveries.state, "received"),
|
||||
or(
|
||||
isNull(chatDeliveries.nextAttemptAt),
|
||||
lte(chatDeliveries.nextAttemptAt, now),
|
||||
),
|
||||
),
|
||||
and(
|
||||
eq(chatDeliveries.state, "retry"),
|
||||
or(
|
||||
isNull(chatDeliveries.nextAttemptAt),
|
||||
lte(chatDeliveries.nextAttemptAt, now),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatDeliveries.receivedAt), asc(chatDeliveries.id))
|
||||
.limit(limit);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await processPendingReactionDelivery(candidate);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: candidate.endpointId,
|
||||
deliveryId: candidate.id,
|
||||
error: redactError(error),
|
||||
},
|
||||
"chat reaction-link reconciliation failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
return candidates.length;
|
||||
}
|
||||
return { handleReaction, processPendingReactionDeliveries };
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import type { ChatRuntime } from "../chat-runtime.js";
|
||||
import type { EndpointRow, RuntimeContext, LifecycleRuntimeFence } from "./runtime-types.js";
|
||||
|
||||
import { dueChatAction, PROVIDER_EFFECT_STALE_MS } from "../chat-action-work.js";
|
||||
|
||||
import { notifyChatActionWork } from "../chat-work-notifications.js";
|
||||
|
||||
import type { ChatEndpointRuntime } from "../voice/voice-runtime.js";
|
||||
|
||||
import {
|
||||
receiptReactionPayload,
|
||||
type ReceiptReactionPayload,
|
||||
} from "../chat-receipt-reactions.js";
|
||||
|
||||
import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../chat-credential-mutation-lease.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
eq,
|
||||
like,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatDeliveries,
|
||||
chatEndpoints,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEndpointSetupState,
|
||||
ChatProvider,
|
||||
ToolCredentialSecretRef,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
parseGitHubReceiptIdentity,
|
||||
type GitHubReceiptIdentity,
|
||||
} from "../chat-github-receipt-reactions.js";
|
||||
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
|
||||
import { classifyChatPublicationError } from "../chat-publication-errors.js";
|
||||
|
||||
import type {
|
||||
Message,
|
||||
Thread,
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "./runtime-types.js";
|
||||
import { isSlackReceiptReactionAlreadyApplied } from "./slack/messages.js";
|
||||
|
||||
type ReceiptReactionsOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
fetchImpl: typeof fetch;
|
||||
runtime: Pick<ChatRuntime, "get">;
|
||||
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
||||
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
|
||||
redactError: (error: unknown) => string;
|
||||
MAX_ERROR_TEXT: number;
|
||||
receiptReactionTransportBarrier?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createReceiptReactions(db: Db, options: ReceiptReactionsOptions) {
|
||||
const { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, runtimeFor, fetchImpl, runtime, runtimeGeneration, credentialFingerprint, redactError, MAX_ERROR_TEXT } = options;
|
||||
|
||||
|
||||
async function addReceiptReaction(input: {
|
||||
deliveryId: string;
|
||||
endpoint: EndpointRow;
|
||||
message: Message;
|
||||
runtimeContext?: LifecycleRuntimeFence;
|
||||
thread: Thread;
|
||||
}): Promise<void> {
|
||||
// GitHub acknowledges through one editable response comment. Continue to
|
||||
// drain legacy eyes removals, but never create new eyes for these requests.
|
||||
if (input.endpoint.provider === "github") return;
|
||||
const record = await endpointRecord(input.endpoint.id);
|
||||
if (!record) return;
|
||||
// A live SDK object remains bound to the credentials that authenticated
|
||||
// its callback. Preserve that original fence rather than sampling the
|
||||
// endpoint again after a rotation and accidentally blessing an obsolete
|
||||
// adapter with the new generation.
|
||||
const context = input.runtimeContext ?? runtimeContextForRecord(record);
|
||||
const [inserted] = await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
return tx
|
||||
.insert(chatActions)
|
||||
.values({
|
||||
companyId: input.endpoint.companyId,
|
||||
endpointId: input.endpoint.id,
|
||||
deliveryId: input.deliveryId,
|
||||
kind: "receipt_reaction",
|
||||
providerActionId: `receipt_reaction:${input.deliveryId}`,
|
||||
payload: {
|
||||
version: 1,
|
||||
operation: "add",
|
||||
threadId: input.thread.id,
|
||||
messageId: input.message.id,
|
||||
reaction: "eyes",
|
||||
runtimeGeneration: context.generation,
|
||||
credentialFingerprint: context.credentialFingerprint,
|
||||
} satisfies ReceiptReactionPayload,
|
||||
status: "received",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: chatActions.id });
|
||||
});
|
||||
const actionId =
|
||||
inserted?.id ??
|
||||
(await db
|
||||
.select({ id: chatActions.id })
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, input.endpoint.id),
|
||||
eq(
|
||||
chatActions.providerActionId,
|
||||
`receipt_reaction:${input.deliveryId}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.id ?? null));
|
||||
if (!actionId) return;
|
||||
await options.receiptReactionTransportBarrier?.();
|
||||
await processReceiptReaction(actionId);
|
||||
}
|
||||
|
||||
async function processReceiptReaction(actionId: string): Promise<void> {
|
||||
const initialAction = await db
|
||||
.select({ endpointId: chatActions.endpointId })
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, actionId),
|
||||
eq(chatActions.kind, "receipt_reaction"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!initialAction) return;
|
||||
const initialEndpoint = await endpointRecord(initialAction.endpointId);
|
||||
if (!initialEndpoint) return;
|
||||
// Receipt reactions are GitHub repository mutations too. Serialize their
|
||||
// final runtime-fence check and provider call with reconnect, secret
|
||||
// rotation, pause, and removal so credentials cannot become obsolete
|
||||
// between authorization and transport.
|
||||
await withCredentialMutationLease(initialEndpoint.endpoint, (lease) =>
|
||||
processReceiptReactionWithCredentialLease(actionId, lease),
|
||||
);
|
||||
}
|
||||
|
||||
async function processReceiptReactionWithCredentialLease(
|
||||
actionId: string,
|
||||
credentialLease: CredentialMutationLeaseGuard,
|
||||
): Promise<void> {
|
||||
let action = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, actionId),
|
||||
eq(chatActions.kind, "receipt_reaction"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!action || ["processed", "cancelled"].includes(action.status)) return;
|
||||
const payload = receiptReactionPayload(action.payload);
|
||||
if (!payload) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: {
|
||||
code: "receipt_reaction_payload_invalid",
|
||||
retryable: false,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatActions.id, action.id));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (payload.operation === "add" && action.deliveryId) {
|
||||
const terminalRemoval = await db
|
||||
.select({ id: chatActions.id })
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, action.endpointId),
|
||||
eq(
|
||||
chatActions.providerActionId,
|
||||
`receipt_reaction_remove:${action.deliveryId}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (terminalRemoval) {
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
...(typeof action!.result?.attempts === "number"
|
||||
? { attempts: action!.result.attempts }
|
||||
: {}),
|
||||
code: "receipt_reaction_superseded_by_terminal_publication",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.status, action!.status),
|
||||
),
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (action.status === "processing") {
|
||||
if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS))
|
||||
return;
|
||||
// Adding or removing the same reaction is idempotent. A worker
|
||||
// interruption can be retried safely without an ambiguous
|
||||
// duplicate-message boundary.
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: {
|
||||
...(action.result ?? {}),
|
||||
retryable: true,
|
||||
retryAt: new Date().toISOString(),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
eq(chatActions.updatedAt, action.updatedAt),
|
||||
),
|
||||
);
|
||||
});
|
||||
action = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(eq(chatActions.id, action.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!action) return;
|
||||
}
|
||||
if (action.status === "failed") {
|
||||
if (action.result?.retryable !== true) return;
|
||||
const retryAt =
|
||||
typeof action.result.retryAt === "string"
|
||||
? new Date(action.result.retryAt)
|
||||
: null;
|
||||
if (retryAt && retryAt > new Date()) return;
|
||||
} else if (action.status !== "received") {
|
||||
return;
|
||||
}
|
||||
const attempt =
|
||||
(typeof action.result?.attempts === "number" ? action.result.attempts : 0) +
|
||||
1;
|
||||
const retainedGitHubReceipt = parseGitHubReceiptIdentity(
|
||||
action.result?.githubReceipt,
|
||||
);
|
||||
const [claimed] = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
const rows = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processing",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
...(retainedGitHubReceipt
|
||||
? { githubReceipt: retainedGitHubReceipt }
|
||||
: {}),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.status, action!.status),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
await credentialLease.assertOwned(tx);
|
||||
return rows;
|
||||
});
|
||||
if (!claimed) return;
|
||||
|
||||
let reactionProvider: ChatProvider | null = null;
|
||||
let githubReceipt: GitHubReceiptIdentity | null = retainedGitHubReceipt;
|
||||
try {
|
||||
const record = await endpointRecord(action.endpointId);
|
||||
if (
|
||||
!record ||
|
||||
!["verifying", "active"].includes(record.endpoint.status) ||
|
||||
runtimeGeneration(record.endpoint.setup) !== payload.runtimeGeneration ||
|
||||
credentialFingerprint(record.credentialSecretRefs) !==
|
||||
payload.credentialFingerprint
|
||||
) {
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code: "receipt_reaction_runtime_superseded",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
reactionProvider = record.endpoint.provider;
|
||||
// Resolve the adapter only after acquiring the credential lease and
|
||||
// proving the action's durable generation/fingerprint is still current.
|
||||
// Never reuse the live callback's adapter: it may hold an installation
|
||||
// token minted by a superseded private key.
|
||||
const endpointRuntime = await runtimeFor(record.endpoint);
|
||||
const adapter = endpointRuntime.thread(payload.threadId).adapter;
|
||||
await credentialLease.assertOwned();
|
||||
if (record.endpoint.provider === "slack") {
|
||||
await endpointRuntime.applySlackReceiptReaction(payload, fetchImpl);
|
||||
} else if (record.endpoint.provider === "github") {
|
||||
const assertCurrent = async () => {
|
||||
await credentialLease.assertOwned();
|
||||
const [current] = await db
|
||||
.select({ endpoint: chatEndpoints, connection: toolConnections })
|
||||
.from(chatEndpoints)
|
||||
.innerJoin(
|
||||
toolConnections,
|
||||
and(
|
||||
eq(toolConnections.id, chatEndpoints.connectionId),
|
||||
eq(toolConnections.companyId, chatEndpoints.companyId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpoints.id, action!.endpointId),
|
||||
eq(chatEndpoints.companyId, action!.companyId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (
|
||||
!current ||
|
||||
!["active", "verifying"].includes(current.endpoint.status) ||
|
||||
!current.connection.enabled ||
|
||||
current.connection.status !== "active" ||
|
||||
runtimeGeneration(current.endpoint.setup) !==
|
||||
payload.runtimeGeneration ||
|
||||
credentialFingerprint(current.connection.credentialSecretRefs) !==
|
||||
payload.credentialFingerprint ||
|
||||
runtime.get(action!.endpointId) !== endpointRuntime
|
||||
)
|
||||
throw new Error("GitHub receipt authority is no longer current");
|
||||
};
|
||||
const applied = await endpointRuntime.applyGitHubReceiptReaction(
|
||||
{ ...payload, ...(githubReceipt ? { githubReceipt } : {}) },
|
||||
assertCurrent,
|
||||
fetchImpl,
|
||||
);
|
||||
githubReceipt = parseGitHubReceiptIdentity(applied);
|
||||
if (
|
||||
!githubReceipt ||
|
||||
(payload.operation === "add" && !githubReceipt.reactionId)
|
||||
)
|
||||
throw new Error("GitHub receipt identity was not confirmed");
|
||||
} else if (payload.operation === "remove") {
|
||||
await adapter.removeReaction(
|
||||
payload.threadId,
|
||||
payload.messageId,
|
||||
payload.reaction,
|
||||
);
|
||||
} else {
|
||||
await adapter.addReaction(
|
||||
payload.threadId,
|
||||
payload.messageId,
|
||||
payload.reaction,
|
||||
);
|
||||
}
|
||||
// The provider may already have accepted the idempotent reaction. Fence
|
||||
// the local success receipt so a reclaimed lease cannot report success
|
||||
// for an obsolete runtime.
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
const processed = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processed",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
...(githubReceipt ? { githubReceipt } : {}),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!processed.length) {
|
||||
throw new Error(
|
||||
"Receipt-reaction ownership changed before success settlement",
|
||||
);
|
||||
}
|
||||
if (action.deliveryId) {
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({ redactedError: null, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.state, "processed"),
|
||||
like(chatDeliveries.redactedError, "Receipt reaction %"),
|
||||
),
|
||||
);
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
payload.operation === "add" &&
|
||||
reactionProvider === "slack" &&
|
||||
isSlackReceiptReactionAlreadyApplied(error)
|
||||
) {
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
const processed = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processed",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code: "receipt_reaction_already_applied",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!processed.length) {
|
||||
throw new Error(
|
||||
"Receipt-reaction ownership changed before idempotent settlement",
|
||||
);
|
||||
}
|
||||
if (action.deliveryId) {
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({ redactedError: null, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.state, "processed"),
|
||||
like(chatDeliveries.redactedError, "Receipt reaction %"),
|
||||
),
|
||||
);
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const disposition = classifyChatPublicationError(error, attempt);
|
||||
const retryable =
|
||||
disposition.kind === "retry" ||
|
||||
(disposition.kind === "delivery_unknown" && attempt < 3);
|
||||
const retryAfterMs =
|
||||
disposition.kind === "retry"
|
||||
? disposition.retryAfterMs
|
||||
: 100 * 2 ** Math.max(0, attempt - 1);
|
||||
const operationLabel =
|
||||
payload.operation === "remove" ? " reaction removal" : " reaction";
|
||||
const diagnostic =
|
||||
`Receipt${operationLabel} ${retryable ? "deferred" : "failed"} after ${attempt} attempt${attempt === 1 ? "" : "s"} (${disposition.kind}): ${redactError(error)}`.slice(
|
||||
0,
|
||||
MAX_ERROR_TEXT,
|
||||
);
|
||||
const retryAt = retryable
|
||||
? new Date(Date.now() + retryAfterMs).toISOString()
|
||||
: null;
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
const [failed] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
...(githubReceipt ? { githubReceipt } : {}),
|
||||
code:
|
||||
payload.operation === "remove"
|
||||
? `receipt_reaction_removal_${disposition.kind}`
|
||||
: `receipt_reaction_${disposition.kind}`,
|
||||
redactedError: diagnostic,
|
||||
retryable,
|
||||
retryAt,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (failed && action.deliveryId) {
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({ redactedError: diagnostic, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.state, "processed"),
|
||||
),
|
||||
);
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: action.endpointId,
|
||||
deliveryId: action.deliveryId,
|
||||
attempts: attempt,
|
||||
disposition: disposition.kind,
|
||||
operation: payload.operation,
|
||||
retryAt,
|
||||
},
|
||||
"external chat receipt reaction deferred",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function processPendingReceiptReactions(
|
||||
limit = 25,
|
||||
onlyActionId?: string,
|
||||
) {
|
||||
const actions = await db.select({ id: chatActions.id }).from(chatActions)
|
||||
.where(and(dueChatAction("receipt_reaction"), onlyActionId ? eq(chatActions.id, onlyActionId) : undefined))
|
||||
.orderBy(asc(chatActions.createdAt)).limit(limit);
|
||||
for (const action of actions) await processReceiptReaction(action.id);
|
||||
return actions.length;
|
||||
}
|
||||
return { addReceiptReaction, processReceiptReaction, processPendingReceiptReactions };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Db, chatEndpoints } from "@paperclipai/db";
|
||||
import type { ToolCredentialSecretRef } from "@paperclipai/shared";
|
||||
import type { ToolCredentialSecretRef, ChatEndpointSetupState, ChatEndpointCallbackSurfaces } from "@paperclipai/shared";
|
||||
import type { ChatEndpointRuntime } from "../voice/voice-runtime.js";
|
||||
|
||||
export type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
@@ -36,3 +36,17 @@ export type EndpointRecord = {
|
||||
applicationId: string;
|
||||
credentialSecretRefs: ToolCredentialSecretRef[];
|
||||
};
|
||||
|
||||
export type SlackCallbackSurface = keyof ChatEndpointCallbackSurfaces;
|
||||
|
||||
export type SlackCallbackObservation = { url: string; observedAt: string };
|
||||
|
||||
export type InternalSetupState = ChatEndpointSetupState & {
|
||||
photonIntakeAfter?: string;
|
||||
runtimeGeneration?: number;
|
||||
/** Binds pre-install URL verification to the signing secret later configured. */
|
||||
slackVerificationSigningFingerprint?: string;
|
||||
slackCallbackSurfaces?: Partial<
|
||||
Record<SlackCallbackSurface, SlackCallbackObservation>
|
||||
>;
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
import type { EndpointRow, DbOrTransaction } from "./runtime-types.js";
|
||||
|
||||
import { notifyChatEndpointWork } from "../chat-work-notifications.js";
|
||||
|
||||
import { chatCredentialMutationLease } from "../chat-credential-mutation-lease.js";
|
||||
|
||||
import { githubChatManagementService } from "../chat-github-management.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpoints,
|
||||
chatIdentityLinks,
|
||||
chatMessageLinks,
|
||||
chatPublications,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEndpoint,
|
||||
ChatEndpointSetupState,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
conflict,
|
||||
forbidden,
|
||||
notFound,
|
||||
} from "../../errors.js";
|
||||
|
||||
import {
|
||||
logActivity,
|
||||
} from "../activity-log.js";
|
||||
|
||||
import {
|
||||
githubRunReplyState,
|
||||
} from "../chat-run-publications.js";
|
||||
|
||||
import type { EndpointRecord } from "./runtime-types.js";
|
||||
|
||||
type SetupProbesOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
fetchImpl: typeof fetch;
|
||||
findAuthorizedIdentityLink: (endpoint: EndpointRow, userId: string) => Promise<{ id: string } | null>;
|
||||
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
||||
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>;
|
||||
get: (endpointId: string) => Promise<ChatEndpoint>;
|
||||
setupTestActivationBarrier?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createSetupProbes(db: Db, options: SetupProbesOptions) {
|
||||
const { endpointRecord, withCredentialMutationLease, fetchImpl, findAuthorizedIdentityLink, runtimeGeneration, lockCurrentPrincipalAuthorization, get } = options;
|
||||
|
||||
|
||||
async function setupTestStatus(endpointId: string, userId: string) {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("Chat endpoint not found");
|
||||
const startedAt = record.endpoint.setup.testStartedAt;
|
||||
if (!startedAt) return { messageReceivedAt: null };
|
||||
const links = await db.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and(
|
||||
eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId),
|
||||
eq(chatIdentityLinks.paperclipUserId, userId), eq(chatIdentityLinks.status, "linked"),
|
||||
));
|
||||
if (!links.length) return { messageReceivedAt: null };
|
||||
const principalIds = links.map((link) => link.principalId);
|
||||
const [delivery, command] = await Promise.all([
|
||||
db.select({ at: chatDeliveries.createdAt }).from(chatDeliveries).where(and(
|
||||
eq(chatDeliveries.companyId, record.endpoint.companyId), eq(chatDeliveries.endpointId, endpointId),
|
||||
inArray(chatDeliveries.principalId, principalIds), gte(chatDeliveries.createdAt, new Date(startedAt)),
|
||||
inArray(chatDeliveries.eventKind, ["message", "mention"]),
|
||||
)).orderBy(asc(chatDeliveries.createdAt)).limit(1).then((rows) => rows[0]),
|
||||
db.select({ at: chatActions.createdAt }).from(chatActions).where(and(
|
||||
eq(chatActions.companyId, record.endpoint.companyId), eq(chatActions.endpointId, endpointId),
|
||||
inArray(chatActions.principalId, principalIds), eq(chatActions.kind, "slash_task_start"), gte(chatActions.createdAt, new Date(startedAt)),
|
||||
)).orderBy(asc(chatActions.createdAt)).limit(1).then((rows) => rows[0]),
|
||||
]);
|
||||
const at = [delivery?.at, command?.at].filter((value): value is Date => Boolean(value)).sort((a, b) => a.getTime() - b.getTime())[0];
|
||||
return { messageReceivedAt: at?.toISOString() ?? null };
|
||||
}
|
||||
|
||||
async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }) {
|
||||
const initial = await endpointRecord(endpointId);
|
||||
if (!initial) throw notFound("Chat endpoint not found");
|
||||
return withCredentialMutationLease(
|
||||
initial.endpoint,
|
||||
async (credentialLease) => {
|
||||
// Reload only after owning the same lane as reconnect, pause, removal,
|
||||
// and credential rotation. Qualification and activation must describe
|
||||
// one exact provider runtime, never a setup snapshot that a concurrent
|
||||
// reconnect has already superseded.
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("Chat endpoint not found");
|
||||
const endpoint = record.endpoint;
|
||||
if (endpoint.status !== "verifying" || endpoint.setup.step !== "test") {
|
||||
throw conflict("This connection is not waiting for a setup test", {
|
||||
code: "chat_endpoint_not_testing",
|
||||
});
|
||||
}
|
||||
if (endpoint.provider === "github" && endpoint.setup.github) {
|
||||
const verified = await githubChatManagementService(db, fetchImpl).verification(endpoint.id);
|
||||
if (!verified.ready && !(finishOptions?.optionalSlackTestForUser && verified.connectionReady)) throw conflict("Finish verifying the App, repositories, and assigned agent's tools before activating this bot", { checks: verified.checks });
|
||||
}
|
||||
const testStartedAtValue = endpoint.setup.testStartedAt;
|
||||
const testStartedAt = testStartedAtValue
|
||||
? new Date(testStartedAtValue)
|
||||
: null;
|
||||
const optionalSlackTest = Boolean(finishOptions?.optionalSlackTestForUser);
|
||||
if (optionalSlackTest) {
|
||||
if (!["slack", "github"].includes(endpoint.provider) || !endpoint.setup.webhookVerifiedAt || !endpoint.providerAccountId) {
|
||||
throw conflict("Verify the connection before finishing setup");
|
||||
}
|
||||
const linked = await findAuthorizedIdentityLink(endpoint, finishOptions!.optionalSlackTestForUser);
|
||||
if (!linked) throw forbidden("Connect your account before finishing setup");
|
||||
}
|
||||
if (!optionalSlackTest) {
|
||||
if (
|
||||
!endpoint.lastEventAt ||
|
||||
!testStartedAtValue ||
|
||||
!testStartedAt ||
|
||||
Number.isNaN(testStartedAt.getTime()) ||
|
||||
endpoint.lastEventAt < testStartedAt
|
||||
) {
|
||||
throw conflict(
|
||||
"Send the test message in the provider before completing setup",
|
||||
{
|
||||
code: "chat_test_message_missing",
|
||||
},
|
||||
);
|
||||
}
|
||||
const requiredTrigger =
|
||||
["telegram", "imessage-photon"].includes(endpoint.provider)
|
||||
? "direct_message"
|
||||
: "subscribed_message";
|
||||
const qualifyingDelivery = await db
|
||||
.select({
|
||||
id: chatDeliveries.id,
|
||||
conversationId: chatDeliveries.conversationId,
|
||||
processedAt: chatDeliveries.processedAt,
|
||||
})
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.companyId, endpoint.companyId),
|
||||
eq(chatDeliveries.endpointId, endpoint.id),
|
||||
eq(chatDeliveries.state, "processed"),
|
||||
gte(chatDeliveries.processedAt, testStartedAt),
|
||||
sql`${chatDeliveries.normalizedEvent}->>'trigger' = ${requiredTrigger}`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatDeliveries.processedAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!qualifyingDelivery?.conversationId ||
|
||||
!qualifyingDelivery.processedAt
|
||||
) {
|
||||
throw conflict(
|
||||
["telegram", "imessage-photon"].includes(endpoint.provider)
|
||||
? "Send the test direct message before completing setup"
|
||||
: "Reply once without mentioning the agent before completing setup",
|
||||
{ code: "chat_test_follow_up_missing" },
|
||||
);
|
||||
}
|
||||
const finalPublication = await db
|
||||
.select({
|
||||
commentId: chatPublications.commentId,
|
||||
payload: chatPublications.payload,
|
||||
})
|
||||
.from(chatPublications)
|
||||
.innerJoin(
|
||||
issueComments,
|
||||
and(
|
||||
eq(issueComments.id, chatPublications.commentId),
|
||||
eq(issueComments.companyId, chatPublications.companyId),
|
||||
eq(issueComments.issueId, chatPublications.issueId),
|
||||
eq(issueComments.authorType, "agent"),
|
||||
eq(issueComments.authorAgentId, endpoint.assignedAgentId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
chatMessageLinks,
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, chatPublications.companyId),
|
||||
eq(chatMessageLinks.endpointId, chatPublications.endpointId),
|
||||
eq(
|
||||
chatMessageLinks.conversationId,
|
||||
chatPublications.conversationId,
|
||||
),
|
||||
eq(chatMessageLinks.deliveryId, qualifyingDelivery.id),
|
||||
eq(chatMessageLinks.direction, "inbound"),
|
||||
isNotNull(chatMessageLinks.commentId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
heartbeatRuns,
|
||||
and(
|
||||
eq(heartbeatRuns.id, issueComments.createdByRunId),
|
||||
eq(heartbeatRuns.companyId, chatPublications.companyId),
|
||||
eq(heartbeatRuns.agentId, endpoint.assignedAgentId),
|
||||
eq(heartbeatRuns.status, "succeeded"),
|
||||
eq(
|
||||
sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
||||
sql<string>`${chatPublications.issueId}::text`,
|
||||
),
|
||||
or(
|
||||
sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`,
|
||||
sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'commentId'`,
|
||||
sql`coalesce(${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, endpoint.companyId),
|
||||
eq(chatPublications.endpointId, endpoint.id),
|
||||
eq(
|
||||
chatPublications.conversationId,
|
||||
qualifyingDelivery.conversationId,
|
||||
),
|
||||
eq(chatPublications.state, "published"),
|
||||
gte(chatPublications.publishedAt, qualifyingDelivery.processedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatPublications.publishedAt))
|
||||
.then((rows) =>
|
||||
rows.find(
|
||||
(row) =>
|
||||
row.payload.interactionId === undefined &&
|
||||
row.payload.progressState === undefined &&
|
||||
row.commentId !== null,
|
||||
),
|
||||
);
|
||||
let githubToolReply = false;
|
||||
if (!finalPublication && endpoint.provider === "github") {
|
||||
const setupRuns = await db.select({ id: heartbeatRuns.id, issueId: chatConversations.issueId })
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(chatMessageLinks, and(
|
||||
eq(chatMessageLinks.companyId, endpoint.companyId),
|
||||
eq(chatMessageLinks.endpointId, endpoint.id),
|
||||
eq(chatMessageLinks.deliveryId, qualifyingDelivery.id),
|
||||
eq(chatMessageLinks.direction, "inbound"),
|
||||
or(
|
||||
sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`,
|
||||
sql`coalesce(${heartbeatRuns.contextSnapshot}->'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`,
|
||||
),
|
||||
))
|
||||
.innerJoin(chatConversations, and(
|
||||
eq(chatConversations.id, qualifyingDelivery.conversationId),
|
||||
eq(chatConversations.id, chatMessageLinks.conversationId),
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${chatConversations.issueId}::text`,
|
||||
))
|
||||
.where(and(eq(heartbeatRuns.companyId, endpoint.companyId),
|
||||
eq(heartbeatRuns.agentId, endpoint.assignedAgentId), eq(heartbeatRuns.status, "succeeded")))
|
||||
.orderBy(desc(heartbeatRuns.createdAt)).limit(20);
|
||||
for (const run of setupRuns) {
|
||||
if (await githubRunReplyState(db, { companyId: endpoint.companyId, endpointId: endpoint.id,
|
||||
issueId: run.issueId, runId: run.id }) === "confirmed") {
|
||||
githubToolReply = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!finalPublication && !githubToolReply) {
|
||||
throw conflict(
|
||||
"Wait for the Paperclip agent to reply to the setup turn before completing setup",
|
||||
{
|
||||
code: "chat_test_round_trip_incomplete",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await options.setupTestActivationBarrier?.();
|
||||
const expectedGeneration = runtimeGeneration(endpoint.setup);
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
if (optionalSlackTest) {
|
||||
const linked = await tx.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and(
|
||||
eq(chatIdentityLinks.endpointId, endpoint.id), eq(chatIdentityLinks.paperclipUserId, finishOptions!.optionalSlackTestForUser), eq(chatIdentityLinks.status, "linked"),
|
||||
)).limit(1).then((rows) => rows[0]);
|
||||
if (!linked || !(await lockCurrentPrincipalAuthorization(tx, endpoint, linked.principalId)).allowed) throw forbidden("Your connected account is no longer authorized");
|
||||
}
|
||||
await notifyChatEndpointWork(tx);
|
||||
const [activated] = await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: "active",
|
||||
setup: {
|
||||
...endpoint.setup,
|
||||
step: "complete",
|
||||
testStartedAt: null,
|
||||
testSkipped: optionalSlackTest,
|
||||
},
|
||||
healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected",
|
||||
activatedAt: endpoint.activatedAt ?? new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpoints.id, endpointId),
|
||||
eq(chatEndpoints.status, "verifying"),
|
||||
sql`${chatEndpoints.setup}->>'step' = 'test'`,
|
||||
sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${expectedGeneration}`,
|
||||
sql`${chatEndpoints.setup}->>'testStartedAt' = ${testStartedAtValue}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatEndpoints.id });
|
||||
if (!activated) {
|
||||
throw conflict("This setup test was superseded by a reconnect", {
|
||||
code: "chat_endpoint_test_superseded",
|
||||
});
|
||||
}
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "healthy",
|
||||
healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected",
|
||||
lastError: null,
|
||||
healthCheckedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(toolConnections.id, endpoint.connectionId));
|
||||
if (optionalSlackTest) await logActivity(tx as unknown as Db, {
|
||||
companyId: endpoint.companyId, actorType: "user", actorId: finishOptions!.optionalSlackTestForUser,
|
||||
action: "chat.setup_completed", entityType: "chat_endpoint", entityId: endpointId,
|
||||
details: { conversationTestOptional: true },
|
||||
});
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
return get(endpointId);
|
||||
},
|
||||
);
|
||||
}
|
||||
return { setupTestStatus, test };
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { isProvisionedSlackSetup } from "@paperclipai/shared";
|
||||
import type { EndpointRow, DbOrTransaction, SlackCallbackSurface, InternalSetupState } from "../runtime-types.js";
|
||||
|
||||
import { notifyChatVerificationWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import { slackChatRegistrationService } from "../../chat-slack-registration.js";
|
||||
import { chatSlackRegistrations } from "@paperclipai/db";
|
||||
|
||||
import { runtimeCanonicalOrigin } from "../../cloud-runtime-identity.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
createHmac,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatEndpoints,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ToolCredentialSecretRef,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
logActivity,
|
||||
} from "../../activity-log.js";
|
||||
|
||||
import { secretService } from "../../secrets.js";
|
||||
|
||||
function slackRequestSignatureIsValid(
|
||||
request: Request,
|
||||
body: string,
|
||||
signingSecret: string,
|
||||
): boolean {
|
||||
const timestamp = request.headers.get("x-slack-request-timestamp");
|
||||
const signature = request.headers.get("x-slack-signature");
|
||||
if (!timestamp || !signature) return false;
|
||||
const timestampSeconds = Number(timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestampSeconds) ||
|
||||
Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 300
|
||||
)
|
||||
return false;
|
||||
const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${body}`).digest("hex")}`;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function slackRequestWorkspaceId(
|
||||
body: string,
|
||||
contentType: string,
|
||||
): string | null {
|
||||
let payload: unknown;
|
||||
try {
|
||||
if (contentType.includes("application/json")) {
|
||||
payload = JSON.parse(body);
|
||||
} else {
|
||||
const form = new URLSearchParams(body);
|
||||
const interactivePayload = form.get("payload");
|
||||
payload = interactivePayload
|
||||
? JSON.parse(interactivePayload)
|
||||
: Object.fromEntries(form.entries());
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const nestedId = (value: unknown): string | null => {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const id = (value as Record<string, unknown>).id;
|
||||
return typeof id === "string" && id.length > 0 ? id : null;
|
||||
};
|
||||
const stringField = (key: string): string | null => {
|
||||
const value = record[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
};
|
||||
const authorization = Array.isArray(record.authorizations)
|
||||
? record.authorizations.find(
|
||||
(value): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object",
|
||||
)
|
||||
: null;
|
||||
const authorizationField = (key: string): string | null => {
|
||||
const value = authorization?.[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
};
|
||||
|
||||
// A Grid event may carry both an enterprise id and the concrete workspace
|
||||
// id. The bot identity is verified against auth.test's team_id, so prefer a
|
||||
// team id and use the enterprise id only when Slack omitted team context.
|
||||
return (
|
||||
stringField("team_id") ??
|
||||
nestedId(record.team) ??
|
||||
authorizationField("team_id") ??
|
||||
stringField("enterprise_id") ??
|
||||
nestedId(record.enterprise) ??
|
||||
authorizationField("enterprise_id")
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalCallbackUrl(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
||||
url.hostname = url.hostname.replace(/\.$/, "");
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// TLS terminates at a reverse proxy in many self-hosted deployments. The
|
||||
// transport scheme is not evidence of URL drift; authority and path still are.
|
||||
// This is diagnostic only: it does not trust forwarded headers or change auth.
|
||||
function slackCallbackMatchesPublicUrl(observed: string, current: string): boolean {
|
||||
const canonical = canonicalCallbackUrl(observed);
|
||||
if (canonical === current) return true;
|
||||
if (!canonical) return false;
|
||||
const observedUrl = new URL(canonical);
|
||||
const currentUrl = new URL(current);
|
||||
return observedUrl.protocol === "http:"
|
||||
&& currentUrl.protocol === "https:"
|
||||
&& observedUrl.host === currentUrl.host
|
||||
&& observedUrl.pathname === currentUrl.pathname;
|
||||
}
|
||||
|
||||
async function inspectSlackCallback(
|
||||
request: Request,
|
||||
): Promise<SlackCallbackInspection | null> {
|
||||
const url = slackCallbackObservationUrl(request);
|
||||
if (!url) return null;
|
||||
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
let body: string;
|
||||
try {
|
||||
body = await request.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const payload = JSON.parse(body) as {
|
||||
type?: unknown;
|
||||
challenge?: unknown;
|
||||
};
|
||||
const isUrlVerification =
|
||||
payload.type === "url_verification" &&
|
||||
typeof payload.challenge === "string" &&
|
||||
payload.challenge.length > 0;
|
||||
if (isUrlVerification || payload.type === "event_callback") {
|
||||
return {
|
||||
surface: "events",
|
||||
url,
|
||||
isUrlVerification,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const form = new URLSearchParams(body);
|
||||
if (form.get("command")) {
|
||||
return { surface: "slashCommands", url, isUrlVerification: false };
|
||||
}
|
||||
const interactivePayload = form.get("payload");
|
||||
if (!interactivePayload) return null;
|
||||
try {
|
||||
const payload = JSON.parse(interactivePayload) as { type?: unknown };
|
||||
if (
|
||||
typeof payload.type === "string" &&
|
||||
[
|
||||
"block_actions",
|
||||
"block_suggestion",
|
||||
"dialog_submission",
|
||||
"message_action",
|
||||
"shortcut",
|
||||
"view_closed",
|
||||
"view_submission",
|
||||
"workflow_step_edit",
|
||||
].includes(payload.type)
|
||||
) {
|
||||
return { surface: "interactivity", url, isUrlVerification: false };
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type SlackCallbackInspection = {
|
||||
surface: SlackCallbackSurface;
|
||||
url: string;
|
||||
isUrlVerification: boolean;
|
||||
};
|
||||
|
||||
// Cloud records the public request host in dedicated headers so provider edges
|
||||
// cannot replace it with their upstream host. Only use callback-health evidence on a
|
||||
// claimed Cloud instance, after provider authentication succeeded. It must not
|
||||
// change the Request passed to the adapter, signature verification, routing,
|
||||
// board identity, or the configured URL used to generate callbacks.
|
||||
function slackCallbackObservationUrl(request: Request): string | null {
|
||||
const directUrl = canonicalCallbackUrl(request.url);
|
||||
if (!directUrl || !runtimeCanonicalOrigin()) return directUrl;
|
||||
const hasCloudEvidence = request.headers.has("x-paperclip-cloud-forwarded-host");
|
||||
const host = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-host" : "x-forwarded-host")?.trim();
|
||||
const protocol = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-proto" : "x-forwarded-proto")?.trim();
|
||||
if (!host || /[\s/@\\?#,]/.test(host) || (protocol !== "https" && protocol !== "http")) return directUrl;
|
||||
try {
|
||||
const origin = new URL(`${protocol}://${host}`);
|
||||
// Accept one authority only, never credentials, paths, queries, or lists.
|
||||
if (!origin.host || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) return directUrl;
|
||||
const observed = new URL(directUrl);
|
||||
observed.protocol = origin.protocol;
|
||||
observed.host = origin.host;
|
||||
observed.port = origin.port;
|
||||
return canonicalCallbackUrl(observed.toString());
|
||||
} catch {
|
||||
return directUrl;
|
||||
}
|
||||
}
|
||||
export { slackRequestSignatureIsValid, slackRequestWorkspaceId, canonicalCallbackUrl, slackCallbackMatchesPublicUrl, inspectSlackCallback };
|
||||
|
||||
type SlackCallbackVerificationOptions = {
|
||||
resolveCredentialRefs: (endpoint: EndpointRow, refs: ToolCredentialSecretRef[], database?: DbOrTransaction) => Promise<Record<string, string>>;
|
||||
getWebhookPublicBaseUrl: () => string | null;
|
||||
scheduleMessageProcessing: (task: () => Promise<void>) => void;
|
||||
slackRegistration: Pick<ReturnType<typeof slackChatRegistrationService>, "notifyVerified">;
|
||||
};
|
||||
|
||||
export function createSlackCallbackVerification(db: Db, options: SlackCallbackVerificationOptions) {
|
||||
const { resolveCredentialRefs, getWebhookPublicBaseUrl, scheduleMessageProcessing, slackRegistration } = options;
|
||||
|
||||
|
||||
async function verifySlackRequestUrl(endpoint: EndpointRow, request: Request): Promise<Response | null> {
|
||||
const callback = await inspectSlackCallback(request.clone());
|
||||
if (!callback?.isUrlVerification) return null;
|
||||
const body = await request.clone().text();
|
||||
let notifyAccount = false;
|
||||
// URL verification needs only the signing secret. Do not initialize the
|
||||
// SDK (which calls Slack auth.test), wait for OAuth, or acquire the creation
|
||||
// lease: Slack can deliver this check while app setup still owns that lease.
|
||||
const response = await db.transaction(async tx => {
|
||||
const [current] = await tx.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id)).for("no key update");
|
||||
if (!current || ["archived", "paused", "revoked"].includes(current.status)) return new Response("ignored", { status: 200 });
|
||||
const [connection] = await tx.select({ status: toolConnections.status, refs: toolConnections.credentialSecretRefs }).from(toolConnections)
|
||||
.where(and(eq(toolConnections.id, current.connectionId), eq(toolConnections.companyId, current.companyId)));
|
||||
if (!connection || connection.status === "archived") return new Response("ignored", { status: 200 });
|
||||
let secret = (await resolveCredentialRefs(current, connection.refs.filter(ref => ref.configPath.replace(/^credentials\./, "") === "signingSecret"), tx)).signingSecret;
|
||||
if (!secret && isProvisionedSlackSetup(current.setup.slackSetupMethod)) {
|
||||
const [registration] = await tx.select().from(chatSlackRegistrations)
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, current.id), eq(chatSlackRegistrations.companyId, current.companyId)));
|
||||
if (registration?.status !== "removed" && registration?.secretIds.signingSecret) {
|
||||
secret = await secretService(tx as unknown as Db).resolveSecretValue(current.companyId, registration.secretIds.signingSecret, "latest", {
|
||||
accessContext: { consumerType: "system", consumerId: `slack-registration:${current.id}`, configPath: "slack_registration.signingSecret", actorType: "system" },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!secret) return new Response("Slack signing secret is not ready yet", { status: 503 });
|
||||
if (!slackRequestSignatureIsValid(request, body, secret)) return new Response("Invalid signature", { status: 401 });
|
||||
const workspaceId = slackRequestWorkspaceId(body, request.headers.get("content-type") ?? "");
|
||||
if (workspaceId && current.providerAccountId && workspaceId !== current.providerAccountId) return new Response("ignored", { status: 200 });
|
||||
const observedAt = new Date();
|
||||
const setup = current.setup as InternalSetupState;
|
||||
const matchesCurrentUrl = slackCallbackMatchesPublicUrl(callback.url, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${current.publicId}/slack`);
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatEndpoints).set({ setup: {
|
||||
...setup,
|
||||
...(matchesCurrentUrl ? { webhookVerifiedAt: setup.webhookVerifiedAt ?? observedAt.toISOString(),
|
||||
slackVerificationSigningFingerprint: createHash("sha256").update(secret).digest("hex") } : {}),
|
||||
slackCallbackSurfaces: { ...setup.slackCallbackSurfaces, events: { url: callback.url, observedAt: observedAt.toISOString() } },
|
||||
...(matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus
|
||||
? { slackAccount: { ...setup.slackAccount, verificationStatus: "pending" as const } } : {}),
|
||||
} as InternalSetupState, ...(matchesCurrentUrl ? { healthMessage: "Slack Events Request URL verified" } : {}), updatedAt: observedAt }).where(eq(chatEndpoints.id, current.id));
|
||||
if (matchesCurrentUrl && !setup.webhookVerifiedAt) await logActivity(tx as unknown as Db, { companyId: current.companyId, actorType: "system", actorId: "slack-webhook",
|
||||
action: "chat_endpoint.webhook_verified", entityType: "tool_connection", entityId: current.connectionId,
|
||||
details: { endpointId: current.id, provider: "slack" } });
|
||||
notifyAccount = matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus;
|
||||
return Response.json({ challenge: (JSON.parse(body) as { challenge: string }).challenge });
|
||||
});
|
||||
if (notifyAccount) scheduleMessageProcessing(() => slackRegistration.notifyVerified(endpoint.id));
|
||||
return response;
|
||||
}
|
||||
return { verifySlackRequestUrl };
|
||||
}
|
||||
@@ -41,3 +41,49 @@ export function slackMessageSentAt(raw: unknown, messageId: string): Date | null
|
||||
const date = new Date(Number(ts) * 1_000);
|
||||
return Number.isFinite(date.getTime()) ? date : null;
|
||||
}
|
||||
|
||||
export function isSlackReceiptReactionAlreadyApplied(error: unknown): boolean {
|
||||
const pending: Array<{ depth: number; value: unknown }> = [
|
||||
{ depth: 0, value: error },
|
||||
];
|
||||
const seen = new Set<object>();
|
||||
while (pending.length > 0) {
|
||||
const current = pending.shift();
|
||||
if (
|
||||
!current ||
|
||||
current.depth > 4 ||
|
||||
!current.value ||
|
||||
typeof current.value !== "object" ||
|
||||
seen.has(current.value)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current.value);
|
||||
const record = current.value as {
|
||||
cause?: unknown;
|
||||
code?: unknown;
|
||||
data?: { error?: unknown };
|
||||
original?: unknown;
|
||||
originalError?: unknown;
|
||||
};
|
||||
if (
|
||||
record.code === "already_reacted" ||
|
||||
record.data?.error === "already_reacted"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
for (const value of [record.cause, record.original, record.originalError]) {
|
||||
pending.push({ depth: current.depth + 1, value });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function slackThreadChannelId(threadId: string): string | null {
|
||||
if (!threadId.startsWith("slack:")) return null;
|
||||
const channelAndThread = threadId.slice("slack:".length);
|
||||
const separator = channelAndThread.indexOf(":");
|
||||
const channelId =
|
||||
separator === -1 ? channelAndThread : channelAndThread.slice(0, separator);
|
||||
return channelId || null;
|
||||
}
|
||||
@@ -1073,3 +1073,21 @@ export function createSlackTaskStart(db: Db, options: SlackTaskStartOptions) {
|
||||
}
|
||||
return { latestSlackDmControlThreadId, finalizeSlackTaskStartFailure, admitConfirmedSlackTaskStart, processSlackTaskStart, processPendingSlackTaskStarts };
|
||||
}
|
||||
|
||||
export function slackTaskStartActivityStatus(action: {
|
||||
status: string;
|
||||
updatedAt: Date;
|
||||
}): string {
|
||||
const age = Date.now() - action.updatedAt.getTime();
|
||||
if (action.status === "validating" && age >= SLACK_COMMAND_POST_STALE_MS) {
|
||||
return "queued";
|
||||
}
|
||||
if (
|
||||
(action.status === "received" && age >= SLACK_COMMAND_POST_STALE_MS) ||
|
||||
(action.status === "resolving" &&
|
||||
age >= SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS)
|
||||
) {
|
||||
return "delivery_unknown";
|
||||
}
|
||||
return action.status;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction } from "../runtime-types.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import {
|
||||
chatEndpointResources,
|
||||
chatExternalPrincipals,
|
||||
chatIdentityLinks,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
type TelegramCallbackReceipt,
|
||||
} from "../../chat-telegram-ephemeral.js";
|
||||
|
||||
type TelegramCallbackNoticeAuthorityOptions = {
|
||||
nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean;
|
||||
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; linkedDenied: boolean; userId: string | null; sponsorUserId?: string | null }>;
|
||||
};
|
||||
|
||||
export function createTelegramCallbackNoticeAuthority(options: TelegramCallbackNoticeAuthorityOptions) {
|
||||
const { nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization } = options;
|
||||
|
||||
|
||||
// This is permission to return one fixed, neutral callback denial, not
|
||||
// permission to execute the denied action or publish to the conversation.
|
||||
// Retain the initial identity-policy snapshot so queued notices cannot cross
|
||||
// a subsequent actor/link/reach change. No provider I/O occurs under locks.
|
||||
async function lockTelegramCallbackNoticeAuthority(
|
||||
tx: DbTransaction,
|
||||
endpoint: EndpointRow,
|
||||
receipt: TelegramCallbackReceipt,
|
||||
principalId: string | null,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
endpoint.provider !== "telegram" ||
|
||||
endpoint.status !== "active" ||
|
||||
endpoint.companyId !== receipt.companyId ||
|
||||
endpoint.id !== receipt.endpointId ||
|
||||
endpoint.botExternalId !== receipt.botUserId ||
|
||||
!endpoint.providerAccountId ||
|
||||
!principalId ||
|
||||
Date.now() < receipt.receivedAtMs ||
|
||||
Date.now() >= receipt.deadlineAtMs
|
||||
)
|
||||
return null;
|
||||
const resource =
|
||||
receipt.chatType === "private"
|
||||
? null
|
||||
: await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.providerResourceId, receipt.chatId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
receipt.chatType === "private"
|
||||
? !endpoint.allowDirectMessages
|
||||
: !resource || !nonDirectDestinationAllowed(endpoint, resource)
|
||||
)
|
||||
return null;
|
||||
const principal = await tx
|
||||
.select()
|
||||
.from(chatExternalPrincipals)
|
||||
.where(
|
||||
and(
|
||||
eq(chatExternalPrincipals.companyId, endpoint.companyId),
|
||||
eq(chatExternalPrincipals.provider, endpoint.provider),
|
||||
eq(
|
||||
chatExternalPrincipals.providerAccountId,
|
||||
endpoint.providerAccountId,
|
||||
),
|
||||
eq(chatExternalPrincipals.id, principalId),
|
||||
eq(chatExternalPrincipals.externalId, receipt.receiverUserId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!principal || principal.kind !== "user" || principal.isBot) return null;
|
||||
const authorization = await lockCurrentPrincipalAuthorization(
|
||||
tx,
|
||||
endpoint,
|
||||
principal.id,
|
||||
);
|
||||
const link = await tx
|
||||
.select({
|
||||
status: chatIdentityLinks.status,
|
||||
userId: chatIdentityLinks.paperclipUserId,
|
||||
})
|
||||
.from(chatIdentityLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatIdentityLinks.companyId, endpoint.companyId),
|
||||
eq(chatIdentityLinks.endpointId, endpoint.id),
|
||||
eq(chatIdentityLinks.principalId, principal.id),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
endpoint.companyId,
|
||||
endpoint.id,
|
||||
receipt.botUserId,
|
||||
resource?.id ?? null,
|
||||
principal.id,
|
||||
principal.externalId,
|
||||
link,
|
||||
authorization,
|
||||
endpoint.allowUnlinkedPeople,
|
||||
endpoint.sponsorUserId,
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
return { lockTelegramCallbackNoticeAuthority };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Attachment } from "chat";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { chatDeliveries } from "@paperclipai/db";
|
||||
import type { ChatProvider } from "@paperclipai/shared";
|
||||
@@ -221,3 +222,14 @@ export function telegramMessageSentAt(raw: unknown): Date | null {
|
||||
const sentAt = new Date(value * 1_000);
|
||||
return Number.isFinite(sentAt.getTime()) ? sentAt : null;
|
||||
}
|
||||
|
||||
export function telegramMarkdownAttachment(text: string): Attachment {
|
||||
const data = Buffer.from(text, "utf8");
|
||||
return {
|
||||
data,
|
||||
mimeType: "text/markdown; charset=utf-8",
|
||||
name: "paperclip-response.md",
|
||||
size: data.byteLength,
|
||||
type: "file",
|
||||
};
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-le
|
||||
// Keep generic connection removal independent of the registration service's
|
||||
// authorization graph; access routes already load tool-access through the barrel.
|
||||
export async function removeSlackRegistration(db: Db, endpointId: string, lease: CredentialMutationLeaseGuard) {
|
||||
await lease.assertOwned();
|
||||
const vault = secretService(db);
|
||||
const [row] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId));
|
||||
if (endpoint) await db.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, endpoint.connectionId), like(toolOauthStates.state, "slack-manager.%")));
|
||||
if (!row) return;
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ status: "removed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
@@ -16,4 +19,4 @@ export async function removeSlackRegistration(db: Db, endpointId: string, lease:
|
||||
for (const id of Object.values(row.secretIds)) { await lease.assertOwned(); await vault.remove(id); }
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ secretIds: {} }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
}
|
||||
}
|
||||
@@ -1,531 +1,3 @@
|
||||
import { notifyChatVerificationWork } from "./chat-work-notifications.js";
|
||||
import { createAgentAvatarPool } from "./agent-avatar-pool.js";
|
||||
import type { AgentAvatarRequest } from "./agent-avatars.js";
|
||||
import { removeSlackRegistration } from "./chat-slack-registration-cleanup.js";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, eq, gt, inArray, like, sql } from "drizzle-orm";
|
||||
import { agents, chatEndpoints, chatSlackRegistrations, companies, toolConnections, toolOauthStates, type Db } from "@paperclipai/db";
|
||||
import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, slackRegistrationErrorMessage, type SlackRegistrationInput, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState } from "@paperclipai/shared";
|
||||
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { writeConnectionCredential } from "./connection-credentials.js";
|
||||
import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-lease.js";
|
||||
|
||||
export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean }
|
||||
type Registration = typeof chatSlackRegistrations.$inferSelect;
|
||||
const prefix = "slack-install.";
|
||||
const timeoutMs = 20_000;
|
||||
const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code });
|
||||
const knownProviderErrors: Record<string, string> = {
|
||||
invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid",
|
||||
token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid",
|
||||
invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid",
|
||||
ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied",
|
||||
missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid",
|
||||
};
|
||||
|
||||
export function slackRegistrationProjection(row: Registration): SlackRegistrationState {
|
||||
return {
|
||||
status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status,
|
||||
appId: row.appId,
|
||||
managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps",
|
||||
errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode,
|
||||
};
|
||||
}
|
||||
|
||||
export function slackChatRegistrationService(db: Db, options: {
|
||||
publicOrigin: () => string | null;
|
||||
webhookOrigin: () => string | null;
|
||||
fetch?: typeof fetch;
|
||||
renderAvatar?: (request: AgentAvatarRequest) => Promise<Buffer>;
|
||||
withLock: <T>(endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise<T>) => Promise<T>;
|
||||
runtimeSigningSecret: (endpointId: string) => Promise<string>;
|
||||
runtimeBotToken: (endpointId: string) => Promise<string>;
|
||||
canWelcome: (endpointId: string, account: SlackAccountState) => Promise<boolean>;
|
||||
linkInstaller: (endpointId: string, user: Record<string, unknown>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<void>;
|
||||
configure: (endpointId: string, credentials: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<unknown>;
|
||||
}) {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const vault = secretService(db);
|
||||
let avatarPool: ReturnType<typeof createAgentAvatarPool> | undefined;
|
||||
function origin(value: string | null) {
|
||||
if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app");
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
||||
throw badRequest("Slack setup requires a configured public HTTPS origin");
|
||||
return url.origin;
|
||||
}
|
||||
const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`;
|
||||
async function registration(endpointId: string) {
|
||||
return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0];
|
||||
}
|
||||
async function endpoint(endpointId: string, actor: SlackSetupActor) {
|
||||
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
|
||||
const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance })
|
||||
.from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId)))
|
||||
.innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId)))
|
||||
.where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack")));
|
||||
if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found");
|
||||
if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections"))
|
||||
throw forbidden("Missing permission: tools:manage_connections");
|
||||
return row;
|
||||
}
|
||||
function assertOrigins(row: Registration, publicId: string) {
|
||||
if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup.");
|
||||
const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`;
|
||||
if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected)
|
||||
throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup.");
|
||||
}
|
||||
async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) {
|
||||
await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId,
|
||||
action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId,
|
||||
details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } });
|
||||
}
|
||||
async function api(method: string, fields: Record<string, string> | FormData, bearer?: string) {
|
||||
const response = await fetchImpl(`https://slack.com/api/${method}`, {
|
||||
method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs),
|
||||
headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) },
|
||||
body: fields instanceof FormData ? fields : new URLSearchParams(fields),
|
||||
});
|
||||
const result = object(await response.json());
|
||||
if (!response.ok || result.ok !== true) {
|
||||
const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure");
|
||||
throw providerFailure(code);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function saveSecrets(row: Registration, values: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard,
|
||||
patch: Partial<Registration> = {}, account?: SlackAccountState) {
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0];
|
||||
if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
const ids = { ...current.secretIds };
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration",
|
||||
configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId },
|
||||
...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) });
|
||||
ids[key] = result.secret.id;
|
||||
}
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId));
|
||||
if (account) {
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
if (!saved || saved.status === "archived") throw conflict("Slack setup changed");
|
||||
// Reauthorization never changes an established personal account binding.
|
||||
if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId));
|
||||
}
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
async function readSecret(row: Registration, key: string) {
|
||||
if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection.");
|
||||
return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", {
|
||||
accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" },
|
||||
});
|
||||
}
|
||||
async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) {
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() })
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId)));
|
||||
}
|
||||
async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0];
|
||||
if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
if (!saved || saved.status === "archived" || saved.setup.slackSetupMethod !== "automatic") throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() })
|
||||
.where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId)));
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const previous = await registration(endpointId);
|
||||
if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup.");
|
||||
if (previous?.appId) {
|
||||
if (previous.errorCode === "slack_manifest_update_pending") {
|
||||
await configureManifest(previous, actor, input.credentials.configurationToken, lease);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (previous?.requestId === input.requestId) return;
|
||||
if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) {
|
||||
await setFailure(previous, "uncertain", "slack_creation_uncertain", lease);
|
||||
return;
|
||||
}
|
||||
if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== "automatic")
|
||||
throw conflict("Automatic creation is only available for a new Slack connection");
|
||||
const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp);
|
||||
const redirect = callbackUri();
|
||||
const manifest = buildSlackAppManifest({ app, agentName: current.agentName,
|
||||
webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect });
|
||||
// Slack cannot authenticate a challenge until its creation response gives us
|
||||
// the signing secret. Add event subscriptions only after that secret is durable.
|
||||
const { event_subscriptions: _events, ...creationSettings } = manifest.settings;
|
||||
const creationManifest = { ...manifest, settings: creationSettings };
|
||||
try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); }
|
||||
catch (error) {
|
||||
if (error instanceof Error && "status" in error) throw error;
|
||||
throw providerFailure("slack_provider_failure");
|
||||
}
|
||||
// No provider creation has happened until this intent is durable.
|
||||
await endpoint(endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const,
|
||||
manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect,
|
||||
createdByUserId: actor.userId, errorCode: null, updatedAt: new Date() };
|
||||
const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values })
|
||||
.onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning();
|
||||
await audit(row, actor, "creation_started");
|
||||
let createdAppId: string;
|
||||
try {
|
||||
const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken);
|
||||
const credentials = object(result.credentials);
|
||||
if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id)
|
||||
|| typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id)
|
||||
|| typeof credentials.client_secret !== "string" || !credentials.client_secret
|
||||
|| typeof credentials.signing_secret !== "string" || !credentials.signing_secret)
|
||||
throw new Error("Incomplete Slack creation response");
|
||||
await endpoint(endpointId, actor);
|
||||
await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease,
|
||||
{ appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" });
|
||||
createdAppId = result.app_id;
|
||||
} catch (error) {
|
||||
// Only documented rejection responses prove that creation did not happen.
|
||||
const code = object(object(error).details).code;
|
||||
const rejected = typeof code === "string" && Object.values(knownProviderErrors).includes(code);
|
||||
await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease);
|
||||
await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain");
|
||||
return;
|
||||
}
|
||||
// The app and credentials are durable before optional icon provisioning.
|
||||
// A retry/restart always reuses this app, even if icon upload is interrupted.
|
||||
const created = { ...row, appId: createdAppId };
|
||||
if (!await configureManifest(created, actor, input.credentials.configurationToken, lease)) return;
|
||||
await saveAvatar(created, { status: "pending" }, actor, lease);
|
||||
let avatar: SlackAvatarState;
|
||||
try {
|
||||
await endpoint(endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
// Cloud ingress requires a tenant session. Send the preset PNG bytes so
|
||||
// Slack does not need to fetch an authenticated Paperclip URL.
|
||||
const render = options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render;
|
||||
const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) };
|
||||
delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets.
|
||||
const png = await render({ appearance,
|
||||
size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" });
|
||||
await endpoint(endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
const upload = new FormData();
|
||||
upload.set("app_id", createdAppId);
|
||||
upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png");
|
||||
await api("apps.icon.set", upload, input.credentials.configurationToken);
|
||||
avatar = { status: "uploaded", uploadedAt: new Date().toISOString() };
|
||||
} catch {
|
||||
// Provider echoes (including tokens) never become saved state or activity.
|
||||
avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" };
|
||||
}
|
||||
await saveAvatar(created, avatar, actor, lease);
|
||||
try { await audit(created, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); }
|
||||
catch { logger.warn({ endpointId, appId: createdAppId }, "Slack avatar activity could not be recorded"); }
|
||||
// An audit outage cannot change the saved creation outcome.
|
||||
try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); }
|
||||
catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); }
|
||||
});
|
||||
}
|
||||
async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard) {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== "automatic")
|
||||
throw conflict("Resume the saved Slack connection before configuring its app");
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
await lease.assertOwned();
|
||||
try {
|
||||
await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken);
|
||||
} catch {
|
||||
// The pending marker was saved with the app secrets before dispatch. Retrying
|
||||
// the same manifest is safe even when Slack's response was lost.
|
||||
await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending");
|
||||
return false;
|
||||
}
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() })
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId)));
|
||||
await audit(row, actor, "manifest_updated");
|
||||
return true;
|
||||
}
|
||||
async function install(endpointId: string, actor: SlackSetupActor) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const row = await registration(endpointId);
|
||||
if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it");
|
||||
if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it");
|
||||
if (current.endpoint.setup.slackSetupMethod !== "automatic" || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it");
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
const state = `${prefix}${randomBytes(32).toString("base64url")}`;
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[];
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`)));
|
||||
await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id,
|
||||
codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri,
|
||||
endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }),
|
||||
createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId,
|
||||
requestedScopes: scopes, expiresAt });
|
||||
await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
});
|
||||
const url = new URL("https://slack.com/oauth/v2/authorize");
|
||||
url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri,
|
||||
...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString();
|
||||
await audit(row, actor, "installation_started");
|
||||
return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() };
|
||||
});
|
||||
}
|
||||
async function expiredReturn(state: string, actor: SlackSetupActor) {
|
||||
if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null;
|
||||
const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state));
|
||||
if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null;
|
||||
const binding = object(JSON.parse(attempt.codeVerifier));
|
||||
const current = await endpoint(String(binding.endpointId), actor);
|
||||
return options.withLock(current.endpoint.id, async lease => {
|
||||
const latest = await endpoint(current.endpoint.id, actor);
|
||||
const row = await registration(current.endpoint.id);
|
||||
if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId
|
||||
|| latest.endpoint.setup.slackSetupMethod !== "automatic" || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null;
|
||||
assertOrigins(row, latest.endpoint.publicId);
|
||||
await lease.assertOwned();
|
||||
await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state));
|
||||
await setFailure(row, row.status, "slack_install_expired", lease);
|
||||
await audit(row, actor, "installation_failed", "slack_install_expired");
|
||||
return returnPath(row.endpointId);
|
||||
});
|
||||
}
|
||||
async function pending(state: string, actor: SlackSetupActor) {
|
||||
if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state");
|
||||
const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date())));
|
||||
if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId)
|
||||
throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again.");
|
||||
const binding = object(JSON.parse(attempt.codeVerifier));
|
||||
const current = await endpoint(String(binding.endpointId), actor);
|
||||
const row = await registration(current.endpoint.id);
|
||||
if (!row || row.status === "removed" || current.endpoint.setup.slackSetupMethod !== "automatic"
|
||||
|| current.endpoint.status === "paused"
|
||||
|| current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked"
|
||||
|| binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0)
|
||||
|| row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri
|
||||
|| row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id)
|
||||
throw conflict("Slack setup changed. Resume the saved connection and install again.");
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
return { attempt, current, row };
|
||||
}
|
||||
async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0];
|
||||
if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed");
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId));
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
const account = current.endpoint.setup.slackAccount;
|
||||
if (!account) throw providerFailure("slack_install_account_missing");
|
||||
if (account.status === "linked") return;
|
||||
const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId);
|
||||
const user = object((await api("users.info", { user: account.externalUserId }, token)).user);
|
||||
if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing");
|
||||
await endpoint(row.endpointId, actor);
|
||||
await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease);
|
||||
await saveAccount(row, { ...account, status: "linked" }, actor, lease);
|
||||
try { await audit(row, actor, "account_linked"); }
|
||||
catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); }
|
||||
}
|
||||
async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
const account = current.endpoint.setup.slackAccount;
|
||||
if (!account || account.status !== "linked") return;
|
||||
if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return;
|
||||
const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus";
|
||||
const status = account[field] ?? "pending";
|
||||
if (status === "sending") {
|
||||
await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease);
|
||||
return; // Never replay an ambiguous dispatch after a process restart.
|
||||
}
|
||||
if (status !== "pending") return;
|
||||
await saveAccount(row, { ...account, [field]: "sending" }, actor, lease);
|
||||
let result: SlackAccountState;
|
||||
try {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed");
|
||||
const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId,
|
||||
text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance."
|
||||
: `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`,
|
||||
unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId));
|
||||
if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response");
|
||||
result = { ...account, [field]: "sent", dmChannelId: sent.channel };
|
||||
} catch (error) {
|
||||
result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" };
|
||||
}
|
||||
await saveAccount(row, result, actor, lease);
|
||||
try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); }
|
||||
catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); }
|
||||
}
|
||||
async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await sendSetupMessage(row, actor, lease, "welcome");
|
||||
await sendSetupMessage(row, actor, lease, "verification");
|
||||
}
|
||||
async function notifyVerified(endpointId: string) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status !== "configured") return;
|
||||
const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId));
|
||||
const account = saved?.setup.slackAccount;
|
||||
if (!account || !["verifying", "active"].includes(saved.status)) return;
|
||||
await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification");
|
||||
});
|
||||
}
|
||||
function pendingVerificationMessage() {
|
||||
// A pending verification cannot progress until linking, welcome, and an
|
||||
// authenticated callback finish. Their commits wake this lane.
|
||||
return and(eq(chatSlackRegistrations.status, "configured"),
|
||||
inArray(chatEndpoints.status, ["verifying", "active"]),
|
||||
sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`,
|
||||
sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`,
|
||||
sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`,
|
||||
sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`);
|
||||
}
|
||||
function pendingVerificationMessages(limit: number) {
|
||||
return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints)
|
||||
.innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId)))
|
||||
.where(pendingVerificationMessage()).limit(limit);
|
||||
}
|
||||
async function nextVerificationMessageAt(): Promise<number | null> {
|
||||
return (await pendingVerificationMessages(1)).length ? Date.now() : null;
|
||||
}
|
||||
async function processPendingVerificationMessages(limit = 25) {
|
||||
for (const row of await pendingVerificationMessages(limit)) {
|
||||
try { await notifyVerified(row.endpointId); }
|
||||
catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); }
|
||||
}
|
||||
}
|
||||
async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status === "removed") throw conflict("Slack registration is unavailable");
|
||||
if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; }
|
||||
if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it");
|
||||
try {
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
const botToken = await readSecret(row, "botToken");
|
||||
const auth = await api("auth.test", {}, botToken);
|
||||
if (auth.team_id !== row.workspaceId || auth.user_id !== row.botUserId) throw providerFailure("slack_install_identity_mismatch");
|
||||
await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret
|
||||
? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, {
|
||||
assertOwned: async database => {
|
||||
await lease.assertOwned(database);
|
||||
await endpoint(endpointId, actor);
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
},
|
||||
});
|
||||
await linkAccount(row, actor, lease);
|
||||
} catch (error) {
|
||||
const code = object(object(error).details).code;
|
||||
const safeCode = code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed"
|
||||
? "slack_install_identity_mismatch" : code === "chat_provider_permissions_missing" ? "slack_install_scopes_missing"
|
||||
: code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete";
|
||||
await setFailure(row, "credentials_saved", safeCode, lease);
|
||||
await audit(row, actor, "configuration_failed", safeCode);
|
||||
return;
|
||||
}
|
||||
await lease.assertOwned();
|
||||
await db.transaction(async tx => {
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
});
|
||||
// Runtime now owns a separate vaulted copy. Keep the registration IDs until
|
||||
// deletion succeeds so cleanup itself is retryable.
|
||||
await cleanupStaged(endpointId, lease);
|
||||
try { await audit(row, actor, "installation_completed"); }
|
||||
catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); }
|
||||
await welcome(row, actor, lease);
|
||||
}
|
||||
async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) {
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status !== "configured") return;
|
||||
const ids = { ...row.secretIds };
|
||||
for (const key of ["botToken", "signingSecret"]) {
|
||||
if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; }
|
||||
}
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
}
|
||||
async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) {
|
||||
const before = await pending(state, actor);
|
||||
return options.withLock(before.row.endpointId, async lease => {
|
||||
const { row, attempt, current } = await pending(state, actor);
|
||||
const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning();
|
||||
if (!claimed) throw conflict("This Slack authorization was already used");
|
||||
if (error) {
|
||||
const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed";
|
||||
await setFailure(row, row.status, failureCode, lease);
|
||||
await audit(row, actor, "installation_failed", failureCode);
|
||||
return row.endpointId;
|
||||
}
|
||||
try {
|
||||
if (!code || code.length > 4096) throw providerFailure("slack_install_failed");
|
||||
const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri });
|
||||
const teamId = object(result.team).id;
|
||||
const installerId = object(result.authed_user).id;
|
||||
if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-")
|
||||
|| typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id)
|
||||
|| row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id)
|
||||
throw providerFailure("slack_install_identity_mismatch");
|
||||
const granted = new Set(String(result.scope ?? "").split(","));
|
||||
if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing");
|
||||
if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId
|
||||
|| current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id)
|
||||
throw providerFailure("slack_install_identity_mismatch");
|
||||
if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing");
|
||||
await endpoint(row.endpointId, actor);
|
||||
// Reauthorization reuses the runtime signing secret after staging was cleaned.
|
||||
await saveSecrets(row, { botToken: result.access_token }, actor, lease,
|
||||
{ status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null },
|
||||
{ externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" });
|
||||
} catch (failure) {
|
||||
const code = object(object(failure).details).code;
|
||||
const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed";
|
||||
await setFailure(row, row.status, safeCode, lease);
|
||||
await audit(row, actor, "installation_failed", safeCode);
|
||||
return row.endpointId;
|
||||
}
|
||||
await resumeLocked(row.endpointId, actor, lease);
|
||||
return row.endpointId;
|
||||
});
|
||||
}
|
||||
const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease);
|
||||
async function returnPath(endpointId: string) {
|
||||
const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints)
|
||||
.innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId));
|
||||
if (!row) throw notFound("Slack connection not found");
|
||||
return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`;
|
||||
}
|
||||
return { create, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt,
|
||||
close: async () => { await avatarPool?.close(); },
|
||||
resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, lease => resumeLocked(endpointId, actor, lease)) };
|
||||
}
|
||||
// Compatibility boundary: both provisioning paths converge on the same Slack runtime.
|
||||
export { slackChatRegistrationService } from "./connectors/slack/setup/service.js";
|
||||
export { slackRegistrationProjection, type SlackSetupActor } from "./connectors/slack/setup/registration.js";
|
||||
@@ -0,0 +1,164 @@
|
||||
import { notifyChatVerificationWork } from "../../../chat-work-notifications.js";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { chatEndpoints, chatSlackRegistrations } from "@paperclipai/db";
|
||||
import { SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES, type SlackAccountState } from "@paperclipai/shared";
|
||||
import { conflict, forbidden } from "../../../../errors.js";
|
||||
import { logger } from "../../../../middleware/logger.js";
|
||||
import { secretService } from "../../../secrets.js";
|
||||
import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js";
|
||||
import { object, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js";
|
||||
export function createSlackCompletion(store: SlackRegistrationStore) {
|
||||
const { db, options, endpoint, registration, assertOrigins, api, readSecret, audit, setFailure } = store;
|
||||
const vault = secretService(db);
|
||||
async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0];
|
||||
if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed");
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId));
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
const account = current.endpoint.setup.slackAccount;
|
||||
if (!account) throw providerFailure("slack_install_account_missing");
|
||||
if (account.status === "linked") return;
|
||||
const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId);
|
||||
const user = object((await api("users.info", { user: account.externalUserId }, token)).user);
|
||||
if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing");
|
||||
await endpoint(row.endpointId, actor);
|
||||
await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease);
|
||||
await saveAccount(row, { ...account, status: "linked" }, actor, lease);
|
||||
try { await audit(row, actor, "account_linked"); }
|
||||
catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); }
|
||||
}
|
||||
async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
const account = current.endpoint.setup.slackAccount;
|
||||
if (!account || account.status !== "linked") return;
|
||||
if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return;
|
||||
const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus";
|
||||
const status = account[field] ?? "pending";
|
||||
if (status === "sending") {
|
||||
await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease);
|
||||
return; // Never replay an ambiguous dispatch after a process restart.
|
||||
}
|
||||
if (status !== "pending") return;
|
||||
await saveAccount(row, { ...account, [field]: "sending" }, actor, lease);
|
||||
let result: SlackAccountState;
|
||||
try {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed");
|
||||
const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId,
|
||||
text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance."
|
||||
: `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`,
|
||||
unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId));
|
||||
if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response");
|
||||
result = { ...account, [field]: "sent", dmChannelId: sent.channel };
|
||||
} catch (error) {
|
||||
result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" };
|
||||
}
|
||||
await saveAccount(row, result, actor, lease);
|
||||
try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); }
|
||||
catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); }
|
||||
}
|
||||
async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await sendSetupMessage(row, actor, lease, "welcome");
|
||||
await sendSetupMessage(row, actor, lease, "verification");
|
||||
}
|
||||
async function notifyVerified(endpointId: string) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status !== "configured") return;
|
||||
const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId));
|
||||
const account = saved?.setup.slackAccount;
|
||||
if (!account || !["verifying", "active"].includes(saved.status)) return;
|
||||
await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification");
|
||||
});
|
||||
}
|
||||
function pendingVerificationMessages(limit: number) {
|
||||
return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints)
|
||||
.innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId)))
|
||||
.where(and(eq(chatSlackRegistrations.status, "configured"), inArray(chatEndpoints.status, ["verifying", "active"]),
|
||||
sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`,
|
||||
sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`,
|
||||
sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`,
|
||||
sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`)).limit(limit);
|
||||
}
|
||||
async function nextVerificationMessageAt(): Promise<number | null> {
|
||||
return (await pendingVerificationMessages(1)).length ? Date.now() : null;
|
||||
}
|
||||
async function processPendingVerificationMessages(limit = 25) {
|
||||
for (const row of await pendingVerificationMessages(limit)) {
|
||||
try { await notifyVerified(row.endpointId); }
|
||||
catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); }
|
||||
}
|
||||
}
|
||||
async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status === "removed") throw conflict("Slack registration is unavailable");
|
||||
if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; }
|
||||
if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it");
|
||||
try {
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
const botToken = await readSecret(row, "botToken");
|
||||
const auth = await api("auth.test", {}, botToken, row.managerGrantId ? [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES] : undefined);
|
||||
if (auth.team_id !== row.workspaceId || row.botUserId && auth.user_id !== row.botUserId || typeof auth.user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(auth.user_id)) throw providerFailure("slack_install_identity_mismatch");
|
||||
if (row.managerGrantId) {
|
||||
if (typeof auth.bot_id !== "string" || !/^B[A-Z0-9]+$/.test(auth.bot_id)) throw providerFailure("slack_install_identity_mismatch");
|
||||
const bot = object((await api("bots.info", { bot: auth.bot_id }, botToken)).bot);
|
||||
if (bot.id !== auth.bot_id || bot.app_id !== row.appId || bot.user_id !== auth.user_id || bot.deleted !== false) throw providerFailure("slack_install_identity_mismatch");
|
||||
}
|
||||
if (!row.botUserId) {
|
||||
if (!row.managerGrantId) throw providerFailure("slack_install_identity_mismatch");
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ botUserId: auth.user_id }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
}
|
||||
await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret
|
||||
? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, {
|
||||
assertOwned: async database => {
|
||||
await lease.assertOwned(database);
|
||||
await endpoint(endpointId, actor);
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
},
|
||||
});
|
||||
await linkAccount(row, actor, lease);
|
||||
} catch (error) {
|
||||
const code = object(object(error).details).code;
|
||||
const safeCode = code === "slack_configuration_token_invalid" ? "slack_install_token_invalid" : code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed"
|
||||
? "slack_install_identity_mismatch" : (code === "chat_provider_permissions_missing" || code === "slack_install_scopes_missing" || code === "slack_setup_permission_denied") ? "slack_install_scopes_missing"
|
||||
: code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete";
|
||||
await setFailure(row, "credentials_saved", safeCode, lease);
|
||||
await audit(row, actor, "configuration_failed", safeCode);
|
||||
return;
|
||||
}
|
||||
await lease.assertOwned();
|
||||
await db.transaction(async tx => {
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
});
|
||||
// Runtime now owns a separate vaulted copy. Keep the registration IDs until
|
||||
// deletion succeeds so cleanup itself is retryable.
|
||||
await cleanupStaged(endpointId, lease);
|
||||
try { await audit(row, actor, "installation_completed"); }
|
||||
catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); }
|
||||
await welcome(row, actor, lease);
|
||||
}
|
||||
async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) {
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status !== "configured") return;
|
||||
const ids = { ...row.secretIds };
|
||||
for (const key of ["botToken", "signingSecret"]) {
|
||||
if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; }
|
||||
}
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
}
|
||||
return { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt };
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { chatSlackRegistrations } from "@paperclipai/db";
|
||||
import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, type SlackRegistrationInput, type SlackAvatarState } from "@paperclipai/shared";
|
||||
import { conflict } from "../../../../errors.js";
|
||||
import { logger } from "../../../../middleware/logger.js";
|
||||
import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js";
|
||||
import { object, knownProviderErrors, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js";
|
||||
export function createCustomerOwnedSetup(store: SlackRegistrationStore) {
|
||||
const { db, options, endpoint, registration, callbackUri, origin, api, audit, saveSecrets, setFailure, saveAvatar, assertOrigins, renderAvatar } = store;
|
||||
async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput, managed?: { grantId: string; workspaceId: string; request: typeof api }) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const api = managed?.request ?? store.api;
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const previous = await registration(endpointId);
|
||||
const method = managed ? "managed" : "automatic";
|
||||
if (current.endpoint.setup.slackSetupMethod !== method) throw conflict("Resume the selected Slack setup method");
|
||||
if (previous && previous.managerGrantId !== (managed?.grantId ?? null)) throw conflict("Resume setup with the original Slack workspace");
|
||||
if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup.");
|
||||
if (previous?.appId) {
|
||||
if (previous.errorCode === "slack_manifest_update_pending") {
|
||||
if (!await configureManifest(previous, actor, input.credentials.configurationToken, lease, method, api)) return;
|
||||
if (current.endpoint.setup.slackAvatar?.status !== "uploaded") await configureAvatar(previous, actor, input.credentials.configurationToken, lease, api);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (previous?.requestId === input.requestId) return;
|
||||
if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) {
|
||||
await setFailure(previous, "uncertain", "slack_creation_uncertain", lease);
|
||||
return;
|
||||
}
|
||||
if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== method)
|
||||
throw conflict("Automatic creation is only available for a new Slack connection");
|
||||
const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp);
|
||||
const redirect = callbackUri();
|
||||
const manifest = buildSlackAppManifest({ app, agentName: current.agentName,
|
||||
webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect });
|
||||
// Slack cannot authenticate a challenge until its creation response gives us
|
||||
// the signing secret. Add event subscriptions only after that secret is durable.
|
||||
const { event_subscriptions: _events, ...creationSettings } = manifest.settings;
|
||||
const creationManifest = { ...manifest, settings: creationSettings };
|
||||
try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); }
|
||||
catch (error) {
|
||||
if (error instanceof Error && "status" in error) throw error;
|
||||
throw providerFailure("slack_provider_failure");
|
||||
}
|
||||
// No provider creation has happened until this intent is durable.
|
||||
await endpoint(endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const,
|
||||
manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect,
|
||||
createdByUserId: actor.userId, managerGrantId: managed?.grantId ?? null, workspaceId: managed?.workspaceId ?? null, errorCode: null, updatedAt: new Date() };
|
||||
const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values })
|
||||
.onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning();
|
||||
await audit(row, actor, "creation_started");
|
||||
let createdAppId: string;
|
||||
try {
|
||||
const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken);
|
||||
const credentials = object(result.credentials);
|
||||
if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id)
|
||||
|| typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id)
|
||||
|| typeof credentials.client_secret !== "string" || !credentials.client_secret
|
||||
|| typeof credentials.signing_secret !== "string" || !credentials.signing_secret)
|
||||
throw new Error("Incomplete Slack creation response");
|
||||
if (managed && result.team_id !== managed.workspaceId) throw new Error("Slack creation workspace mismatch");
|
||||
await endpoint(endpointId, actor);
|
||||
await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease,
|
||||
{ appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" });
|
||||
createdAppId = result.app_id;
|
||||
} catch (error) {
|
||||
// Only documented rejection responses prove that creation did not happen.
|
||||
const code = object(object(error).details).code;
|
||||
const rejected = typeof code === "string" && [...Object.values(knownProviderErrors), "slack_manager_reauthorize"].includes(code);
|
||||
await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease);
|
||||
await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain");
|
||||
return;
|
||||
}
|
||||
// The app and credentials are durable before optional icon provisioning.
|
||||
// A retry/restart always reuses this app, even if icon upload is interrupted.
|
||||
const created = { ...row, appId: createdAppId };
|
||||
if (!await configureManifest(created, actor, input.credentials.configurationToken, lease, method, api)) return;
|
||||
await configureAvatar(created, actor, input.credentials.configurationToken, lease, api);
|
||||
// An audit outage cannot change the saved creation outcome.
|
||||
try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); }
|
||||
catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); }
|
||||
});
|
||||
}
|
||||
async function configureAvatar(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, api = store.api) {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
await saveAvatar(row, { status: "pending" }, actor, lease);
|
||||
let avatar: SlackAvatarState;
|
||||
try {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
// Cloud ingress requires a tenant session. Send the preset PNG bytes so
|
||||
// Slack does not need to fetch an authenticated Paperclip URL.
|
||||
const render = renderAvatar;
|
||||
const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) };
|
||||
delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets.
|
||||
const png = await render({ appearance,
|
||||
size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" });
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
const upload = new FormData();
|
||||
upload.set("app_id", row.appId!);
|
||||
upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png");
|
||||
await api("apps.icon.set", upload, configurationToken);
|
||||
avatar = { status: "uploaded", uploadedAt: new Date().toISOString() };
|
||||
} catch {
|
||||
// Provider echoes (including tokens) never become saved state or activity.
|
||||
avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" };
|
||||
}
|
||||
await saveAvatar(row, avatar, actor, lease);
|
||||
try { await audit(row, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); }
|
||||
catch { logger.warn({ endpointId: row.endpointId, appId: row.appId! }, "Slack avatar activity could not be recorded"); }
|
||||
}
|
||||
async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, method: "automatic" | "managed" = "automatic", api = store.api) {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== method)
|
||||
throw conflict("Resume the saved Slack connection before configuring its app");
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
await lease.assertOwned();
|
||||
try {
|
||||
await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken);
|
||||
} catch {
|
||||
// The pending marker was saved with the app secrets before dispatch. Retrying
|
||||
// the same manifest is safe even when Slack's response was lost.
|
||||
await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending");
|
||||
return false;
|
||||
}
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() })
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId)));
|
||||
await audit(row, actor, "manifest_updated");
|
||||
return true;
|
||||
}
|
||||
return { create, configureManifest };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
||||
import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js";
|
||||
import { createSlackSetupClient, object, providerFailure } from "./provider-client.js";
|
||||
|
||||
export type SlackManagerClient = Pick<PaperclipCloudConnector,
|
||||
"getSlackManagerAppId" | "startAuthorization" | "claim" | "refresh" | "revoke"
|
||||
> & { authorizationMode?: "code" };
|
||||
|
||||
// Explicit local qualification only. Production continues using the sealed Cloud
|
||||
// broker; ordinary self-hosted installs neither load credentials nor contact it.
|
||||
export function developmentSlackManager(
|
||||
publicOrigin: () => string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): SlackManagerClient | null {
|
||||
if (env.NODE_ENV !== "development" || env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true"
|
||||
|| !env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG) return null;
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
const path = env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG;
|
||||
if (!isAbsolute(path)) throw new Error();
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error();
|
||||
config = object(JSON.parse(readFileSync(path, "utf8")));
|
||||
} catch { throw providerFailure("slack_managed_unavailable"); }
|
||||
const { appId, clientId, clientSecret } = config;
|
||||
if (typeof appId !== "string" || !/^A[A-Z0-9]+$/.test(appId)
|
||||
|| typeof clientId !== "string" || !/^\d+\.\d+$/.test(clientId)
|
||||
|| typeof clientSecret !== "string" || !clientSecret) throw providerFailure("slack_managed_unavailable");
|
||||
const callback = () => {
|
||||
const url = new URL(publicOrigin());
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw providerFailure("slack_managed_unavailable");
|
||||
return `${url.origin}/api/chat-slack/managed/oauth/callback`;
|
||||
};
|
||||
const api = createSlackSetupClient(fetchImpl);
|
||||
async function credentials(fields: Record<string, string>, subject: string, companyId: string, initial: boolean): Promise<SealedConnectorCredentials> {
|
||||
let result: Record<string, unknown>;
|
||||
try { result = await api("oauth.v2.access", { client_id: clientId as string, client_secret: clientSecret as string, ...fields }); }
|
||||
catch { throw providerFailure("slack_manager_reauthorize"); }
|
||||
const user = initial ? object(result.authed_user) : result;
|
||||
const scopes = typeof user.scope === "string" ? user.scope.split(/[, ]+/) : [];
|
||||
const team = object(result.team);
|
||||
if (typeof user.access_token !== "string" || !user.access_token || typeof user.refresh_token !== "string" || !user.refresh_token
|
||||
|| user.token_type !== "user" || typeof user.expires_in !== "number" || !Number.isFinite(user.expires_in) || user.expires_in <= 0
|
||||
|| SLACK_MANAGER_SCOPES.some(scope => !scopes.includes(scope))
|
||||
|| (initial && (result.app_id !== appId || typeof team.id !== "string" || !/^T[A-Z0-9]+$/.test(team.id)
|
||||
|| typeof team.name !== "string" || !team.name || typeof user.id !== "string" || !/^[UW][A-Z0-9]+$/.test(user.id)))) throw providerFailure("slack_manager_reauthorize");
|
||||
return {
|
||||
v: 1, accessToken: user.access_token, refreshToken: user.refresh_token, tokenType: "user",
|
||||
accessTokenExpiresAt: new Date(Date.now() + user.expires_in * 1000).toISOString(), refreshTokenExpiresAt: null,
|
||||
scopes, subject, companyId, instanceId: "local-slack-qualification", environment: "development", provider: "slack", profile: "slack.manager",
|
||||
...(initial ? { slackManager: { appId: appId as string, workspaceId: team.id as string, workspaceName: team.name as string, userId: user.id as string } } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
authorizationMode: "code",
|
||||
async getSlackManagerAppId() { callback(); return appId as string; },
|
||||
async startAuthorization(values) {
|
||||
if (values.returnUri !== callback() || values.profile !== "slack.manager") throw providerFailure("slack_manager_reauthorize");
|
||||
const url = new URL("https://slack.com/oauth/v2/authorize");
|
||||
url.search = new URLSearchParams({ client_id: clientId as string, user_scope: SLACK_MANAGER_SCOPES.join(","), redirect_uri: callback(), state: values.returnState }).toString();
|
||||
return { authorizationUrl: url.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() };
|
||||
},
|
||||
async claim(values) { return credentials({ code: values.claimId, redirect_uri: callback() }, values.subject, values.companyId, true); },
|
||||
async refresh(values) { return credentials({ grant_type: "refresh_token", refresh_token: values.refreshToken }, values.subject, values.companyId, false); },
|
||||
async revoke(values) {
|
||||
try { await api("auth.revoke", {}, values.token); }
|
||||
catch { throw providerFailure("slack_manager_reauthorize"); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { chatActions, chatSlackRegistrations } from "@paperclipai/db";
|
||||
import { type SlackManagedProvisionInput, type SlackManagedResult } from "@paperclipai/shared";
|
||||
import { conflict } from "../../../../errors.js";
|
||||
import type { createCustomerOwnedSetup } from "./customer-owned.js";
|
||||
import type { createSlackManagerGrants } from "./manager-grants.js";
|
||||
import type { createSlackCompletion } from "./completion.js";
|
||||
import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js";
|
||||
|
||||
/** Managed provisioning ends at the same saved-credential boundary as child OAuth. */
|
||||
export function createManagedSlackSetup(store: SlackRegistrationStore, grants: ReturnType<typeof createSlackManagerGrants>,
|
||||
provisioner: ReturnType<typeof createCustomerOwnedSetup>, completion: ReturnType<typeof createSlackCompletion>,
|
||||
installFallback: (endpointId: string, actor: SlackSetupActor) => Promise<{ authorizationUrl: string; expiresAt: string }>) {
|
||||
async function provision(endpointId: string, actor: SlackSetupActor, input: SlackManagedProvisionInput): Promise<SlackManagedResult> {
|
||||
const current = await store.endpoint(endpointId, actor);
|
||||
if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method");
|
||||
const authorized = await grants.token(input.grantId, current.endpoint.companyId, actor);
|
||||
await provisioner.create(endpointId, actor, { requestId: input.requestId, confirmedNoAppCreated: input.confirmedNoAppCreated,
|
||||
credentials: { configurationToken: authorized.token } }, { grantId: authorized.grant.id, workspaceId: authorized.grant.workspaceId, request: (method, fields) => grants.request(input.grantId, current.endpoint.companyId, actor, method, fields) });
|
||||
let fallback = false;
|
||||
await store.options.withLock(endpointId, async lease => {
|
||||
const latest = await store.endpoint(endpointId, actor);
|
||||
const row = await store.registration(endpointId);
|
||||
if (!row?.appId || row.status === "removed" || row.errorCode === "slack_manifest_update_pending") return;
|
||||
if (row.managerGrantId !== authorized.grant.id || latest.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Slack setup changed");
|
||||
store.assertOrigins(row, latest.endpoint.publicId);
|
||||
const reconnectKey = input.reconnect ? `slack_managed_reconnect:${actor.userId}:${input.requestId}` : null;
|
||||
const [previous] = reconnectKey ? await store.db.select().from(chatActions).where(and(eq(chatActions.companyId, row.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey))) : [];
|
||||
async function completeSaved() {
|
||||
await completion.resumeLocked(endpointId, actor, lease);
|
||||
if (reconnectKey && (await store.registration(endpointId))?.status === "configured") {
|
||||
await lease.assertOwned();
|
||||
await store.db.update(chatActions).set({ status: "processed", updatedAt: new Date() }).where(and(eq(chatActions.companyId, row!.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey)));
|
||||
}
|
||||
}
|
||||
if (previous && row.status === "configured" && !["revoked", "attention"].includes(latest.endpoint.status)) {
|
||||
await completeSaved(); return;
|
||||
}
|
||||
const needsReinstall = row.status === "configured" && (input.reconnect || ["revoked", "attention"].includes(latest.endpoint.status))
|
||||
|| row.status === "credentials_saved" && ["slack_install_token_invalid", "slack_install_scopes_missing"].includes(row.errorCode ?? "");
|
||||
if (reconnectKey && !previous) {
|
||||
// Atomically bind an explicit reconnect to its durable installation intent.
|
||||
// Duplicate requests can return the saved connection without reinstalling.
|
||||
await store.db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
await tx.insert(chatActions).values({ companyId: row.companyId, endpointId, kind: "slack_managed_reconnect", providerActionId: reconnectKey, status: "processing", payload: { grantId: row.managerGrantId, registrationRequestId: row.requestId } });
|
||||
if (needsReinstall) await tx.update(chatSlackRegistrations).set({ status: "install", errorCode: row.errorCode ?? "slack_install_failed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
});
|
||||
}
|
||||
if ((row.status === "configured" || row.status === "credentials_saved") && !needsReinstall) {
|
||||
await completeSaved();
|
||||
return;
|
||||
}
|
||||
// Keep reinstall intent durable even if this dispatch fails or the process exits.
|
||||
if (needsReinstall && (!reconnectKey || previous)) await store.setFailure(row, "install", row.errorCode ?? "slack_install_failed", lease);
|
||||
const { grant } = await grants.token(input.grantId, row.companyId, actor);
|
||||
try {
|
||||
await lease.assertOwned();
|
||||
const result = await grants.request(grant.id, row.companyId, actor, "apps.managedInstall", { app_id: row.appId, team_id: grant.workspaceId });
|
||||
const botToken = object(result.api_access_tokens).bot_access_token;
|
||||
if (result.app_id !== row.appId || result.team_id !== grant.workspaceId || typeof botToken !== "string" || !botToken.startsWith("xoxb-")) throw providerFailure("slack_install_identity_mismatch");
|
||||
if ((await grants.get(grant.id, row.companyId, actor)).status !== "active") throw providerFailure("slack_manager_reauthorize");
|
||||
await store.endpoint(endpointId, actor);
|
||||
// Vault before auth.test/inventory so network failures never lose this installation.
|
||||
await store.saveSecrets(row, { botToken }, actor, lease, { status: "credentials_saved", workspaceId: grant.workspaceId, errorCode: null },
|
||||
{ externalUserId: grant.slackUserId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" });
|
||||
} catch (error) {
|
||||
const code = object(object(error).details).code;
|
||||
const safe = typeof code === "string" ? code : "slack_install_failed";
|
||||
await store.setFailure(row, needsReinstall ? "install" : row.status, safe, lease);
|
||||
await store.audit(row, actor, "installation_failed", safe);
|
||||
fallback = ["slack_approval_required", "slack_approval_pending", "slack_approval_denied"].includes(safe);
|
||||
return;
|
||||
}
|
||||
await completeSaved();
|
||||
});
|
||||
// OAuth's state/actor binding is ours; never trust a provider-supplied redirect.
|
||||
return fallback ? { authorization: await installFallback(endpointId, actor) } : {};
|
||||
}
|
||||
return { provision };
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { and, eq, gt, like, sql } from "drizzle-orm";
|
||||
import { chatEndpoints, chatSlackManagerGrants, chatSlackRegistrations, companySecrets, toolOauthStates } from "@paperclipai/db";
|
||||
import { SLACK_MANAGER_SCOPES, type SlackSetupOptions as SetupChoices } from "@paperclipai/shared";
|
||||
import { conflict, forbidden } from "../../../../errors.js";
|
||||
import { getCloudRuntimeIdentity } from "../../../cloud-runtime-identity.js";
|
||||
import { createPaperclipCloudConnector, paperclipCloudConnectorConfigFromEnv, type SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js";
|
||||
import { instanceSettingsService } from "../../../instance-settings.js";
|
||||
import { accessService } from "../../../access.js";
|
||||
import { secretService } from "../../../secrets.js";
|
||||
import { writeConnectionCredential } from "../../../connection-credentials.js";
|
||||
import { logActivity } from "../../../activity-log.js";
|
||||
import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js";
|
||||
import { developmentSlackManager, type SlackManagerClient } from "./development-manager.js";
|
||||
|
||||
export const SLACK_MANAGER_STATE_PREFIX = "slack-manager.";
|
||||
export function createSlackManagerGrants(store: SlackRegistrationStore) {
|
||||
const { db, options, endpoint, origin, api } = store;
|
||||
type Database = Parameters<typeof secretService>[0];
|
||||
function broker(): SlackManagerClient | null {
|
||||
if (options.managed) return options.managed.enabled ? options.managed.broker : null;
|
||||
if (!getCloudRuntimeIdentity()) {
|
||||
const development = developmentSlackManager(() => origin(options.publicOrigin()));
|
||||
if (development) return development;
|
||||
}
|
||||
// Open-source instances never contact a hosted service to discover this path.
|
||||
if (!getCloudRuntimeIdentity() || process.env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true") return null;
|
||||
const config = paperclipCloudConnectorConfigFromEnv();
|
||||
return config ? createPaperclipCloudConnector({ config }) : null;
|
||||
}
|
||||
async function access(companyId: string, actor: SlackSetupActor, database: Database = db) {
|
||||
if (!(await instanceSettingsService(database as typeof db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
|
||||
if (!actor.bypassPermissionCheck && !await accessService(database as typeof db).hasPermission(companyId, "user", actor.userId, "tools:manage_connections")) throw forbidden("Missing permission: tools:manage_connections");
|
||||
}
|
||||
// Deployment intent is stable across broker outages and preview rollbacks.
|
||||
// A missing live profile pauses managed setup; it must not select token entry.
|
||||
function defaultMethod(): "managed" | "automatic" { return broker() ? "managed" : "automatic"; }
|
||||
async function available() {
|
||||
const client = broker();
|
||||
if (!client || !(await instanceSettingsService(db).getExperimental()).enableChatConnectors) return false;
|
||||
try { origin(options.publicOrigin()); origin(options.webhookOrigin()); return Boolean(await client.getSlackManagerAppId()); }
|
||||
catch { return false; }
|
||||
}
|
||||
async function requiredBroker() {
|
||||
if (!await available()) throw providerFailure("slack_managed_unavailable");
|
||||
return broker()!;
|
||||
}
|
||||
async function choices(companyId: string, actor: SlackSetupActor): Promise<SetupChoices> {
|
||||
await access(companyId, actor);
|
||||
const managedAvailable = await available();
|
||||
const managerAppId = managedAvailable ? await broker()!.getSlackManagerAppId() : null;
|
||||
const grants = managedAvailable && managerAppId ? await db.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.status, "active"), eq(chatSlackManagerGrants.managerAppId, managerAppId))) : [];
|
||||
return { managedAvailable, defaultMethod: defaultMethod(), workspaces: grants.map(g => ({ grantId: g.id, workspaceId: g.workspaceId, workspaceName: g.workspaceName, userId: g.slackUserId })) };
|
||||
}
|
||||
const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/managed/oauth/callback`;
|
||||
async function authorize(endpointId: string, actor: SlackSetupActor) {
|
||||
const client = await requiredBroker();
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method");
|
||||
const row = await store.registration(endpointId);
|
||||
const revisions = await db.select({ id: chatSlackManagerGrants.id, revision: chatSlackManagerGrants.revision }).from(chatSlackManagerGrants)
|
||||
.where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId)));
|
||||
const state = `${SLACK_MANAGER_STATE_PREFIX}${randomBytes(32).toString("base64url")}`;
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
await lease.assertOwned();
|
||||
await db.insert(toolOauthStates).values({ state, companyId: current.endpoint.companyId, connectionId: current.connection.id,
|
||||
codeVerifier: JSON.stringify({ endpointId, revisions, authorizationMode: client.authorizationMode ?? "claim", requestId: row?.requestId ?? null, callbackUri: callbackUri(), webhookOrigin: origin(options.webhookOrigin()), runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }),
|
||||
createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, requestedScopes: [...SLACK_MANAGER_SCOPES], expiresAt });
|
||||
try { return await client.startAuthorization({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, returnUri: callbackUri(), returnState: state }); }
|
||||
catch { await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); throw providerFailure("slack_manager_reauthorize"); }
|
||||
});
|
||||
}
|
||||
async function pending(state: string, actor: SlackSetupActor, allowExpired = false) {
|
||||
if (!/^slack-manager\.[A-Za-z0-9_-]{43}$/.test(state)) throw forbidden("Invalid Slack authorization state");
|
||||
const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), ...(allowExpired ? [] : [gt(toolOauthStates.expiresAt, new Date())])));
|
||||
if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) throw forbidden("Slack authorization expired or belongs to another session");
|
||||
const binding = object(JSON.parse(attempt.codeVerifier));
|
||||
const current = await endpoint(String(binding.endpointId), actor);
|
||||
const row = await store.registration(current.endpoint.id);
|
||||
if (current.endpoint.setup.slackSetupMethod !== "managed" || current.connection.id !== attempt.connectionId || current.endpoint.companyId !== attempt.companyId
|
||||
|| ["paused", "revoked"].includes(current.endpoint.status) || row?.status === "removed"
|
||||
|| (binding.authorizationMode ?? "claim") !== (broker()?.authorizationMode ?? "claim")
|
||||
|| (row?.requestId ?? null) !== binding.requestId || binding.callbackUri !== callbackUri() || binding.webhookOrigin !== origin(options.webhookOrigin())
|
||||
|| binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0)) throw conflict("Slack setup changed. Resume authorization.");
|
||||
return { attempt, current, row, binding };
|
||||
}
|
||||
async function expiredReturn(state: string, actor: SlackSetupActor) {
|
||||
const { current, attempt } = await pending(state, actor, true);
|
||||
if (attempt.expiresAt.getTime() > Date.now()) return null;
|
||||
return options.withLock(current.endpoint.id, async lease => {
|
||||
const latest = await pending(state, actor, true);
|
||||
await lease.assertOwned();
|
||||
await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state));
|
||||
await db.update(chatEndpoints).set({ setup: { ...latest.current.endpoint.setup, slackManagerError: "slack_install_expired" } }).where(eq(chatEndpoints.id, current.endpoint.id));
|
||||
return current.endpoint.id;
|
||||
});
|
||||
}
|
||||
async function complete(state: string, claimId: string | null, error: string | null, actor: SlackSetupActor, authorizationCode: string | null = null) {
|
||||
const client = await requiredBroker();
|
||||
claimId = client.authorizationMode === "code" ? authorizationCode : claimId;
|
||||
const before = await pending(state, actor);
|
||||
return options.withLock(before.current.endpoint.id, async lease => {
|
||||
const { current, row, binding } = await pending(state, actor);
|
||||
await lease.assertOwned();
|
||||
const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning();
|
||||
if (!claimed) throw conflict("Slack authorization was already used");
|
||||
async function outcome(code: string | null) {
|
||||
await lease.assertOwned();
|
||||
const latest = await endpoint(current.endpoint.id, actor);
|
||||
await db.update(chatEndpoints).set({ setup: { ...latest.endpoint.setup, slackManagerError: code ?? undefined } }).where(eq(chatEndpoints.id, current.endpoint.id));
|
||||
if (code) await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorization_failed", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { code } });
|
||||
return current.endpoint.id;
|
||||
}
|
||||
if (error || !claimId || claimId.length > 4096) return outcome(error === "access_denied" ? "slack_install_declined" : "slack_manager_reauthorize");
|
||||
try {
|
||||
let grant: SealedConnectorCredentials;
|
||||
try { grant = await client.claim({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, claimId, redemptionId: randomUUID() }); }
|
||||
catch { throw providerFailure("slack_manager_reauthorize"); }
|
||||
const identity = grant.slackManager;
|
||||
if (!identity || identity.appId !== await client.getSlackManagerAppId() || !/^A[A-Z0-9]+$/.test(identity.appId) || !/^T[A-Z0-9]+$/.test(identity.workspaceId) || !/^[UW][A-Z0-9]+$/.test(identity.userId) || typeof identity.workspaceName !== "string" || !identity.workspaceName || identity.workspaceName.length > 255) throw providerFailure("slack_manager_reauthorize");
|
||||
if (row?.managerGrantId) {
|
||||
const existing = await get(row.managerGrantId, current.endpoint.companyId, actor);
|
||||
if (existing.managerAppId !== identity.appId || existing.workspaceId !== identity.workspaceId || existing.slackUserId !== identity.userId) throw providerFailure("slack_install_identity_mismatch");
|
||||
}
|
||||
await endpoint(current.endpoint.id, actor);
|
||||
await access(current.endpoint.companyId, actor);
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager:${current.endpoint.companyId}:${actor.userId}:${identity.appId}:${identity.workspaceId}`}, 0))`);
|
||||
const [existing] = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.managerAppId, identity.appId), eq(chatSlackManagerGrants.workspaceId, identity.workspaceId)));
|
||||
if (existing) {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${existing.id}`}, 0))`);
|
||||
const [latest] = await tx.select().from(chatSlackManagerGrants).where(eq(chatSlackManagerGrants.id, existing.id));
|
||||
const revision = Array.isArray(binding.revisions) ? binding.revisions.map(object).find(value => value.id === existing.id)?.revision : undefined;
|
||||
if (!latest || latest.revision !== revision) throw conflict("Workspace authorization changed. Start again.");
|
||||
}
|
||||
const [saved] = await tx.insert(chatSlackManagerGrants).values({ companyId: current.endpoint.companyId, userId: actor.userId, slackUserId: identity.userId, workspaceId: identity.workspaceId, workspaceName: identity.workspaceName, managerAppId: identity.appId })
|
||||
.onConflictDoUpdate({ target: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.userId, chatSlackManagerGrants.managerAppId, chatSlackManagerGrants.workspaceId], set: { updatedAt: new Date() } }).returning();
|
||||
// A reauthorization must never change which Slack person this grant represents.
|
||||
if (saved.slackUserId !== identity.userId) throw conflict("Reconnect using the original Slack account");
|
||||
await access(current.endpoint.companyId, actor, tx);
|
||||
await save(tx, saved, grant, actor);
|
||||
if (row?.managerGrantId === saved.id) {
|
||||
await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() })
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, current.endpoint.id), eq(chatSlackRegistrations.managerGrantId, saved.id), eq(chatSlackRegistrations.errorCode, "slack_manager_reauthorize")));
|
||||
}
|
||||
});
|
||||
await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorized", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { workspaceId: identity.workspaceId, managerAppId: identity.appId } });
|
||||
return outcome(null);
|
||||
} catch { return outcome("slack_manager_reauthorize"); }
|
||||
});
|
||||
}
|
||||
type Grant = typeof chatSlackManagerGrants.$inferSelect;
|
||||
async function get(id: string, companyId: string, actor: SlackSetupActor) {
|
||||
await access(companyId, actor);
|
||||
return readGrant(db, id, companyId, actor);
|
||||
}
|
||||
async function readGrant(database: Database, id: string, companyId: string, actor: SlackSetupActor) {
|
||||
await access(companyId, actor, database);
|
||||
const [grant] = await database.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.id, id), eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId)));
|
||||
if (!grant) throw forbidden("This Slack workspace authorization belongs to another account");
|
||||
return grant;
|
||||
}
|
||||
async function save(tx: Parameters<Parameters<typeof db.transaction>[0]>[0], row: Grant, credentials: SealedConnectorCredentials, actor: SlackSetupActor) {
|
||||
if (credentials.tokenType !== "user" || !credentials.accessToken || !credentials.refreshToken || !credentials.accessTokenExpiresAt || !Number.isFinite(Date.parse(credentials.accessTokenExpiresAt)) || Date.parse(credentials.accessTokenExpiresAt) <= Date.now() || credentials.provider !== "slack" || credentials.profile !== "slack.manager" || credentials.subject !== row.userId || credentials.companyId !== row.companyId || SLACK_MANAGER_SCOPES.some(scope => !credentials.scopes.includes(scope))) throw providerFailure("slack_manager_reauthorize");
|
||||
const write = (key: string, value: string, secretId: string | null) => writeConnectionCredential(tx, { companyId: row.companyId, ownerUserId: row.userId, actor: { userId: actor.userId }, connectionName: "Slack workspace management", configPath: `slack_manager.${key}`, definitionKey: `slack_manager.${row.id}.${key}`, label: key, value, ...(secretId ? { existingRef: { secretId, configPath: `slack_manager.${key}` } } : {}) });
|
||||
const accessToken = await write("access_token", credentials.accessToken, row.accessSecretId);
|
||||
const refreshToken = credentials.refreshToken ? await write("refresh_token", credentials.refreshToken, row.refreshSecretId) : null;
|
||||
await tx.update(chatSlackManagerGrants).set({ accessSecretId: accessToken.secret.id, refreshSecretId: refreshToken?.secret.id ?? row.refreshSecretId, expiresAt: credentials.accessTokenExpiresAt ? new Date(credentials.accessTokenExpiresAt) : null, status: "active", revision: row.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, row.id));
|
||||
}
|
||||
async function secret(row: Grant, id: string | null, database: Database = db) {
|
||||
if (!id) throw providerFailure("slack_manager_reauthorize");
|
||||
const [ref] = await database.select().from(companySecrets).where(and(eq(companySecrets.id, id), eq(companySecrets.companyId, row.companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, row.userId)));
|
||||
if (!ref?.userSecretDefinitionId) throw providerFailure("slack_manager_reauthorize");
|
||||
// Owner-scoped system consumer; the durable grant is its authority, not a tool binding.
|
||||
const value = await secretService(database).resolveUserSecretValue(row.companyId, { definitionId: ref.userSecretDefinitionId, responsibleUserId: row.userId }, { consumerType: "system", consumerId: `slack-manager:${row.id}`, actorType: "user", actorId: row.userId, responsibleUserId: row.userId });
|
||||
if (!value) throw providerFailure("slack_manager_reauthorize");
|
||||
return value.value;
|
||||
}
|
||||
const tokenRequests = new Map<string, Promise<{ grant: Grant; token: string }>>();
|
||||
async function token(id: string, companyId: string, actor: SlackSetupActor) {
|
||||
await access(companyId, actor);
|
||||
const client = await requiredBroker();
|
||||
const managerAppId = await client.getSlackManagerAppId();
|
||||
const key = `${companyId}:${actor.userId}:${id}`;
|
||||
const running = tokenRequests.get(key);
|
||||
if (running) return running;
|
||||
const operation = (async () => {
|
||||
// Commit the single-use refresh claim before dispatch, without holding a
|
||||
// pool connection while another query or the broker needs it.
|
||||
const prepared = await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`);
|
||||
const grant = await readGrant(tx, id, companyId, actor);
|
||||
if (grant.managerAppId !== managerAppId || grant.status !== "active") throw providerFailure("slack_manager_reauthorize");
|
||||
if (!grant.expiresAt || grant.expiresAt.getTime() > Date.now() + 60_000) return { grant, token: await secret(grant, grant.accessSecretId, tx) };
|
||||
const refreshToken = await secret(grant, grant.refreshSecretId, tx);
|
||||
const [claimed] = await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: grant.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)).returning();
|
||||
return { grant: claimed!, refreshToken };
|
||||
});
|
||||
if ("token" in prepared) return { grant: prepared.grant, token: prepared.token! };
|
||||
try {
|
||||
const refreshed = await client.refresh({ profile: "slack.manager", subject: actor.userId, companyId, refreshToken: prepared.refreshToken });
|
||||
await access(companyId, actor);
|
||||
return await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`);
|
||||
const current = await readGrant(tx, id, companyId, actor);
|
||||
if (current.status !== "reauthorize" || current.revision !== prepared.grant.revision) throw providerFailure("slack_manager_reauthorize");
|
||||
await save(tx, current, refreshed, actor);
|
||||
return { grant: await readGrant(tx, id, companyId, actor), token: refreshed.accessToken };
|
||||
});
|
||||
} catch { throw providerFailure("slack_manager_reauthorize"); }
|
||||
})();
|
||||
tokenRequests.set(key, operation);
|
||||
try { return await operation; } finally { if (tokenRequests.get(key) === operation) tokenRequests.delete(key); }
|
||||
}
|
||||
async function revoke(id: string, companyId: string, actor: SlackSetupActor) {
|
||||
await access(companyId, actor);
|
||||
let providerRevoked = true;
|
||||
const grant = await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`);
|
||||
const current = await readGrant(tx, id, companyId, actor);
|
||||
await tx.update(chatSlackManagerGrants).set({ status: "revoked", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id));
|
||||
await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.companyId, companyId), eq(toolOauthStates.createdByActorId, actor.userId), like(toolOauthStates.state, `${SLACK_MANAGER_STATE_PREFIX}%`)));
|
||||
const client = broker();
|
||||
for (const secretId of [current.accessSecretId, current.refreshSecretId]) if (secretId) {
|
||||
try {
|
||||
if (!client) throw providerFailure("slack_managed_unavailable");
|
||||
await client.revoke({ subject: actor.userId, companyId, profile: "slack.manager", token: await secret(current, secretId, tx) });
|
||||
} catch { providerRevoked = false; }
|
||||
await secretService(tx).remove(secretId);
|
||||
}
|
||||
await tx.update(chatSlackManagerGrants).set({ accessSecretId: null, refreshSecretId: null }).where(eq(chatSlackManagerGrants.id, id));
|
||||
return current;
|
||||
});
|
||||
await logActivity(db, { companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_revoked", entityType: "slack_manager_grant", entityId: id, details: { workspaceId: grant.workspaceId, providerRevoked } });
|
||||
}
|
||||
async function request(id: string, companyId: string, actor: SlackSetupActor, method: string, fields: Record<string, string> | FormData) {
|
||||
const authorized = await token(id, companyId, actor);
|
||||
const { grant } = authorized;
|
||||
// Slack shares a method budget across manager tokens in the same workspace.
|
||||
// Persist Retry-After across grants and process restarts; never retry a dispatch.
|
||||
const result = await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-api:${companyId}:${grant.managerAppId}:${grant.workspaceId}:${method}`}, 0))`);
|
||||
// Serialize provider dispatch against refresh/revocation for this personal grant.
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`);
|
||||
const peers = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.managerAppId, grant.managerAppId), eq(chatSlackManagerGrants.workspaceId, grant.workspaceId))).orderBy(chatSlackManagerGrants.id);
|
||||
if (peers.some(peer => Date.parse(peer.rateLimits[method] ?? "") > Date.now())) return { error: providerFailure("slack_setup_rate_limited") };
|
||||
const current = await readGrant(tx, id, companyId, actor);
|
||||
if (current.status !== "active") return { error: providerFailure("slack_manager_reauthorize") };
|
||||
try { return { value: await api(method, fields, await secret(current, current.accessSecretId, tx)) }; }
|
||||
catch (error) {
|
||||
const details = object(object(error).details);
|
||||
if (details.code === "slack_setup_rate_limited") {
|
||||
const until = new Date(Date.now() + Math.min(Number(details.retryAfterSeconds) || 60, 86_400) * 1000).toISOString();
|
||||
for (const peer of peers) await tx.update(chatSlackManagerGrants).set({ rateLimits: sql`${chatSlackManagerGrants.rateLimits} || ${JSON.stringify({ [method]: until })}::jsonb` }).where(eq(chatSlackManagerGrants.id, peer.id));
|
||||
}
|
||||
if (details.code === "slack_configuration_token_invalid") {
|
||||
await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id));
|
||||
return { error: providerFailure("slack_manager_reauthorize") };
|
||||
}
|
||||
return { error };
|
||||
}
|
||||
});
|
||||
if ("error" in result) throw result.error;
|
||||
return result.value!;
|
||||
}
|
||||
return { choices, defaultMethod, available, authorize, pending, expiredReturn, complete, get, token, revoke, request };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { slackRegistrationErrorMessage } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../../../../errors.js";
|
||||
export const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
export const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code });
|
||||
export const knownProviderErrors: Record<string, string> = {
|
||||
app_approval_request_eligible: "slack_approval_required", app_approval_request_pending: "slack_approval_pending",
|
||||
app_approval_request_denied: "slack_approval_denied", admin_approval_required: "slack_approval_required",
|
||||
manager_app_not_eligible: "slack_managed_unavailable", feature_not_enabled: "slack_managed_unavailable",
|
||||
invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid",
|
||||
token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid",
|
||||
invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid",
|
||||
ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied",
|
||||
missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid",
|
||||
};
|
||||
|
||||
const timeoutMs = 20_000;
|
||||
export function createSlackSetupClient(fetchImpl: typeof fetch = fetch) {
|
||||
return async function api(method: string, fields: Record<string, string> | FormData, bearer?: string, requiredScopes?: readonly string[]) {
|
||||
const response = await fetchImpl(`https://slack.com/api/${method}`, {
|
||||
method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs),
|
||||
headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) },
|
||||
body: fields instanceof FormData ? fields : new URLSearchParams(fields),
|
||||
});
|
||||
// Bound the streamed response as well as the request lifetime.
|
||||
const reader = response.body?.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
if (reader) {
|
||||
try {
|
||||
while (true) {
|
||||
const part = await reader.read();
|
||||
if (part.done) break;
|
||||
size += part.value.byteLength;
|
||||
if (size > 1_048_576) { await reader.cancel(); throw providerFailure("slack_provider_failure"); }
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
}
|
||||
let result: Record<string, unknown>;
|
||||
try { result = object(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
||||
catch { result = {}; }
|
||||
if (!response.ok || result.ok !== true) {
|
||||
const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure");
|
||||
const retry = Number(response.headers.get("retry-after"));
|
||||
if (response.status === 429 || result.error === "ratelimited") {
|
||||
throw unprocessable(slackRegistrationErrorMessage("slack_setup_rate_limited"), {
|
||||
code: "slack_setup_rate_limited", retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? Math.ceil(retry) : 60,
|
||||
});
|
||||
}
|
||||
throw providerFailure(code);
|
||||
}
|
||||
if (requiredScopes) {
|
||||
const granted = new Set((response.headers.get("x-oauth-scopes") ?? "").split(/[, ]+/));
|
||||
if (requiredScopes.some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { notifyChatVerificationWork } from "../../../chat-work-notifications.js";
|
||||
import { createAgentAvatarPool } from "../../../agent-avatar-pool.js";
|
||||
import type { AgentAvatarRequest } from "../../../agent-avatars.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { agents, chatEndpoints, chatSlackRegistrations, toolConnections, type Db } from "@paperclipai/db";
|
||||
import { slackRegistrationErrorMessage, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState, isProvisionedSlackSetup } from "@paperclipai/shared";
|
||||
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../../errors.js";
|
||||
import { accessService } from "../../../access.js";
|
||||
import { logActivity } from "../../../activity-log.js";
|
||||
import { instanceSettingsService } from "../../../instance-settings.js";
|
||||
import { secretService } from "../../../secrets.js";
|
||||
import { writeConnectionCredential } from "../../../connection-credentials.js";
|
||||
import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js";
|
||||
|
||||
export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean }
|
||||
export type Registration = typeof chatSlackRegistrations.$inferSelect;
|
||||
|
||||
|
||||
export { object, providerFailure, knownProviderErrors } from "./provider-client.js";
|
||||
import { object } from "./provider-client.js";
|
||||
|
||||
export function slackRegistrationProjection(row: Registration): SlackRegistrationState {
|
||||
return {
|
||||
status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status,
|
||||
appId: row.appId,
|
||||
...(row.managerGrantId ? { managerGrantId: row.managerGrantId } : {}),
|
||||
managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps",
|
||||
errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode,
|
||||
};
|
||||
}
|
||||
|
||||
import { createSlackSetupClient } from "./provider-client.js";
|
||||
export type SlackSetupOptions = {
|
||||
managed?: { enabled: boolean; broker: import("../../../paperclip-cloud-connector.js").PaperclipCloudConnector };
|
||||
publicOrigin: () => string | null;
|
||||
webhookOrigin: () => string | null;
|
||||
fetch?: typeof fetch;
|
||||
renderAvatar?: (request: AgentAvatarRequest) => Promise<Buffer>;
|
||||
withLock: <T>(endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise<T>) => Promise<T>;
|
||||
runtimeSigningSecret: (endpointId: string) => Promise<string>;
|
||||
runtimeBotToken: (endpointId: string) => Promise<string>;
|
||||
canWelcome: (endpointId: string, account: SlackAccountState) => Promise<boolean>;
|
||||
linkInstaller: (endpointId: string, user: Record<string, unknown>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<void>;
|
||||
configure: (endpointId: string, credentials: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<unknown>;
|
||||
};
|
||||
export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions) {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const vault = secretService(db);
|
||||
let avatarPool: ReturnType<typeof createAgentAvatarPool> | undefined;
|
||||
function origin(value: string | null) {
|
||||
if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app");
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
||||
throw badRequest("Slack setup requires a configured public HTTPS origin");
|
||||
return url.origin;
|
||||
}
|
||||
const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`;
|
||||
async function registration(endpointId: string) {
|
||||
return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0];
|
||||
}
|
||||
async function endpoint(endpointId: string, actor: SlackSetupActor) {
|
||||
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
|
||||
const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance })
|
||||
.from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId)))
|
||||
.innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId)))
|
||||
.where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack")));
|
||||
if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found");
|
||||
if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections"))
|
||||
throw forbidden("Missing permission: tools:manage_connections");
|
||||
return row;
|
||||
}
|
||||
function assertOrigins(row: Registration, publicId: string) {
|
||||
if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup.");
|
||||
const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`;
|
||||
if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected)
|
||||
throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup.");
|
||||
}
|
||||
async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) {
|
||||
await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId,
|
||||
action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId,
|
||||
details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } });
|
||||
}
|
||||
const api = createSlackSetupClient(fetchImpl);
|
||||
async function saveSecrets(row: Registration, values: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard,
|
||||
patch: Partial<Registration> = {}, account?: SlackAccountState) {
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0];
|
||||
if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
const ids = { ...current.secretIds };
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration",
|
||||
configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId },
|
||||
...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) });
|
||||
ids[key] = result.secret.id;
|
||||
}
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId));
|
||||
if (account) {
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
if (!saved || saved.status === "archived") throw conflict("Slack setup changed");
|
||||
// Reauthorization never changes an established personal account binding.
|
||||
if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId));
|
||||
}
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
async function readSecret(row: Registration, key: string) {
|
||||
if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection.");
|
||||
return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", {
|
||||
accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" },
|
||||
});
|
||||
}
|
||||
async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) {
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() })
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId)));
|
||||
}
|
||||
async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0];
|
||||
if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
if (!saved || saved.status === "archived" || !isProvisionedSlackSetup(saved.setup.slackSetupMethod)) throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() })
|
||||
.where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId)));
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
const renderAvatar = (request: AgentAvatarRequest) => (options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render)(request);
|
||||
return { db, options, origin, callbackUri, registration, endpoint, assertOrigins, audit, api, saveSecrets, readSecret, setFailure, saveAvatar, renderAvatar,
|
||||
close: async () => { await avatarPool?.close(); } };
|
||||
}
|
||||
export type SlackRegistrationStore = ReturnType<typeof createSlackRegistrationStore>;
|
||||
@@ -0,0 +1,151 @@
|
||||
import { isProvisionedSlackSetup } from "@paperclipai/shared";
|
||||
import { createSlackManagerGrants } from "./manager-grants.js";
|
||||
import { createManagedSlackSetup } from "./managed.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, gt, like } from "drizzle-orm";
|
||||
import { chatEndpoints, chatSlackRegistrations, companies, toolOauthStates, type Db } from "@paperclipai/db";
|
||||
import { badRequest, conflict, forbidden, notFound } from "../../../../errors.js";
|
||||
import { removeSlackRegistration } from "../../../chat-slack-registration-cleanup.js";
|
||||
import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js";
|
||||
import { object, providerFailure, createSlackRegistrationStore, type SlackSetupOptions, type SlackSetupActor } from "./registration.js";
|
||||
import { createCustomerOwnedSetup } from "./customer-owned.js";
|
||||
import { createSlackCompletion } from "./completion.js";
|
||||
const prefix = "slack-install.";
|
||||
export function slackChatRegistrationService(db: Db, options: SlackSetupOptions) {
|
||||
const store = createSlackRegistrationStore(db, options);
|
||||
const { endpoint, registration, assertOrigins, callbackUri, audit, api, readSecret, setFailure, saveSecrets } = store;
|
||||
const provisioner = createCustomerOwnedSetup(store);
|
||||
const { create } = provisioner;
|
||||
const completion = createSlackCompletion(store);
|
||||
const { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt } = completion;
|
||||
const managerGrants = createSlackManagerGrants(store);
|
||||
const managed = createManagedSlackSetup(store, managerGrants, provisioner, completion, install);
|
||||
async function install(endpointId: string, actor: SlackSetupActor) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const row = await registration(endpointId);
|
||||
if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it");
|
||||
if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it");
|
||||
if (!isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod) || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it");
|
||||
if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor);
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
const state = `${prefix}${randomBytes(32).toString("base64url")}`;
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[];
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`)));
|
||||
await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id,
|
||||
codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri,
|
||||
endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }),
|
||||
createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId,
|
||||
requestedScopes: scopes, expiresAt });
|
||||
await tx.update(chatSlackRegistrations).set({ errorCode: row.managerGrantId ? row.errorCode : null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
});
|
||||
const url = new URL("https://slack.com/oauth/v2/authorize");
|
||||
url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri,
|
||||
...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString();
|
||||
await audit(row, actor, "installation_started");
|
||||
return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() };
|
||||
});
|
||||
}
|
||||
async function expiredReturn(state: string, actor: SlackSetupActor) {
|
||||
if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null;
|
||||
const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state));
|
||||
if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null;
|
||||
const binding = object(JSON.parse(attempt.codeVerifier));
|
||||
const current = await endpoint(String(binding.endpointId), actor);
|
||||
return options.withLock(current.endpoint.id, async lease => {
|
||||
const latest = await endpoint(current.endpoint.id, actor);
|
||||
const row = await registration(current.endpoint.id);
|
||||
if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId
|
||||
|| !isProvisionedSlackSetup(latest.endpoint.setup.slackSetupMethod) || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null;
|
||||
assertOrigins(row, latest.endpoint.publicId);
|
||||
await lease.assertOwned();
|
||||
await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state));
|
||||
await setFailure(row, row.status, "slack_install_expired", lease);
|
||||
await audit(row, actor, "installation_failed", "slack_install_expired");
|
||||
return returnPath(row.endpointId);
|
||||
});
|
||||
}
|
||||
async function pending(state: string, actor: SlackSetupActor) {
|
||||
if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state");
|
||||
const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date())));
|
||||
if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId)
|
||||
throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again.");
|
||||
const binding = object(JSON.parse(attempt.codeVerifier));
|
||||
const current = await endpoint(String(binding.endpointId), actor);
|
||||
const row = await registration(current.endpoint.id);
|
||||
if (!row || row.status === "removed" || !isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod)
|
||||
|| current.endpoint.status === "paused"
|
||||
|| current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked"
|
||||
|| binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0)
|
||||
|| row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri
|
||||
|| row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id)
|
||||
throw conflict("Slack setup changed. Resume the saved connection and install again.");
|
||||
if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor);
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
return { attempt, current, row };
|
||||
}
|
||||
async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) {
|
||||
const before = await pending(state, actor);
|
||||
return options.withLock(before.row.endpointId, async lease => {
|
||||
const { row, attempt, current } = await pending(state, actor);
|
||||
const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning();
|
||||
if (!claimed) throw conflict("This Slack authorization was already used");
|
||||
if (error) {
|
||||
const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed";
|
||||
await setFailure(row, row.status, failureCode, lease);
|
||||
await audit(row, actor, "installation_failed", failureCode);
|
||||
return row.endpointId;
|
||||
}
|
||||
try {
|
||||
if (!code || code.length > 4096) throw providerFailure("slack_install_failed");
|
||||
const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri });
|
||||
const teamId = object(result.team).id;
|
||||
const installerId = object(result.authed_user).id;
|
||||
if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-")
|
||||
|| typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id)
|
||||
|| row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id)
|
||||
throw providerFailure("slack_install_identity_mismatch");
|
||||
const granted = new Set(String(result.scope ?? "").split(","));
|
||||
if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing");
|
||||
if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId
|
||||
|| current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id)
|
||||
throw providerFailure("slack_install_identity_mismatch");
|
||||
if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing");
|
||||
if (row.managerGrantId) {
|
||||
const grant = await managerGrants.get(row.managerGrantId, row.companyId, actor);
|
||||
if (grant.status !== "active" || installerId !== grant.slackUserId || teamId !== grant.workspaceId) throw providerFailure("slack_install_identity_mismatch");
|
||||
}
|
||||
await endpoint(row.endpointId, actor);
|
||||
// Reauthorization reuses the runtime signing secret after staging was cleaned.
|
||||
await saveSecrets(row, { botToken: result.access_token }, actor, lease,
|
||||
{ status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null },
|
||||
{ externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" });
|
||||
} catch (failure) {
|
||||
const code = object(object(failure).details).code;
|
||||
const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed";
|
||||
await setFailure(row, row.status, safeCode, lease);
|
||||
await audit(row, actor, "installation_failed", safeCode);
|
||||
return row.endpointId;
|
||||
}
|
||||
await resumeLocked(row.endpointId, actor, lease);
|
||||
return row.endpointId;
|
||||
});
|
||||
}
|
||||
const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease);
|
||||
async function returnPath(endpointId: string) {
|
||||
const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints)
|
||||
.innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId));
|
||||
if (!row) throw notFound("Slack connection not found");
|
||||
return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`;
|
||||
}
|
||||
return { create, managerGrants, managed, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt,
|
||||
close: store.close,
|
||||
resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, async lease => {
|
||||
const row = await registration(endpointId);
|
||||
if (row?.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor);
|
||||
return resumeLocked(endpointId, actor, lease);
|
||||
}) };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { and, eq, gte, inArray, ne } from "drizzle-orm";
|
||||
import { agents, chatActions, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatSlackManagerGrants, companyMemberships, toolConnections, type Db } from "@paperclipai/db";
|
||||
import { slackTestMessageSchema, type SlackTestMessageInput, type SlackTestMessageResult } from "@paperclipai/shared";
|
||||
import { conflict, forbidden, notFound, tooManyRequests, unprocessable } from "../../../errors.js";
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
import { logActivity } from "../../activity-log.js";
|
||||
import { instanceSettingsService } from "../../instance-settings.js";
|
||||
import { createSlackSetupClient, object } from "./setup/provider-client.js";
|
||||
import type { SlackSetupActor, SlackSetupOptions } from "./setup/registration.js";
|
||||
|
||||
type Options = Pick<SlackSetupOptions, "fetch" | "withLock" | "runtimeBotToken" | "linkInstaller"> & {
|
||||
ensureAccount: (endpointId: string, user: Record<string, unknown>) => Promise<string>;
|
||||
createLinkIntent: (endpointId: string, principalId: string, expiresInSeconds: number) => Promise<{ confirmationUrl: string; expiresAt: string }>;
|
||||
};
|
||||
const human = (user: Record<string, unknown>, workspaceId: string) =>
|
||||
typeof user.id === "string" && /^[UW][A-Z0-9]+$/.test(user.id) && user.team_id === workspaceId &&
|
||||
user.is_bot === false && user.is_app_user !== true && user.deleted !== true && user.id !== "USLACKBOT";
|
||||
|
||||
export function slackTestMessageService(db: Db, options: Options) {
|
||||
const api = createSlackSetupClient(options.fetch);
|
||||
return async (endpointId: string, actor: SlackSetupActor, rawInput: SlackTestMessageInput): Promise<SlackTestMessageResult> => {
|
||||
const input = slackTestMessageSchema.parse(rawInput);
|
||||
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const [record] = await db.select({ endpoint: chatEndpoints, agentName: agents.name }).from(chatEndpoints)
|
||||
.innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId)))
|
||||
.innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId), eq(toolConnections.enabled, true), eq(toolConnections.status, "active")))
|
||||
.where(eq(chatEndpoints.id, endpointId));
|
||||
if (!record || record.endpoint.provider !== "slack") throw notFound("Active Slack connection not found");
|
||||
const endpoint = record.endpoint;
|
||||
if (!["active", "verifying"].includes(endpoint.status) || !endpoint.providerAccountId || !endpoint.allowDirectMessages) throw conflict("Enable direct messages on an active Slack connection first");
|
||||
const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, endpoint.companyId), eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"),
|
||||
));
|
||||
if (!membership) throw forbidden("An active Paperclip account is required");
|
||||
const actionKey = `slack_test_message:${actor.userId}:${input.requestId}`;
|
||||
const [previous] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, actionKey)));
|
||||
if (previous) {
|
||||
if (previous.status === "processed" && typeof previous.result?.slackUrl === "string") return { status: "sent", slackUrl: previous.result.slackUrl };
|
||||
throw conflict("This message may already have been sent. Check your Slack DMs before sending another.");
|
||||
}
|
||||
const recent = await db.select({ id: chatActions.id }).from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), inArray(chatActions.kind, ["slack_test_message", "slack_test_message_invitation"]), gte(chatActions.createdAt, new Date(Date.now() - 60_000)),
|
||||
)).limit(5);
|
||||
if (recent.length >= 5) throw tooManyRequests("Wait a minute before sending another test message");
|
||||
const links = await db.select({ endpointId: chatIdentityLinks.endpointId, slackUserId: chatExternalPrincipals.externalId }).from(chatIdentityLinks)
|
||||
.innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, chatIdentityLinks.companyId)))
|
||||
.where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.paperclipUserId, actor.userId), eq(chatIdentityLinks.status, "linked"),
|
||||
eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId), eq(chatExternalPrincipals.isBot, false)));
|
||||
const current = links.filter(link => link.endpointId === endpointId);
|
||||
const grants = await db.select({ slackUserId: chatSlackManagerGrants.slackUserId }).from(chatSlackManagerGrants).where(and(
|
||||
eq(chatSlackManagerGrants.companyId, endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.workspaceId, endpoint.providerAccountId), eq(chatSlackManagerGrants.status, "active"),
|
||||
));
|
||||
const known = [...new Set((current.length ? current : [...links, ...grants]).map(link => link.slackUserId))];
|
||||
const [invitation] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, `${actionKey}:invite`)));
|
||||
const invitedUser = typeof invitation?.payload.slackUserId === "string" ? invitation.payload.slackUserId : undefined;
|
||||
if (invitedUser && input.slackUserId && input.slackUserId !== invitedUser) throw conflict("This request already invited a different Slack account");
|
||||
const userId = input.slackUserId ?? invitedUser ?? (known.length === 1 ? known[0] : undefined);
|
||||
const token = await options.runtimeBotToken(endpointId);
|
||||
if (!token) throw conflict("Reconnect this Slack app before sending a message");
|
||||
async function call(method: string, fields: Record<string, string>) {
|
||||
try { return await api(method, fields, token); }
|
||||
catch { throw unprocessable("Slack could not complete this request. Check the connection and try again."); }
|
||||
}
|
||||
if (!userId) {
|
||||
const accounts: { id: string; name: string }[] = [];
|
||||
const result = await call("users.list", { limit: "200", ...(input.accountCursor ? { cursor: input.accountCursor } : {}) });
|
||||
for (const value of Array.isArray(result.members) ? result.members : []) {
|
||||
const user = object(value);
|
||||
if (human(user, endpoint.providerAccountId)) accounts.push({ id: String(user.id), name: String(user.real_name || user.name || user.id) });
|
||||
}
|
||||
const cursor = object(result.response_metadata).next_cursor;
|
||||
return { status: "connection_required", accounts, nextCursor: typeof cursor === "string" && cursor ? cursor : null };
|
||||
}
|
||||
const user = object((await call("users.info", { user: userId })).user);
|
||||
if (user.id !== userId || !human(user, endpoint.providerAccountId)) throw unprocessable("Choose your personal Slack account in this workspace");
|
||||
if (!known.includes(userId)) {
|
||||
const principalId = await options.ensureAccount(endpointId, user);
|
||||
const principalLinks = await db.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.principalId, principalId)));
|
||||
if (principalLinks.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId)
|
||||
|| principalLinks.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link");
|
||||
if (invitation) {
|
||||
if (invitation.status !== "processed") throw conflict("The invitation may already have been sent. Check Slack before requesting another.");
|
||||
const expiresAt = String(invitation.result?.expiresAt ?? "");
|
||||
if (!expiresAt || new Date(expiresAt).getTime() <= Date.now()) throw conflict("The Slack invitation expired. Request a new test message.");
|
||||
return { status: "connection_pending", principalId, expiresAt };
|
||||
}
|
||||
// The account holder must follow the private Slack challenge and sign in.
|
||||
// Selecting a workspace member does not prove identity ownership.
|
||||
const intent = await options.createLinkIntent(endpointId, principalId, 600);
|
||||
const [pending] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, principalId,
|
||||
kind: "slack_test_message_invitation", providerActionId: `${actionKey}:invite`, status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning();
|
||||
try {
|
||||
const response = await call("chat.postMessage", { channel: userId,
|
||||
text: `Connect your Slack account to ${record.agentName} in Paperclip to receive your test message.`,
|
||||
blocks: JSON.stringify([{ type: "section", text: { type: "plain_text", text: `Connect your Slack account to ${record.agentName} in Paperclip.` } },
|
||||
{ type: "section", text: { type: "mrkdwn", text: `<${intent.confirmationUrl}|Connect my account> to receive your test message.` } }]),
|
||||
unfurl_links: "false", unfurl_media: "false" });
|
||||
if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm invitation delivery");
|
||||
await lease.assertOwned();
|
||||
await db.update(chatActions).set({ status: "processed", result: { expiresAt: intent.expiresAt }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id));
|
||||
} catch {
|
||||
await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_invitation_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id));
|
||||
throw unprocessable("Invitation delivery was not confirmed. Check Slack before requesting another.");
|
||||
}
|
||||
try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_invited", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); }
|
||||
catch { logger.warn({ endpointId }, "Slack test invitation activity could not be recorded"); }
|
||||
return { status: "connection_pending", principalId, expiresAt: intent.expiresAt };
|
||||
}
|
||||
await options.linkInstaller(endpointId, user, actor, lease);
|
||||
await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_requested", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } });
|
||||
await lease.assertOwned();
|
||||
// Persist before dispatch: a crash or ambiguous response must never replay the DM.
|
||||
const [action] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, kind: "slack_test_message", providerActionId: actionKey,
|
||||
status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning();
|
||||
let slackUrl: string;
|
||||
try {
|
||||
const response = await call("chat.postMessage", { channel: userId,
|
||||
text: `Hi, I’m ${record.agentName}. This is your test message. Reply here to talk to me.`, unfurl_links: "false", unfurl_media: "false" });
|
||||
if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm delivery. Check your DMs before trying again.");
|
||||
slackUrl = `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/${response.channel}`;
|
||||
await lease.assertOwned();
|
||||
await db.update(chatActions).set({ status: "processed", result: { slackUrl }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id));
|
||||
} catch {
|
||||
await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_test_message_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id));
|
||||
throw unprocessable("Delivery was not confirmed. Check your Slack DMs before sending another test message.");
|
||||
}
|
||||
try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_sent", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); }
|
||||
catch { logger.warn({ endpointId }, "Slack test message activity could not be recorded"); }
|
||||
return { status: "sent", slackUrl };
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { readConfigValueAtPath, writeConfigValueAtPath } from "./json-schema-sec
|
||||
type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect;
|
||||
|
||||
export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding";
|
||||
/** Measured platform evidence is reserved to explicit server-side environment probes. */
|
||||
export const SANDBOX_PLATFORM_PROBE_METADATA_KEY = "paperclipSandboxPlatformProbes";
|
||||
export const ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS = [
|
||||
"timeoutMs",
|
||||
|
||||
@@ -41,14 +41,12 @@ async function sandboxBinding(db: Db, environment: Environment, companyId: strin
|
||||
});
|
||||
if (parsed.driver !== "sandbox") return null;
|
||||
const image = parsed.config.image;
|
||||
// Names, mutable tags, provider defaults and snapshot names are not immutable identities.
|
||||
const template = selectedTemplate.value;
|
||||
if (!isImmutableImage(image) || ["snapshot", "template", "templateRef"].some(key => record(parsed.config)[key])
|
||||
|| (template && (template.templateKind !== "image" || template.bindingField !== "image"))) return null;
|
||||
return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config as unknown as Record<string, unknown>), image, template };
|
||||
}
|
||||
|
||||
/** Read measured, company-scoped image evidence without leasing or contacting a provider. */
|
||||
export async function readSandboxPlatformQualification(db: Db, environment: Environment, companyId: string): Promise<PlatformFacts | null> {
|
||||
const proof = record(record(environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY])[companyId]);
|
||||
if (proof.v !== 1 || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null;
|
||||
|
||||
@@ -1144,8 +1144,6 @@ export function environmentService(db: Db) {
|
||||
values.metadata = userMetadata;
|
||||
} else {
|
||||
const replacement = userMetadata === null ? sql`null::jsonb` : sql`${JSON.stringify(userMetadata)}::jsonb`;
|
||||
// Preserve the current private subtree in the same statement. A route's
|
||||
// earlier read must never replace a newer concurrent probe receipt.
|
||||
metadataUpdate = sql`case when ${environments.metadata} ? ${SANDBOX_PLATFORM_PROBE_METADATA_KEY}
|
||||
then coalesce(${replacement}, '{}'::jsonb) || jsonb_build_object(
|
||||
${SANDBOX_PLATFORM_PROBE_METADATA_KEY}::text, ${environments.metadata}->${SANDBOX_PLATFORM_PROBE_METADATA_KEY})
|
||||
|
||||
@@ -5,7 +5,15 @@ import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { runningProcesses } from "../../adapters/index.js";
|
||||
import { adapterExecutionControls } from "../adapter-execution-control.js";
|
||||
import {
|
||||
adapterExecutionControls,
|
||||
createAdapterExecutionControl,
|
||||
reportAdapterStopIfUnsettled,
|
||||
waitForAdapterStop,
|
||||
} from "../adapter-execution-control.js";
|
||||
import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js";
|
||||
import { HttpError } from "../../errors.js";
|
||||
import { captureException } from "../../sentry.js";
|
||||
import { terminateLocalService } from "../local-service-supervisor.js";
|
||||
import { createHeartbeatRunState } from "./run-state.js";
|
||||
import { createHeartbeatRunPreparation } from "./run-preparation.js";
|
||||
@@ -13,6 +21,14 @@ import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from ".
|
||||
import { createHeartbeatRunControl, type HeartbeatRunControlDependencies } from "./run-control.js";
|
||||
|
||||
vi.mock("../live-events.js", () => ({ publishLiveEvent: vi.fn() }));
|
||||
vi.mock("../adapter-execution-control.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../adapter-execution-control.js")>();
|
||||
return {
|
||||
...actual,
|
||||
waitForAdapterStop: vi.fn(actual.waitForAdapterStop),
|
||||
reportAdapterStopIfUnsettled: vi.fn(actual.reportAdapterStopIfUnsettled),
|
||||
};
|
||||
});
|
||||
vi.mock("../local-service-supervisor.js", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("../local-service-supervisor.js")>(),
|
||||
terminateLocalService: vi.fn(async () => undefined),
|
||||
@@ -256,6 +272,67 @@ describe.skipIf(!support.supported)("heartbeat run-control database wiring", ()
|
||||
expect(deps.failedProcessRunCancellations.get(run.id)?.error).toBe(failure);
|
||||
});
|
||||
|
||||
async function ownedAdapterRun() {
|
||||
const { company, agent } = await fixture();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running", runtimeMode: "legacy", runtimeModeResolvedAt: new Date() }).returning();
|
||||
const control = createAdapterExecutionControl();
|
||||
adapterExecutionControls.set(run.id, control);
|
||||
return { run, control };
|
||||
}
|
||||
|
||||
it("answers an owned adapter Stop that is still settling with a pending conflict", async () => {
|
||||
const { run, control } = await ownedAdapterRun();
|
||||
const deps = callbacks(db);
|
||||
deps.activeRunExecutions.add(run.id);
|
||||
try {
|
||||
let waitStartedAt = 0;
|
||||
vi.mocked(waitForAdapterStop).mockImplementationOnce(async () => {
|
||||
waitStartedAt = Date.now();
|
||||
throw new AdapterStopTimeoutError(60_000, { runId: run.id });
|
||||
});
|
||||
const requestedAt = Date.now();
|
||||
const error = await createHeartbeatRunControl(db, deps).cancelRunInternal(run.id).catch((err: unknown) => err);
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect(error).toMatchObject({
|
||||
status: 409,
|
||||
message: "Execution is still stopping; termination has not been verified.",
|
||||
details: { code: "adapter_stop_pending", runId: run.id },
|
||||
});
|
||||
expect(control.controller.signal.aborted).toBe(true);
|
||||
// Only a Stop that never settles is reported, from the same execution.
|
||||
expect(reportAdapterStopIfUnsettled).toHaveBeenCalledWith(control.settled, captureException,
|
||||
expect.objectContaining({ runId: run.id, adapterType: "codex_local", runtimeMode: "legacy", abortRequested: true }), control,
|
||||
{ stopRequestedAt: expect.any(Number) });
|
||||
// Its report bound runs from the Stop request, before the response wait.
|
||||
const { stopRequestedAt } = vi.mocked(reportAdapterStopIfUnsettled).mock.calls.at(-1)![4]!;
|
||||
expect(stopRequestedAt).toBeGreaterThanOrEqual(requestedAt);
|
||||
expect(stopRequestedAt).toBeLessThanOrEqual(waitStartedAt);
|
||||
// A pending Stop is not a cancellation receipt.
|
||||
expect(deps.setRunStatusFromLive).not.toHaveBeenCalled();
|
||||
expect(deps.finalizeAgentStatus).not.toHaveBeenCalled();
|
||||
expect((await deps.getRun(run.id))?.status).toBe("running");
|
||||
expect(deps.failedProcessRunCancellations.has(run.id)).toBe(false);
|
||||
} finally {
|
||||
control.finish();
|
||||
adapterExecutionControls.delete(run.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps other owned adapter Stop failures unchanged", async () => {
|
||||
const { run, control } = await ownedAdapterRun();
|
||||
const deps = callbacks(db);
|
||||
vi.mocked(reportAdapterStopIfUnsettled).mockClear();
|
||||
try {
|
||||
const failure = new Error("owner wait failed");
|
||||
vi.mocked(waitForAdapterStop).mockRejectedValueOnce(failure);
|
||||
await expect(createHeartbeatRunControl(db, deps).cancelRunInternal(run.id)).rejects.toBe(failure);
|
||||
expect(reportAdapterStopIfUnsettled).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
control.finish();
|
||||
adapterExecutionControls.delete(run.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates agent batches and cancels only unclaimed pending wakes", async () => {
|
||||
const { company, agent } = await fixture();
|
||||
const [otherAgent] = await db.insert(agents).values({ companyId: company.id, name: "Other agent" }).returning();
|
||||
|
||||
@@ -44,8 +44,11 @@ import {
|
||||
import {
|
||||
adapterExecutionControls,
|
||||
captureAdapterStopOwnership,
|
||||
reportAdapterStopIfUnsettled,
|
||||
waitForAdapterStop,
|
||||
} from "../adapter-execution-control.js";
|
||||
import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js";
|
||||
import { captureException } from "../../sentry.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
@@ -999,6 +1002,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
|
||||
processCancellationSettlement,
|
||||
);
|
||||
}
|
||||
const stopRequestedAt = Date.now();
|
||||
const cancellation = await (async () => {
|
||||
try {
|
||||
if (control) {
|
||||
@@ -1059,12 +1063,24 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
|
||||
}
|
||||
|
||||
if (control) {
|
||||
await waitForAdapterStop(control.settled, undefined, {
|
||||
const stopDiagnostics = {
|
||||
runId: run.id,
|
||||
adapterType: agent?.adapterType,
|
||||
runtimeMode: run.runtimeMode,
|
||||
abortRequested: control.controller.signal.aborted,
|
||||
}, control);
|
||||
};
|
||||
try {
|
||||
await waitForAdapterStop(control.settled, undefined, stopDiagnostics, control);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AdapterStopTimeoutError)) throw error;
|
||||
// The owned adapter is still settling (for example copying the
|
||||
// workspace back) and keeps its Stop proof requirements. Answer
|
||||
// this request honestly; report only a Stop that never settles.
|
||||
reportAdapterStopIfUnsettled(control.settled, captureException, stopDiagnostics, control, {
|
||||
stopRequestedAt,
|
||||
});
|
||||
throw conflict(error.message, { code: "adapter_stop_pending", runId: run.id });
|
||||
}
|
||||
const stopped = await getRun(run.id);
|
||||
if (stopped && isHeartbeatRunTerminalStatus(stopped.status)) {
|
||||
if (
|
||||
|
||||
@@ -45,8 +45,6 @@ function targetPlatform(platform: string, architecture: string, glibc = true, gl
|
||||
}
|
||||
|
||||
function resolveFixtureCodexCommand(home: string): string {
|
||||
// Vitest's global dependency paths can supply a foreign bridge before the
|
||||
// fixture wrapper. Reuse the existing Codex security test's closed Node seam.
|
||||
const source = `
|
||||
import { resolveCodexCommand } from ${JSON.stringify(new URL("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts", import.meta.url).href)};
|
||||
try { console.log(JSON.stringify({ command: resolveCodexCommand(${JSON.stringify(path.join(root!, "issuer.js"))}, { PATH: "/missing-codex" }, ${JSON.stringify(home)}) })); }
|
||||
|
||||
@@ -56,8 +56,6 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s
|
||||
async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> {
|
||||
let executable = "claude";
|
||||
if (provider === "openai") {
|
||||
// The installed Codex CLI is independent of the runner daemon's glibc floor.
|
||||
// Supported CLI hosts use owned resolution; other platforms keep PATH lookup.
|
||||
const legacyPlatform = !((process.platform === "linux" || process.platform === "darwin")
|
||||
&& (process.arch === "x64" || process.arch === "arm64"));
|
||||
try {
|
||||
|
||||
@@ -208,7 +208,6 @@ export function resolvePaperclipRunnerBinary(
|
||||
);
|
||||
}
|
||||
|
||||
/** Select controller-owned bytes for the actual remote host, never its controller. */
|
||||
export async function resolveRemotePaperclipRunnerBinary(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
@@ -224,8 +223,6 @@ export async function resolveRemotePaperclipRunnerBinary(input: {
|
||||
}
|
||||
const configured = input.configuredPath?.trim();
|
||||
if (configured && !isAbsolute(configured)) throw new Error("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH must be an absolute path");
|
||||
// A same-host sandbox can use the supervisor's fresh development artifact.
|
||||
// Cross-platform targets must never inherit the controller's override.
|
||||
const controllerOverride = platform === process.platform && architecture === process.arch
|
||||
? process.env.PAPERCLIP_RUNNER_BINARY : undefined;
|
||||
const binary = configured || (controllerOverride
|
||||
|
||||
@@ -12354,8 +12354,6 @@ describe("runnerd provider runtime wiring", () => {
|
||||
{ version: "0.156.0", model: "gpt-6.1-sol", floor: null, recover: true },
|
||||
{ version: "0.158.0", model: "gpt-6.1-sol", floor: null, recover: true },
|
||||
])("lets sandbox Codex $version reach actual model verification ($model, recover=$recover)", async ({ version, model, recover }) => {
|
||||
// Version metadata is diagnostic. A working app-server reaches the model
|
||||
// turn instead of being rejected or replaced by a static CLI version floor.
|
||||
const preparedModel = recover ? resolvePaperclipRunnerNativeProviderInput({
|
||||
backend: "codex_app_server", adapterConfig: { provider: "codex", model }, codexCliVersion: version,
|
||||
}).model : model;
|
||||
|
||||
@@ -13385,7 +13385,6 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
} satisfies NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession };
|
||||
}
|
||||
|
||||
/** Shared task/setup preparation; setup exercises the same remote artifact contract. */
|
||||
export function createRemoteNativeArtifactPreparation(input: {
|
||||
target: Extract<AdapterExecutionTarget, { kind: "remote" }>;
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
@@ -13398,7 +13397,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
runnerRemoteCodexNpmSpec?: string | null;
|
||||
model?: string | null;
|
||||
reuseRetainedRunner?: boolean;
|
||||
/** The selected Pi companion or bundled provider image verifies its target-platform artifact. */
|
||||
targetPlatformArtifactVerified?: boolean;
|
||||
trace?: NativeRunTrace;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
@@ -13451,9 +13449,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
}
|
||||
const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`;
|
||||
const version = parseCodexCliVersion(versionOutput);
|
||||
// The install pin describes our tested image, not a CLI admission rule.
|
||||
// Ordinary installations reach the real app-server/model probe; a version
|
||||
// difference alone must neither fail setup nor replace a working CLI.
|
||||
if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) {
|
||||
reportedCodexVersions.add(version);
|
||||
await input.onLog?.(
|
||||
@@ -13463,9 +13458,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
}
|
||||
};
|
||||
|
||||
// Image policy: keep one latest stable CLI installation shared by native and
|
||||
// local adapters. Preferred bin entries must point to that same installation;
|
||||
// never bake an older global CLI alongside a private runner-only version.
|
||||
const discoverPreinstalledExecutable = async (
|
||||
name: "paperclip-runnerd" | "codex",
|
||||
) => {
|
||||
@@ -13500,8 +13492,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
};
|
||||
|
||||
const prepare = async (requiredMode: "dial_wss" | "listen_ws") => {
|
||||
// Compatibility metadata does not prove artifact identity. Reuse only the
|
||||
// exact controller-owned bytes when the controller artifact is available.
|
||||
const matchesControllerRunnerArtifact = async (executable: string): Promise<boolean> => {
|
||||
const expected = createHash("sha256")
|
||||
.update(readFileSync(controllerRunnerBinary))
|
||||
@@ -13523,7 +13513,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
/^[a-f0-9]{64}\s/.test(probe.stdout) &&
|
||||
probe.stdout.trim().split(/\s+/)[0] === expected;
|
||||
} catch {
|
||||
// An unavailable checksum uses the verified staging path.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -13572,9 +13561,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
}
|
||||
}
|
||||
if (!runnerArtifactPrepared) {
|
||||
// Upload the same artifact used for the controller identity. The server
|
||||
// vendors the runner under vendor/paperclip-runner/bin, so the package
|
||||
// development fallback cannot locate it in a deployed server.
|
||||
const sourceBinary = controllerRunnerBinary;
|
||||
if (!existsSync(sourceBinary)) {
|
||||
throw new Error("runner_remote_artifact_unavailable");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
randomBytes,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -406,6 +407,16 @@ describe("Paperclip Cloud connector", () => {
|
||||
})).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" });
|
||||
});
|
||||
|
||||
it("opens Slack manager handoffs with their exact profile and personal identity", async () => {
|
||||
const keys = config();
|
||||
const credentials = { v: 1, accessToken: "manager-access-canary", refreshToken: "manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: "2030-01-01T00:00:00.000Z", refreshTokenExpiresAt: null,
|
||||
scopes: [...SLACK_MANAGER_SCOPES], subject, companyId, instanceId, environment: "staging", provider: "slack", profile: "slack.manager",
|
||||
slackManager: { appId: "AMANAGER", workspaceId: "TWORKSPACE", workspaceName: "Paperclip", userId: "UOWNER" } };
|
||||
const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "slack.manager");
|
||||
const connector = createPaperclipCloudConnector({ config: keys.config, request: vi.fn(async () => Response.json({ claimId: "claim", scopes: [...SLACK_MANAGER_SCOPES], sealed })) as typeof fetch });
|
||||
await expect(connector.claim({ subject, companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).resolves.toEqual(credentials);
|
||||
await expect(connector.claim({ subject: "another-user", companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).rejects.toThrow();
|
||||
});
|
||||
it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => {
|
||||
const keys = config();
|
||||
const credentials = {
|
||||
@@ -612,10 +623,10 @@ function seal(
|
||||
recipientPublicKey: KeyObject,
|
||||
purpose: "initial" | "access" | "events",
|
||||
configValue: PaperclipCloudConnectorConfig,
|
||||
profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "github.bot" | "github.code",
|
||||
profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "slack.manager" | "github.bot" | "github.code",
|
||||
) {
|
||||
const provider = profile.startsWith("github.") ? "github" : "google";
|
||||
const scopes = provider === "github" ? [] : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes;
|
||||
const provider = profile.startsWith("github.") ? "github" : profile === "slack.manager" ? "slack" : "google";
|
||||
const scopes = provider === "github" ? [] : profile === "slack.manager" ? SLACK_MANAGER_SCOPES : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes;
|
||||
const ephemeral = generateKeyPairSync("x25519");
|
||||
const ephemeralJwk = ephemeral.publicKey.export({ format: "jwk" }) as { x: string };
|
||||
const recipientJwk = recipientPublicKey.export({ format: "jwk" }) as { x: string };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared";
|
||||
import { SLACK_MANAGER_SCOPES, ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared";
|
||||
import {
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
@@ -34,8 +34,8 @@ export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES };
|
||||
|
||||
export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production";
|
||||
export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app";
|
||||
export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "github.bot";
|
||||
export type PaperclipCloudConnectorProvider = "google" | "github" | "asana";
|
||||
export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "slack.manager" | "github.bot";
|
||||
export type PaperclipCloudConnectorProvider = "google" | "github" | "asana" | "slack";
|
||||
|
||||
export type PaperclipCloudConnectorConfig = {
|
||||
baseUrl: string;
|
||||
@@ -60,6 +60,7 @@ export type SealedConnectorCredentials = {
|
||||
provider: PaperclipCloudConnectorProvider;
|
||||
profile: string;
|
||||
appSlug?: string;
|
||||
slackManager?: { appId: string; workspaceId: string; workspaceName: string; userId: string };
|
||||
};
|
||||
|
||||
export type SealedGmailCredentials = SealedConnectorCredentials & { provider: "google" };
|
||||
@@ -95,6 +96,7 @@ type SealedEnvelope = {
|
||||
};
|
||||
|
||||
type ConnectorResponse = {
|
||||
slackManager?: unknown;
|
||||
confirmationUrl?: unknown;
|
||||
authorizationUrl?: unknown;
|
||||
handoff?: unknown;
|
||||
@@ -413,6 +415,13 @@ export function createPaperclipCloudConnector(input: {
|
||||
typeof value === "string" && isPaperclipCloudConnectorProfileId(value) ? [value] : []
|
||||
))];
|
||||
},
|
||||
async getSlackManagerAppId(): Promise<string | null> {
|
||||
const response = await call("status", { subject: "instance-capabilities", companyId: "instance-capabilities" });
|
||||
const manager = response.slackManager;
|
||||
if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles) || !response.profiles.includes("slack.manager")
|
||||
|| !manager || typeof manager !== "object" || !("appId" in manager) || typeof manager.appId !== "string" || !/^A[A-Z0-9]+$/.test(manager.appId)) return null;
|
||||
return manager.appId;
|
||||
},
|
||||
async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) {
|
||||
const profile = values.profile ?? "gmail.draft";
|
||||
const response = await call("session", { ...values, profile });
|
||||
@@ -782,6 +791,7 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId):
|
||||
provider: PaperclipCloudConnectorProvider;
|
||||
scopes: readonly string[];
|
||||
} {
|
||||
if (profile === "slack.manager") return { provider: "slack", scopes: SLACK_MANAGER_SCOPES };
|
||||
if (profile === "github.bot") return { provider: "github", scopes: [] };
|
||||
if (isAsanaConnectorProfileId(profile)) {
|
||||
return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES };
|
||||
@@ -796,6 +806,7 @@ function isExpectedProviderAuthorizationUrl(
|
||||
profile: PaperclipCloudConnectorProfileId,
|
||||
url: URL,
|
||||
): boolean {
|
||||
if (profile === "slack.manager") return url.origin === "https://slack.com" && url.pathname === "/oauth/v2/authorize";
|
||||
if (isAsanaConnectorProfileId(profile)) {
|
||||
return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize";
|
||||
}
|
||||
@@ -806,7 +817,7 @@ function isExpectedProviderAuthorizationUrl(
|
||||
}
|
||||
|
||||
function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId {
|
||||
return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value);
|
||||
return value === "slack.manager" || isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value);
|
||||
}
|
||||
|
||||
async function sha256Base64Url(value: string): Promise<string> {
|
||||
|
||||
@@ -164,6 +164,16 @@ function isHermesConnectionFailure(run: HeartbeatRun, options: RunFailureReportO
|
||||
(connection.reason === "remote_unavailable" && code === "hermes_gateway_upstream_error");
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapter proved the user's provider account is out of quota, and recovery
|
||||
* waits for its reset. Both fields come from the adapter's own classifier.
|
||||
*/
|
||||
function isProviderQuotaFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
|
||||
return run.status === "failed" && run.errorCode === "provider_quota" &&
|
||||
asRecord(run.resultJson)?.errorFamily === "provider_quota" &&
|
||||
options.phase !== "setup" && run.signal == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a terminal run failure to Sentry. Returns at once for any status
|
||||
* other than failures and unexpected started cancellations. Never throws — a Sentry failure or a
|
||||
@@ -182,6 +192,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR
|
||||
if (isAiConnectionSelectionBlocker(run, options)) return Promise.resolve();
|
||||
if (isLocalPathWorkspaceConfigurationBlocker(run, options)) return Promise.resolve();
|
||||
if (isGitConnectionFailure(run, options)) return Promise.resolve();
|
||||
if (isProviderQuotaFailure(run, options)) return Promise.resolve();
|
||||
const runStatus = run.status;
|
||||
const report = captureTerminalRunFailure(db, run, runStatus, options);
|
||||
pendingRunFailureReports.add(report);
|
||||
|
||||
Reference in new issue
Block a user