mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
Merge origin/master and reconcile Slack and workspace migrations
Preserve managed Slack setup and typed task defaults. Regenerate task workspace migrations after the upstream Slack migration and synchronize the paired feature/access maps. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
b742aff3e1
156 files changed
+190423
-5074
No files matched your search
@@ -1,3 +1,7 @@
|
||||
import * as activityLogModule from "../services/activity-log.js";
|
||||
import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db";
|
||||
import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js";
|
||||
import { buildSlackAppManifest, SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
||||
import { registerChatActionWork, dueChatAction } from "../services/chat-action-work.js";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { registerChatDeliveryWork } from "../services/chat-delivery-work.js";
|
||||
@@ -7,14 +11,11 @@ import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { fastResponseService } from "../services/fast-responses.js";
|
||||
import { fastResponseReceipt } from "../services/fast-response-provider.js";
|
||||
import { fastResponseRequests } from "@paperclipai/db";
|
||||
import { chatSlackRegistrations, toolOauthStates } from "@paperclipai/db";
|
||||
import { buildSlackAppManifest } from "@paperclipai/shared";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { toolActionRequests, toolInvocations } from "@paperclipai/db";
|
||||
import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { githubChatWizardService } from "../services/chat-github-wizard.js";
|
||||
import type { PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js";
|
||||
import { dispatchGitHubBotCloudEvent } from "../services/chat-github-cloud-ingress.js";
|
||||
import { githubChatManagementService } from "../services/chat-github-management.js";
|
||||
import { githubChatReviewService } from "../services/chat-github-reviews.js";
|
||||
@@ -6264,7 +6265,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const token = `xoxb-bot-token-canary-${id}`;
|
||||
const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot;
|
||||
const state = {
|
||||
failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false,
|
||||
managedAccessToken: token, rejectOldBotToken: false,
|
||||
userListCursor: null as string | null, userListNextCursor: "",
|
||||
managedBotAppId: appId, grantedScopes: scopes, managedError: null as string | null, failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false,
|
||||
failManifestUpdate: false, timeoutManifestUpdate: false,
|
||||
beforeManifestUpdate: null as null | (() => Promise<void>),
|
||||
failIcon: false, timeoutIcon: false, failUser: false, failWelcome: false, timeoutWelcome: false,
|
||||
@@ -6272,20 +6275,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
user: { id: installerId, team_id: "TAUTO", name: "installer", real_name: "Installing Person", is_bot: false, deleted: false },
|
||||
beforeIcon: null as null | (() => Promise<void>),
|
||||
beforeAuth: null as null | (() => Promise<void>),
|
||||
beforeExchange: null as null | (() => Promise<void>),
|
||||
oauth: { ok: true, app_id: appId, token_type: "bot", access_token: token, team: { id: "TAUTO" }, bot_user_id: botId, authed_user: { id: installerId }, scope: scopes.join(",") },
|
||||
};
|
||||
const fallback = fakeSlackFetch(botId);
|
||||
const provider = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BMANAGED", app_id: state.managedBotAppId, user_id: botId, deleted: false } });
|
||||
if (url.endsWith("apps.managedInstall")) return Response.json(state.managedError ? { ok: false, error: state.managedError } : { ok: true, app_id: appId, team_id: "TAUTO", api_access_tokens: { bot_access_token: state.managedAccessToken } });
|
||||
if (url.endsWith("apps.manifest.validate")) return Response.json(state.failValidate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true });
|
||||
if (url.endsWith("apps.manifest.create")) {
|
||||
if (state.failCreate) throw new Error(`Timeout ${configToken} ${clientSecret}`);
|
||||
return Response.json({ ok: true, app_id: appId, credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } });
|
||||
return Response.json({ ok: true, app_id: appId, team_id: "TAUTO", credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } });
|
||||
}
|
||||
if (url.endsWith("apps.manifest.update")) {
|
||||
await state.beforeManifestUpdate?.();
|
||||
if (state.timeoutManifestUpdate) throw new Error(`Manifest timeout ${configToken} ${clientSecret}`);
|
||||
return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId });
|
||||
return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId, permissions_updated: true });
|
||||
}
|
||||
if (url.endsWith("apps.icon.set")) {
|
||||
await state.beforeIcon?.();
|
||||
@@ -6293,13 +6299,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
return Response.json(state.failIcon ? { ok: false, error: "missing_scope", detail: `${configToken} ${signingSecret}` } : { ok: true });
|
||||
}
|
||||
if (url.endsWith("oauth.v2.access")) {
|
||||
await state.beforeExchange?.();
|
||||
if (state.failExchange) throw new Error(`Timeout ${clientSecret} ${token}`);
|
||||
return Response.json(state.oauth);
|
||||
}
|
||||
if (url.endsWith("auth.test")) {
|
||||
await state.beforeAuth?.();
|
||||
if (state.rejectOldBotToken && new Headers(init?.headers).get("authorization") !== `Bearer ${state.managedAccessToken}`) return Response.json({ ok: false, error: "token_revoked" });
|
||||
if (state.failAuth) throw new Error(`Temporary failure ${token}`);
|
||||
return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
|
||||
return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, bot_id: "BMANAGED", user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": state.grantedScopes.join(",") } });
|
||||
}
|
||||
if (url.endsWith("users.list")) {
|
||||
state.userListCursor = new URLSearchParams(String(init?.body)).get("cursor");
|
||||
return Response.json({ ok: true, members: [state.user], response_metadata: { next_cursor: state.userListNextCursor } });
|
||||
}
|
||||
if (url.endsWith("users.info")) {
|
||||
await state.beforeUser?.();
|
||||
@@ -6325,6 +6337,408 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const install = async () => new URL((await service.slackRegistration.install(endpoint.id, actor)).authorizationUrl).searchParams.get("state")!;
|
||||
return { ...company, endpoint, service, sdk, app, state, provider, create, install, createInput, token, appId, botId, installerId, avatarBytes, renderAvatar };
|
||||
}
|
||||
async function managedFixture() {
|
||||
const broker = {
|
||||
getCapabilities: vi.fn(async () => ["slack.manager"]),
|
||||
getSlackManagerAppId: vi.fn(async () => "AMANAGER"),
|
||||
startAuthorization: vi.fn(async (_input: { returnState: string }) => ({ authorizationUrl: "https://slack.com/oauth/v2/authorize?state=broker", expiresAt: new Date(Date.now() + 600_000).toISOString() })),
|
||||
claim: vi.fn(), refresh: vi.fn(), revoke: vi.fn(async () => undefined),
|
||||
};
|
||||
const f = await fixture({ slackManaged: { enabled: true, broker: broker as unknown as PaperclipCloudConnector } });
|
||||
const credentials: SealedConnectorCredentials = { v: 1, provider: "slack", profile: "slack.manager", instanceId: "test", environment: "staging", subject: actor.userId, companyId: f.companyId,
|
||||
accessToken: "xoxe.xoxp-manager-access-canary", refreshToken: "xoxe-manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: new Date(Date.now() + 43_200_000).toISOString(), refreshTokenExpiresAt: null,
|
||||
scopes: [...SLACK_MANAGER_SCOPES], slackManager: { appId: "AMANAGER", workspaceId: "TAUTO", workspaceName: "Test workspace", userId: f.installerId } };
|
||||
broker.claim.mockResolvedValue(credentials);
|
||||
broker.refresh.mockResolvedValue({ ...credentials, accessToken: "xoxe.rotated-canary", refreshToken: "xoxe.rotated-refresh-canary" });
|
||||
await f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId);
|
||||
const grants = f.service.slackRegistration.managerGrants;
|
||||
const authorize = async () => { await grants.authorize(f.endpoint.id, actor); return broker.startAuthorization.mock.calls.at(-1)![0].returnState; };
|
||||
const authorizeState = await authorize();
|
||||
await grants.complete(authorizeState, "claim-canary", null, actor);
|
||||
const choices = await grants.choices(f.companyId, actor);
|
||||
expect(choices.workspaces).toHaveLength(1);
|
||||
const grantId = choices.workspaces[0]!.grantId;
|
||||
const input = { requestId: randomUUID(), grantId };
|
||||
const provision = () => f.service.slackRegistration.managed.provision(f.endpoint.id, actor, input);
|
||||
return { ...f, broker, credentials, grants, authorize, authorizeState, grantId, input, provision };
|
||||
}
|
||||
describe("Slack test messages", () => {
|
||||
const posts = (f: Awaited<ReturnType<typeof managedFixture>>) => f.provider.mock.calls.filter(([url]) => String(url).endsWith("chat.postMessage"));
|
||||
it("sends to the current linked user and reuses a durable result on duplicate requests", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
const input = { requestId: randomUUID() };
|
||||
const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(200);
|
||||
expect(result.body).toEqual({ status: "sent", slackUrl: "https://app.slack.com/client/TAUTO/DWELCOME" });
|
||||
await f.service.sendSlackTestMessage(f.endpoint.id, actor, input);
|
||||
expect(posts(f)).toHaveLength(1);
|
||||
expect(new URLSearchParams(String(posts(f)[0]![1]!.body)).get("channel")).toBe(f.installerId);
|
||||
});
|
||||
it("links the actor's own manager identity without requiring a connect command", async () => {
|
||||
const f = await managedFixture(); await f.provision();
|
||||
await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).toMatchObject({ status: "sent" });
|
||||
expect(await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id))).toEqual([expect.objectContaining({ paperclipUserId: actor.userId, status: "linked" })]);
|
||||
});
|
||||
it("requires a private Slack challenge for an unknown account before linking and sending the test", async () => {
|
||||
const f = await managedFixture(); await f.provision();
|
||||
await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
f.provider.mockClear(); const input = { requestId: randomUUID() };
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual({ status: "connection_required", accounts: [{ id: f.installerId, name: "Installing Person" }], nextCursor: null });
|
||||
expect(posts(f)).toHaveLength(0);
|
||||
const pending = await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, slackUserId: f.installerId });
|
||||
expect(pending.status).toBe("connection_pending");
|
||||
const links = await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
expect(links).toEqual([expect.objectContaining({ status: "pending", paperclipUserId: null })]);
|
||||
const fields = new URLSearchParams(String(posts(f)[0]![1]!.body));
|
||||
expect(fields.get("text")).not.toContain("This is your test message");
|
||||
const blocks = JSON.parse(fields.get("blocks")!);
|
||||
const invitationUrl = /<([^|]+)\|/.exec(blocks[1].text.text)![1]!;
|
||||
const token = new URL(invitationUrl).searchParams.get("token")!;
|
||||
expect(JSON.stringify(pending)).not.toContain(token);
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual(pending);
|
||||
expect(posts(f)).toHaveLength(1);
|
||||
await f.service.confirmIdentityLink(token, actor.userId);
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" });
|
||||
expect(new URLSearchParams(String(posts(f).at(-1)![1]!.body)).get("text")).toContain("This is your test message");
|
||||
});
|
||||
it("loads another workspace page instead of truncating account discovery", async () => {
|
||||
const f = await managedFixture(); await f.provision();
|
||||
await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
f.provider.mockClear(); f.state.userListNextCursor = "next-page";
|
||||
const input = { requestId: randomUUID() };
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "connection_required", nextCursor: "next-page" });
|
||||
f.state.userListNextCursor = "";
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, accountCursor: "next-page" })).toMatchObject({ status: "connection_required", nextCursor: null });
|
||||
expect(f.state.userListCursor).toBe("next-page");
|
||||
expect(posts(f)).toHaveLength(0);
|
||||
});
|
||||
it("does not downgrade confirmed delivery when activity logging fails", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
const original = activityLogModule.logActivity;
|
||||
const audit = vi.spyOn(activityLogModule, "logActivity").mockImplementation(async (database, entry) => {
|
||||
if (entry.action === "chat_slack.test_message_sent") throw new Error("Synthetic activity failure");
|
||||
return original(database, entry);
|
||||
});
|
||||
onTestFinished(() => audit.mockRestore());
|
||||
const input = { requestId: randomUUID() };
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" });
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" });
|
||||
expect(posts(f)).toHaveLength(1);
|
||||
});
|
||||
it("does not restore revoked access or take another user's identity", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("existing link");
|
||||
await db.update(chatIdentityLinks).set({ status: "linked", paperclipUserId: "another-user" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID(), slackUserId: f.installerId })).rejects.toThrow("existing link");
|
||||
expect(posts(f)).toHaveLength(0);
|
||||
});
|
||||
it("rejects wrong-workspace users, disabled DMs, and membership revocation", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
f.state.user.team_id = "TOTHER";
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("personal Slack account");
|
||||
f.state.user.team_id = "TAUTO";
|
||||
await db.update(chatEndpoints).set({ allowDirectMessages: false }).where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("Enable direct messages");
|
||||
await db.update(chatEndpoints).set({ allowDirectMessages: true }).where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, actor.userId)));
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("active Paperclip account");
|
||||
expect(posts(f)).toHaveLength(0);
|
||||
});
|
||||
it("redacts provider failures and never retries an ambiguous send", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear(); f.state.timeoutWelcome = true;
|
||||
const input = { requestId: randomUUID() };
|
||||
const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(422);
|
||||
expect(JSON.stringify(result.body)).not.toContain("canary");
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).rejects.toThrow("may already have been sent");
|
||||
expect(posts(f)).toHaveLength(1);
|
||||
const actions = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "slack_test_message")));
|
||||
expect(JSON.stringify(actions)).not.toContain("canary");
|
||||
expect(actions[0]!.status).toBe("uncertain");
|
||||
});
|
||||
});
|
||||
describe("managed Slack", () => {
|
||||
it("keeps open-source setup independent of the broker", async () => {
|
||||
const f = await fixture();
|
||||
expect(await f.service.slackRegistration.managerGrants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "automatic", workspaces: [] });
|
||||
await f.create();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackSetupMethod).toBe("automatic");
|
||||
await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId)).rejects.toThrow();
|
||||
});
|
||||
it("keeps configured Cloud drafts managed when the broker profile disappears", async () => {
|
||||
const f = await managedFixture();
|
||||
f.broker.getSlackManagerAppId.mockResolvedValue(null);
|
||||
expect(f.grants.defaultMethod()).toBe("managed");
|
||||
expect(await f.grants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "managed", workspaces: [] });
|
||||
const next = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId }, actor.userId);
|
||||
expect(next.setup.slackSetupMethod).toBe("managed");
|
||||
await expect(f.provision()).rejects.toThrow();
|
||||
});
|
||||
it("creates and installs once, uploads the avatar and links only the manager grant owner", async () => {
|
||||
const f = await managedFixture();
|
||||
await Promise.all([f.provision(), f.provision()]);
|
||||
const saved = await f.service.get(f.endpoint.id);
|
||||
expect(saved.setup).toMatchObject({ slackSetupMethod: "managed", slackRegistration: { status: "configured", appId: f.appId }, slackAvatar: { status: "uploaded" }, slackAccount: { status: "linked", paperclipUserId: actor.userId, externalUserId: f.installerId } });
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1);
|
||||
const activities = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId));
|
||||
for (const secret of [f.credentials.accessToken, f.credentials.refreshToken!, f.token, clientSecret, signingSecret, "claim-canary"]) expect(JSON.stringify({ saved, activities, choices: await f.grants.choices(f.companyId, actor) })).not.toContain(secret);
|
||||
await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "automatic" }, actor.userId)).rejects.toThrow();
|
||||
});
|
||||
it("reuses the same personal workspace grant for another draft without another authorization", async () => {
|
||||
const f = await managedFixture();
|
||||
const draft = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed" }, actor.userId);
|
||||
expect((await f.grants.choices(f.companyId, actor)).workspaces[0]!.grantId).toBe(f.grantId);
|
||||
const otherActor = { ...actor, userId: "another-user", bypassPermissionCheck: true };
|
||||
expect((await f.grants.choices(f.companyId, otherActor)).workspaces).toEqual([]);
|
||||
await expect(f.service.slackRegistration.managed.provision(draft.id, otherActor, { ...f.input, requestId: randomUUID() })).rejects.toThrow();
|
||||
expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("installs a second bot using the same grant without new manager consent", async () => {
|
||||
const f = await managedFixture(); await f.provision();
|
||||
const secondBot = `${f.botId}SECOND`;
|
||||
const secondApp = `${f.appId}SECOND`;
|
||||
const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot;
|
||||
const provider: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("apps.manifest.create")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", credentials: { client_id: "222.333", client_secret: clientSecret, signing_secret: signingSecret } });
|
||||
if (url.endsWith("apps.managedInstall")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", api_access_tokens: { bot_access_token: "xoxb-second-managed" } });
|
||||
if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: "TAUTO", user_id: secondBot, bot_id: "BSECOND", team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
|
||||
if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BSECOND", app_id: secondApp, user_id: secondBot, deleted: false } });
|
||||
return f.provider(input, init);
|
||||
};
|
||||
const { service } = createService(new FakeChatSdkRuntime(), provider, { renderSlackAvatar: f.renderAvatar, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } });
|
||||
const second = await service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed", slackApp: appDetails }, actor.userId);
|
||||
await service.slackRegistration.managed.provision(second.id, actor, { grantId: f.grantId, requestId: randomUUID() });
|
||||
expect((await service.get(second.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: secondApp });
|
||||
expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(f.broker.claim).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("rejects missing tool scopes before activating a managed bot", async () => {
|
||||
const f = await managedFixture(); f.state.grantedScopes = [];
|
||||
await f.provision();
|
||||
const saved = await f.service.get(f.endpoint.id);
|
||||
expect(saved.providerAccountId).toBeNull();
|
||||
expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_scopes_missing" });
|
||||
});
|
||||
it.each(["revoked", "attention", "active"] as const)("reinstalls the configured app on an explicit %s reconnect", async status => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
await db.update(chatEndpoints).set({ status }).where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
f.state.managedAccessToken = `xoxb-reconnected-${f.botId}`; f.state.rejectOldBotToken = true;
|
||||
const reconnectInput = { ...f.input, requestId: randomUUID(), reconnect: true };
|
||||
await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0);
|
||||
expect((await f.service.get(f.endpoint.id)).status).not.toBe("revoked");
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: f.appId });
|
||||
await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput);
|
||||
await f.provision();
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1);
|
||||
});
|
||||
it("preserves configured-app reconnect intent when managed installation fails", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
await db.update(chatEndpoints).set({ status: "revoked" }).where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
f.state.managedError = "temporary_error";
|
||||
await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", appId: f.appId });
|
||||
f.state.managedError = null;
|
||||
await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0);
|
||||
});
|
||||
it("reinstalls the saved app after missing bot scopes without creating another app", async () => {
|
||||
const f = await managedFixture(); const scopes = f.state.grantedScopes;
|
||||
f.state.grantedScopes = []; await f.provision();
|
||||
f.state.grantedScopes = scopes; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(2);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("retains reinstall intent after a provider failure instead of retrying rejected credentials", async () => {
|
||||
const f = await managedFixture(); const scopes = f.state.grantedScopes;
|
||||
f.state.grantedScopes = []; await f.provision();
|
||||
f.state.managedError = "internal_error"; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", errorCode: "slack_provider_failure" });
|
||||
f.state.managedError = null; f.state.grantedScopes = scopes; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(3);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("clears a manager reconnect error after renewing the bound grant", async () => {
|
||||
const f = await managedFixture(); f.state.managedError = "invalid_auth"; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manager_reauthorize");
|
||||
await f.grants.complete(await f.authorize(), "new-claim", null, actor);
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBeNull();
|
||||
f.state.managedError = null; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("reconnects a rejected manager token without losing pending manifest configuration", async () => {
|
||||
const f = await managedFixture(); f.state.failManifestUpdate = true; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manifest_update_pending");
|
||||
expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]);
|
||||
expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("reauthorize");
|
||||
await f.grants.complete(await f.authorize(), "new-claim", null, actor);
|
||||
f.state.failManifestUpdate = false; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackAvatar?.status).toBe("uploaded");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.icon.set"))).toHaveLength(1);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("does not restore a revoked grant when an in-flight refresh returns", async () => {
|
||||
const f = await managedFixture();
|
||||
await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
f.broker.refresh.mockImplementation(async () => {
|
||||
await f.grants.revoke(f.grantId, f.companyId, actor);
|
||||
return f.credentials;
|
||||
});
|
||||
await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow();
|
||||
expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("revoked");
|
||||
});
|
||||
it("claims concurrent manager callbacks once", async () => {
|
||||
const f = await managedFixture(); const state = await f.authorize();
|
||||
const results = await Promise.allSettled([1, 2].map(() => f.grants.complete(state, "claim", null, actor)));
|
||||
expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1);
|
||||
expect(f.broker.claim).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("atomically consumes manager state and rejects wrong actors and expired attempts", async () => {
|
||||
const f = await managedFixture();
|
||||
await expect(f.grants.complete(f.authorizeState, "replay", null, actor)).rejects.toThrow();
|
||||
const state = await f.authorize();
|
||||
await expect(f.grants.complete(state, "claim", null, { ...actor, userId: "another-user", bypassPermissionCheck: true })).rejects.toThrow();
|
||||
await db.update(toolOauthStates).set({ expiresAt: new Date(0) }).where(eq(toolOauthStates.state, state));
|
||||
await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow();
|
||||
expect(f.broker.claim).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("preserves declined manager authorization as a safe recoverable outcome", async () => {
|
||||
const f = await managedFixture();
|
||||
await f.grants.complete(await f.authorize(), null, "access_denied", actor);
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBe("slack_install_declined");
|
||||
});
|
||||
it("resumes saved installation credentials after inventory failure without creating or reinstalling", async () => {
|
||||
const f = await managedFixture(); f.state.failInventory = true;
|
||||
await f.provision();
|
||||
expect((await f.service.slackRegistration.registration(f.endpoint.id))?.status).toBe("credentials_saved");
|
||||
f.state.failInventory = false; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("never recreates an uncertain app", async () => {
|
||||
const f = await managedFixture(); f.state.failCreate = true;
|
||||
await f.provision(); await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("uncertain");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("uses bound OAuth for approval fallback and rejects a different installer", async () => {
|
||||
const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible";
|
||||
const result = await f.provision();
|
||||
const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!;
|
||||
f.state.oauth.authed_user.id = "UOTHER";
|
||||
await f.service.slackRegistration.complete(state, "code-canary", null, actor);
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch");
|
||||
expect((await f.service.get(f.endpoint.id)).providerAccountId).toBeNull();
|
||||
});
|
||||
it("does not activate a bot when the manager grant is revoked during child OAuth", async () => {
|
||||
const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible";
|
||||
const result = await f.provision();
|
||||
const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!;
|
||||
f.state.beforeExchange = () => f.grants.revoke(f.grantId, f.companyId, actor);
|
||||
await f.service.slackRegistration.complete(state, "code-canary", null, actor);
|
||||
const saved = await f.service.get(f.endpoint.id);
|
||||
expect(saved.providerAccountId).toBeNull();
|
||||
expect(saved.setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch");
|
||||
});
|
||||
it("uses the held transaction for grant queries without acquiring another pool connection", async () => {
|
||||
const f = await managedFixture();
|
||||
const transactionContext = new AsyncLocalStorage<boolean>();
|
||||
const originalTransaction = db.transaction.bind(db);
|
||||
const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((work, ...args) =>
|
||||
originalTransaction(tx => transactionContext.run(true, () => work(tx)), ...args));
|
||||
const querySpies = (["select", "insert", "update", "delete", "execute"] as const).map(method => {
|
||||
const original = db[method].bind(db) as (...args: unknown[]) => unknown;
|
||||
return vi.spyOn(db, method).mockImplementation(((...args: unknown[]) => {
|
||||
if (transactionContext.getStore()) throw new Error("Acquired outer pool connection from held transaction");
|
||||
return original(...args);
|
||||
}) as never);
|
||||
});
|
||||
try {
|
||||
await f.grants.token(f.grantId, f.companyId, actor);
|
||||
await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
await f.grants.token(f.grantId, f.companyId, actor);
|
||||
expect(f.broker.refresh).toHaveBeenCalledTimes(1);
|
||||
await f.grants.request(f.grantId, f.companyId, actor, "apps.manifest.validate", { manifest: "{}" });
|
||||
await f.grants.complete(await f.authorize(), "renewed-claim", null, actor);
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBeUndefined();
|
||||
await f.grants.revoke(f.grantId, f.companyId, actor);
|
||||
expect(f.broker.revoke).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
transactionSpy.mockRestore();
|
||||
for (const spy of querySpies) spy.mockRestore();
|
||||
}
|
||||
});
|
||||
it("serializes refreshes across endpoints and never replays an ambiguous refresh", async () => {
|
||||
const f = await managedFixture();
|
||||
await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
const tokens = await Promise.all([1, 2].map(() => f.grants.token(f.grantId, f.companyId, actor)));
|
||||
expect(tokens.every(result => result.token === "xoxe.rotated-canary")).toBe(true);
|
||||
expect(f.broker.refresh).toHaveBeenCalledTimes(1);
|
||||
await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
f.broker.refresh.mockRejectedValue(new Error("provider echoed manager-canary"));
|
||||
await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow();
|
||||
await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow();
|
||||
expect(f.broker.refresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("rejects a bot token belonging to a different Slack app before activation", async () => {
|
||||
const f = await managedFixture(); f.state.managedBotAppId = "ADIFFERENT";
|
||||
await f.provision();
|
||||
const saved = await f.service.get(f.endpoint.id);
|
||||
expect(saved.providerAccountId).toBeNull();
|
||||
expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_identity_mismatch" });
|
||||
});
|
||||
it("rejects manager identity changes and both callback-origin and ingress-origin drift", async () => {
|
||||
const f = await managedFixture(); const state = await f.authorize();
|
||||
for (const change of [{ publicBaseUrl: "https://changed.example" }, { webhookPublicBaseUrl: "https://changed.example" }]) {
|
||||
const changed = createService(new FakeChatSdkRuntime(), f.provider, { ...change, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } }).service;
|
||||
await expect(changed.slackRegistration.managerGrants.complete(state, "claim", null, actor)).rejects.toThrow();
|
||||
}
|
||||
f.broker.getSlackManagerAppId.mockResolvedValue("ANEWMANAGER");
|
||||
await expect(f.provision()).rejects.toThrow();
|
||||
expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]);
|
||||
});
|
||||
it("preserves provider rate-limit backoff across subsequent attempts", async () => {
|
||||
const f = await managedFixture(); f.state.managedError = "ratelimited";
|
||||
await f.provision();
|
||||
f.state.managedError = null; await f.provision();
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1);
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_setup_rate_limited");
|
||||
});
|
||||
it("routes the broker claim_id callback back to the saved wizard without exposing the handoff", async () => {
|
||||
const f = await managedFixture(); const state = await f.authorize();
|
||||
const response = await request(f.app).get(`/api/chat-slack/managed/oauth/callback?state=${state}&claim_id=sealed-claim-canary`).set("accept", "application/json").expect(303);
|
||||
expect(f.broker.claim.mock.calls.at(-1)![0].claimId).toBe("sealed-claim-canary");
|
||||
expect(response.headers.location).toContain(f.endpoint.id);
|
||||
expect(response.text).not.toContain("sealed-claim-canary");
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
});
|
||||
it("does not accept another company's grant or keep authorizations after removal", async () => {
|
||||
const f = await managedFixture(); const other = await seedCompany();
|
||||
await expect(f.grants.token(f.grantId, other.companyId, { ...actor, bypassPermissionCheck: true })).rejects.toThrow();
|
||||
const state = await f.authorize();
|
||||
await f.service.configure(f.endpoint.id, { action: "remove" }, actor.userId);
|
||||
await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow();
|
||||
expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("active");
|
||||
});
|
||||
it("revocation invalidates pending authorization and prevents future provisioning", async () => {
|
||||
const f = await managedFixture(); const state = await f.authorize();
|
||||
await f.grants.revoke(f.grantId, f.companyId, actor);
|
||||
await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow();
|
||||
await expect(f.provision()).rejects.toThrow();
|
||||
expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
function setupEvent(f: Awaited<ReturnType<typeof fixture>>, patch: Record<string, unknown> = {}, url = f.endpoint.setup.webhookUrl!, secret = signingSecret, timestamp?: string) {
|
||||
return signedSlackWebhookRequest({ url, contentType: "application/json", signingSecret: secret, ...(timestamp ? { timestamp } : {}),
|
||||
body: JSON.stringify({ type: "event_callback", api_app_id: f.appId, team_id: "TAUTO", event_id: "EvSETUP", event: {
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("isPrivateWebhookHttpRequest", () => {
|
||||
describe("isSecretSensitiveHttpRequest", () => {
|
||||
it("protects Slack registration, installation, recovery, and all callback methods", () => {
|
||||
for (const suffix of ["registration", "install", "resume"]) expect(isSecretSensitiveHttpRequest("POST", `/api/chat-endpoints/id/slack/${suffix}`)).toBe(true);
|
||||
for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"])
|
||||
for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "/api/chat-slack/managed/oauth/callback?claim=canary", "/api/chat-slack/managed/oauth/callback?code=canary", "/api/chat-endpoints/endpoint/slack/managed/authorize", "/api/chat-endpoints/endpoint/slack/managed/provision", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"])
|
||||
expect(isSecretSensitiveHttpRequest(method, path)).toBe(true);
|
||||
});
|
||||
it("identifies credential-bearing chat setup mutations", () => {
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
stripSecretBearingUrlParts,
|
||||
} from "../middleware/redact-sensitive.js";
|
||||
|
||||
it("redacts Slack manager grants and sealed OAuth handoffs", () => {
|
||||
const result = redactSensitive({ managerToken: "manager-canary", refreshToken: "refresh-canary", sealed: { ct: "sealed-canary" }, handoff: { token: "handoff-canary" }, claim_id: "claim-canary" });
|
||||
expect(JSON.stringify(result)).not.toContain("canary");
|
||||
});
|
||||
|
||||
describe("redactSensitive", () => {
|
||||
it("redacts a plaintext password field on a sign-in body", () => {
|
||||
const body = {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
||||
import { developmentSlackManager } from "../services/connectors/slack/setup/development-manager.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => roots.splice(0).forEach(path => rmSync(path, { recursive: true, force: true })));
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), "slack-development-")); roots.push(root);
|
||||
const path = join(root, "manager.json");
|
||||
writeFileSync(path, JSON.stringify({ appId: "A123", clientId: "123.456", clientSecret: "client-secret-canary" }), { mode: 0o600 });
|
||||
return { path, env: { NODE_ENV: "development", PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "true", PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG: path } };
|
||||
}
|
||||
const origin = () => "https://local-test.trycloudflare.com";
|
||||
const identity = { subject: "actor", companyId: "company", profile: "slack.manager" as const };
|
||||
const user = { id: "U123", token_type: "user", access_token: "access-canary", refresh_token: "refresh-canary", expires_in: 43200, scope: SLACK_MANAGER_SCOPES.join(",") };
|
||||
const response = (value: unknown) => new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } });
|
||||
|
||||
describe("local managed Slack qualification", () => {
|
||||
it("requires explicit development opt-in and refuses production", () => {
|
||||
const { env } = fixture();
|
||||
expect(developmentSlackManager(origin, {})).toBeNull();
|
||||
expect(developmentSlackManager(origin, { ...env, NODE_ENV: "production" })).toBeNull();
|
||||
expect(developmentSlackManager(origin, { ...env, PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "false" })).toBeNull();
|
||||
});
|
||||
it("rejects readable secrets and invalid HTTPS origins", async () => {
|
||||
const { env, path } = fixture(); chmodSync(path, 0o644);
|
||||
expect(() => developmentSlackManager(origin, env)).toThrow();
|
||||
chmodSync(path, 0o600);
|
||||
await expect(developmentSlackManager(() => "http://local.test", env)!.getSlackManagerAppId()).rejects.toThrow();
|
||||
});
|
||||
it("builds a scoped authorization URL without exposing the client secret", async () => {
|
||||
const { env } = fixture();
|
||||
const client = developmentSlackManager(origin, env)!;
|
||||
const result = await client.startAuthorization({ ...identity, returnUri: `${origin()}/api/chat-slack/managed/oauth/callback`, returnState: "bound-state" });
|
||||
const url = new URL(result.authorizationUrl);
|
||||
expect(url.origin).toBe("https://slack.com");
|
||||
expect(url.searchParams.get("state")).toBe("bound-state");
|
||||
expect(url.searchParams.get("user_scope")).toBe(SLACK_MANAGER_SCOPES.join(","));
|
||||
expect(JSON.stringify(result)).not.toContain("client-secret-canary");
|
||||
await expect(client.startAuthorization({ ...identity, returnUri: "https://other.test/callback", returnState: "bound-state" })).rejects.toThrow();
|
||||
});
|
||||
it("exchanges the code against the exact callback and preserves actor/workspace identity", async () => {
|
||||
const { env } = fixture();
|
||||
const fetcher = vi.fn().mockResolvedValue(response({ ok: true, app_id: "A123", team: { id: "T123", name: "Paperclip" }, authed_user: user }));
|
||||
const result = await developmentSlackManager(origin, env, fetcher)!.claim({ ...identity, claimId: "code-canary", redemptionId: "once" });
|
||||
expect(result.slackManager).toEqual({ appId: "A123", workspaceId: "T123", workspaceName: "Paperclip", userId: "U123" });
|
||||
expect(result.subject).toBe("actor"); expect(result.companyId).toBe("company");
|
||||
const [url, request] = fetcher.mock.calls[0];
|
||||
expect(url).toBe("https://slack.com/api/oauth.v2.access");
|
||||
expect(request.redirect).toBe("error");
|
||||
expect(request.body.get("redirect_uri")).toBe(`${origin()}/api/chat-slack/managed/oauth/callback`);
|
||||
expect(request.body.get("code")).toBe("code-canary");
|
||||
});
|
||||
it.each(["wrong-app", "missing-scope", "provider-echo"])("rejects %s without echoing secrets", async reason => {
|
||||
const { env } = fixture();
|
||||
const value = reason === "provider-echo" ? { ok: false, error: "client-secret-canary code-canary" } : { ok: true, app_id: reason === "wrong-app" ? "AOTHER" : "A123", team: { id: "T123", name: "Paperclip" }, authed_user: { ...user, scope: reason === "missing-scope" ? "" : user.scope } };
|
||||
const client = developmentSlackManager(origin, env, vi.fn().mockResolvedValue(response(value)))!;
|
||||
const error = await client.claim({ ...identity, claimId: "code-canary", redemptionId: "once" }).catch(error => error);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(String(error)).not.toMatch(/canary/);
|
||||
});
|
||||
it("rotates refresh credentials and verifies revocation", async () => {
|
||||
const { env } = fixture();
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(response({ ok: true, ...user })).mockResolvedValueOnce(response({ ok: false, error: "invalid_auth" }));
|
||||
const client = developmentSlackManager(origin, env, fetcher)!;
|
||||
expect((await client.refresh({ ...identity, refreshToken: "old-refresh" })).refreshToken).toBe("refresh-canary");
|
||||
expect(fetcher.mock.calls[0][1].body.get("grant_type")).toBe("refresh_token");
|
||||
await expect(client.revoke({ ...identity, token: "access-canary" })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -93,7 +93,7 @@ export function isSecretSensitiveHttpRequest(
|
||||
// Avatar uploads and dedicated Dot MCP calls may contain private image bytes.
|
||||
if (url && /^(?:\/mcp\/runner(?:\/|$)|\/api\/companies\/[^/]+\/agents\/[^/]+\/avatar(?:\/|$))/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
if (!method || !url) return false;
|
||||
if (/^\/api\/chat-slack\/oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
if (/^\/api\/chat-slack\/(?:managed\/)?oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
if (/^\/api\/chat-endpoints\/[^/]+\/slack(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
if (!SECRET_SENSITIVE_HTTP_METHODS.has(method.toUpperCase())) return false;
|
||||
const pathname = normalizePath(url);
|
||||
|
||||
@@ -16,6 +16,13 @@ const SENSITIVE_KEYS = new Set<string>([
|
||||
// material under `credentials`. Redact the whole subtree instead of trying
|
||||
// to keep an ever-changing allowlist of provider-specific field names in
|
||||
// sync with every connector.
|
||||
"sealed",
|
||||
"handoff",
|
||||
"claim",
|
||||
"claimid",
|
||||
"claim_id",
|
||||
"managertoken",
|
||||
"manager_token",
|
||||
"configurationtoken",
|
||||
"configuration_token",
|
||||
"credential",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { slackRegistrationSchema, slackSetupActionSchema } from "@paperclipai/shared";
|
||||
import { slackTestMessageSchema, slackRegistrationSchema, slackSetupActionSchema, slackManagedProvisionSchema } from "@paperclipai/shared";
|
||||
import { isCrossSiteOAuthCallbackNavigation, oauthCallbackInterstitialHtml } from "../lib/oauth-browser-return.js";
|
||||
import {
|
||||
Router,
|
||||
@@ -164,6 +164,10 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertConnectionManager(req, companyId);
|
||||
if (req.body.provider === "slack" && !req.body.slackSetupMethod) {
|
||||
req.body.slackSetupMethod = service.slackRegistration.managerGrants.defaultMethod();
|
||||
}
|
||||
if (req.body.slackSetupMethod === "managed" && service.slackRegistration.managerGrants.defaultMethod() !== "managed") throw forbidden("Managed Slack setup is unavailable");
|
||||
res
|
||||
.status(201)
|
||||
.json(await service.create(companyId, req.body, actorUserId(req)));
|
||||
@@ -182,6 +186,50 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
|
||||
return { userId: actor.actorId, sessionId: actor.sessionId,
|
||||
bypassPermissionCheck: req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true };
|
||||
};
|
||||
router.get("/companies/:companyId/chat-slack/setup-options", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertConnectionManager(req, companyId);
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json(await service.slackRegistration.managerGrants.choices(companyId, slackActor(req)));
|
||||
});
|
||||
router.post("/chat-endpoints/:endpointId/slack/managed/authorize", validate(slackSetupActionSchema), async (req, res) => {
|
||||
if (!(await assertEndpointManagementAccess(req, res))) return;
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json(await service.slackRegistration.managerGrants.authorize(endpointId(req), slackActor(req)));
|
||||
});
|
||||
router.post("/chat-endpoints/:endpointId/slack/managed/provision", validate(slackManagedProvisionSchema), async (req, res) => {
|
||||
if (!(await assertEndpointManagementAccess(req, res))) return;
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json(await service.slackRegistration.managed.provision(endpointId(req), slackActor(req), req.body));
|
||||
});
|
||||
router.delete("/companies/:companyId/chat-slack/manager-grants/:grantId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertConnectionManager(req, companyId);
|
||||
await service.slackRegistration.managerGrants.revoke(req.params.grantId as string, companyId, slackActor(req));
|
||||
res.sendStatus(204);
|
||||
});
|
||||
router.get("/chat-slack/managed/oauth/callback", async (req, res) => {
|
||||
res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer");
|
||||
const actor = slackActor(req);
|
||||
const state = typeof req.query.state === "string" ? req.query.state : "";
|
||||
const expiredId = await service.slackRegistration.managerGrants.expiredReturn(state, actor);
|
||||
if (expiredId) { res.redirect(303, await service.slackRegistration.returnPath(expiredId)); return; }
|
||||
const pending = await service.slackRegistration.managerGrants.pending(state, actor);
|
||||
await assertConnectionManager(req, pending.current.endpoint.companyId);
|
||||
if (req.get("accept")?.includes("text/html") && isCrossSiteOAuthCallbackNavigation(req)) {
|
||||
res.type("html").send(oauthCallbackInterstitialHtml()); return;
|
||||
}
|
||||
const id = await service.slackRegistration.managerGrants.complete(state,
|
||||
typeof req.query.claim_id === "string" ? req.query.claim_id : null,
|
||||
typeof req.query.error === "string" ? req.query.error : null, actor,
|
||||
typeof req.query.code === "string" ? req.query.code : null);
|
||||
res.redirect(303, await service.slackRegistration.returnPath(id));
|
||||
});
|
||||
router.post("/chat-endpoints/:endpointId/slack/test-message", validate(slackTestMessageSchema), async (req, res) => {
|
||||
if (!(await assertEndpointManagementAccess(req, res))) return;
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json(await service.sendSlackTestMessage(endpointId(req), slackActor(req), req.body));
|
||||
});
|
||||
router.post("/chat-endpoints/:endpointId/slack/registration", validate(slackRegistrationSchema), async (req, res) => {
|
||||
if (!(await assertEndpointManagementAccess(req, res))) return;
|
||||
res.set("Cache-Control", "no-store");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { updateFastResponseSchema } from "@paperclipai/shared";
|
||||
import { slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared";
|
||||
import { slackTestMessageSchema, slackTestMessageResultSchema, slackManagerAuthorizationSchema, slackSetupOptionsSchema, slackManagedProvisionSchema, slackManagedResultSchema, slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared";
|
||||
import { experimentalApiMetadata } from "./experimental-api-metadata.js";
|
||||
import {
|
||||
experimentalApiPaths,
|
||||
@@ -805,8 +805,9 @@ const chatAdapterCapabilitiesResponseSchema = z
|
||||
const chatEndpointSetupResponseSchema = z
|
||||
.object({
|
||||
step: z.enum(["choose_agent", "provider_setup", "test", "complete"]),
|
||||
slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(),
|
||||
slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(),
|
||||
slackRegistration: slackRegistrationStateSchema.optional(),
|
||||
slackManagerError: z.string().optional(),
|
||||
slackAvatar: slackAvatarStateSchema.optional(),
|
||||
slackAccount: slackAccountStateSchema.optional(),
|
||||
slackOAuthCallbackUri: z.string().nullable().optional(),
|
||||
@@ -2565,6 +2566,41 @@ registry.registerPath({
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get", path: "/api/companies/{companyId}/chat-slack/setup-options", tags: ["chat-channels"],
|
||||
summary: "Discover Slack setup methods and the current user's authorized workspaces",
|
||||
description: "Managed setup is advertised only for approved Cloud deployments with the broker profile enabled. Self-hosted discovery makes no hosted-service requests. Grants are personal and company scoped.",
|
||||
request: { params: z.object({ companyId: z.string().uuid() }) }, responses: { 200: r.ok(slackSetupOptionsSchema), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
for (const action of ["authorize", "provision"] as const) registry.registerPath({
|
||||
method: "post", path: `/api/chat-endpoints/{endpointId}/slack/managed/${action}`, tags: ["chat-channels"],
|
||||
summary: action === "authorize" ? "Authorize a personal Slack manager workspace grant" : "Provision the saved managed Slack app",
|
||||
description: "Requires the selected managed method, company membership, connection-management permission and Cloud availability. Authorization state is actor/session bound and single use. Provisioning accepts a personal grant ID and idempotent request ID; no provider credentials or arbitrary manifests are accepted. Explicit reconnect uses reconnect=true with a fresh request ID to reinstall the saved app. Repeating that reconnect request returns its saved result without reinstalling a healthy connection.",
|
||||
request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(action === "authorize" ? slackSetupActionSchema : slackManagedProvisionSchema) },
|
||||
responses: { 200: r.ok(action === "authorize" ? slackManagerAuthorizationSchema : slackManagedResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
|
||||
});
|
||||
registry.registerPath({
|
||||
method: "delete", path: "/api/companies/{companyId}/chat-slack/manager-grants/{grantId}", tags: ["chat-channels"],
|
||||
summary: "Revoke the current user's workspace grant for future managed provisioning",
|
||||
description: "Removes local manager credentials and invalidates pending manager authorization. Installed bots remain connected; this does not uninstall Slack apps.",
|
||||
request: { params: z.object({ companyId: z.string().uuid(), grantId: z.string().uuid() }) }, responses: { 204: { description: "Personal manager grant revoked" }, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
registry.registerPath({
|
||||
method: "get", path: "/api/chat-slack/managed/oauth/callback", tags: ["chat-channels"],
|
||||
summary: "Redeem a sealed manager authorization and return to Slack setup",
|
||||
description: "Authenticated, same-origin continuation with company, actor, session, endpoint revision and configured-origin checks. The broker handoff is single use; credentials are never returned to the browser. Explicit local development qualification accepts a Slack code instead of a broker claim; production ignores code.",
|
||||
request: { query: z.object({ state: z.string(), claim_id: z.string().optional(), code: z.string().optional(), error: z.string().optional() }) },
|
||||
responses: { 200: { description: "Same-origin continuation page" }, 303: { description: "Return to saved setup" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post", path: "/api/chat-endpoints/{endpointId}/slack/test-message", tags: ["chat-channels"],
|
||||
summary: "Connect the current user's Slack identity and send a test DM",
|
||||
description: "Requires a board connection manager and active company membership. Reuses only the actor's verified identity in the same workspace; otherwise returns paginated account choices and sends a private connection challenge. The account holder must confirm it while signed in before linking or test delivery. Revoked and conflicting links cannot be overwritten. The request ID prevents duplicate or ambiguous sends from being replayed.",
|
||||
request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(slackTestMessageSchema) },
|
||||
responses: { 200: r.ok(slackTestMessageResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable, 429: { description: "Wait before sending another test message" } },
|
||||
});
|
||||
|
||||
for (const action of ["registration", "install", "resume"] as const) {
|
||||
registry.registerPath({
|
||||
method: "post", path: `/api/chat-endpoints/{endpointId}/slack/${action}`, tags: ["chat-channels"],
|
||||
|
||||
@@ -323,11 +323,29 @@ describe("run failure diagnostics", () => {
|
||||
expect(JSON.stringify(result)).not.toContain("private");
|
||||
});
|
||||
|
||||
it("includes stop-time ACP activity and closed pending tool-kind labels", () => {
|
||||
const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ resultJson: {
|
||||
acpLastEventAgeMs: 700, acpLastEventAgeAtStopMs: 14_000_000, acpOldestPendingToolAgeAtStopMs: 14_100_000,
|
||||
acpPendingToolKinds: "execute,unknown",
|
||||
} }), {}));
|
||||
expect(result.execution).toEqual({
|
||||
acpLastEventAgeMs: 700, acpLastEventAgeAtStopMs: 14_000_000, acpOldestPendingToolAgeAtStopMs: 14_100_000,
|
||||
acpPendingToolKinds: "execute,unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["execute,private command", "", "execute,", "Execute", ["execute"], 1])(
|
||||
"omits pending tool kinds that are not closed labels (%j)", (value) => {
|
||||
const result = collectRunFailureDiagnostics(run({ resultJson: { acpPendingToolKinds: value } }), {});
|
||||
expect(result.execution).toEqual({});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, -1, Infinity, NaN, 1.5, Number.MAX_SAFE_INTEGER + 1, "private", {}])(
|
||||
"omits invalid ACP activity fields (%j)", (value) => {
|
||||
const result = collectRunFailureDiagnostics(run({ resultJson: {
|
||||
acpLastEventAgeMs: value, acpObservedEventCount: value, acpPendingToolCount: value,
|
||||
acpToolInventoryComplete: value,
|
||||
acpToolInventoryComplete: value, acpLastEventAgeAtStopMs: value, acpOldestPendingToolAgeAtStopMs: value,
|
||||
} }), {});
|
||||
expect(result.execution).toEqual({});
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,903 @@
|
||||
import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js";
|
||||
|
||||
import { notifyChatPublicationWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import { nativeSha256 } from "../../native-runtime/canonical.js";
|
||||
|
||||
import { projectSafeChatPublicationText } from "../../chat-publication-projection.js";
|
||||
import { PhotonAnswerValidationError, nativePhotonInteraction, photonResponseCommand, parsePhotonQuestionAnswer, type PhotonPromptReceipt, type PhotonInteractionBinding, type PhotonDraft } from "../../photon/interactions.js";
|
||||
import { validateNativeQuestionResponseInput } from "../../native-runtime/native-question-bridge.js";
|
||||
import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction } from "@paperclipai/shared";
|
||||
import { PhotonError, photonFailure } from "../../photon/cloud.js";
|
||||
import { PhotonChatAdapter, photonThreadId } from "../../photon/adapter.js";
|
||||
|
||||
import type { LiveEvent as PhotonEvent } from "@photon-ai/advanced-imessage";
|
||||
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
isNotNull,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
chatExternalPrincipals,
|
||||
chatPublications,
|
||||
issues,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
HttpError,
|
||||
forbidden,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import {
|
||||
type ChatSdkMessageCallbackEvent,
|
||||
} from "../../chat-sdk-runtime.js";
|
||||
|
||||
import {
|
||||
logActivity,
|
||||
} from "../../activity-log.js";
|
||||
|
||||
import { issueThreadInteractionService } from "../../issue-thread-interactions.js";
|
||||
import {
|
||||
questionResponseDeliveryService,
|
||||
} from "../../question-response-delivery.js";
|
||||
|
||||
import type {
|
||||
Author,
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
type PhotonEventsOptions = {
|
||||
runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise<EndpointRecord | null>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
invalidateRuntime: (endpointId: string) => Promise<boolean>;
|
||||
ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise<{ principal: Pick<typeof chatExternalPrincipals.$inferSelect, "id" | "kind" | "isBot">; userId: string | null; linkedDenied: boolean }>;
|
||||
requireCurrentExternalActionAuthorization: (tx: DbTransaction, input: { conversationId: string; endpointId: string; expectedUserId: string; principalId: string; runtimeContext: LifecycleRuntimeFence }) => Promise<void>;
|
||||
isExternalActionAuthorizationChange: (error: unknown) => boolean;
|
||||
scheduleMessageProcessing: (task: () => Promise<void>) => void;
|
||||
processPendingPublications: () => Promise<unknown>;
|
||||
questionResponses: Pick<ReturnType<typeof questionResponseDeliveryService>, "deliver">;
|
||||
redactError: (error: unknown) => string;
|
||||
renewDiscordGatewayOwnershipForMessageAdmission: (tx: DbTransaction, endpointId: string, context: RuntimeContext) => Promise<{ kind: "owned" | "lost" }>;
|
||||
handleSdkMessage: (event: ChatSdkMessageCallbackEvent, runtimeContext?: RuntimeContext, messageOptions?: { receiptReactionSupported?: boolean; slackSlashControl?: boolean }) => Promise<void>;
|
||||
};
|
||||
|
||||
export function createPhotonEvents(db: Db, options: PhotonEventsOptions) {
|
||||
const { runtimeCallbackRecord, runtimeCallbackEndpoint, invalidateRuntime, ensurePrincipal, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications, questionResponses, redactError, renewDiscordGatewayOwnershipForMessageAdmission, handleSdkMessage } = options;
|
||||
|
||||
|
||||
async function handlePhotonFailure(
|
||||
endpointId: string,
|
||||
error: unknown,
|
||||
context: RuntimeContext,
|
||||
) {
|
||||
if (error instanceof PhotonError && error.code === "attachment_not_ready")
|
||||
return;
|
||||
error = photonFailure(error);
|
||||
const fatal =
|
||||
error instanceof PhotonError &&
|
||||
[
|
||||
"credentials",
|
||||
"line_unavailable",
|
||||
"history_gap",
|
||||
"invalid_response",
|
||||
].includes(error.code);
|
||||
const message =
|
||||
error instanceof PhotonError
|
||||
? error.message
|
||||
: "Photon connection interrupted; reconnecting";
|
||||
await db.transaction(async (tx) => {
|
||||
const endpoint = await runtimeCallbackEndpoint(tx, endpointId, context, [
|
||||
"verifying",
|
||||
"active",
|
||||
"attention",
|
||||
]);
|
||||
if (!endpoint) return;
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: "attention",
|
||||
healthMessage: message,
|
||||
lastError: message,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatEndpoints.id, endpointId));
|
||||
if (fatal)
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
enabled: false,
|
||||
healthStatus: "error",
|
||||
healthMessage: message,
|
||||
})
|
||||
.where(eq(toolConnections.id, endpoint.connectionId));
|
||||
});
|
||||
// Retirement is queued outside the receiver callback to avoid self-joining.
|
||||
void invalidateRuntime(endpointId).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function processPhotonResponse(
|
||||
endpoint: EndpointRow,
|
||||
event: PhotonEvent,
|
||||
threadId: string,
|
||||
adapter: PhotonChatAdapter,
|
||||
context: RuntimeContext,
|
||||
): Promise<boolean> {
|
||||
const command =
|
||||
event.type === "message.received"
|
||||
? photonResponseCommand(event.message.content.text ?? "")
|
||||
: null;
|
||||
const replyGuid =
|
||||
event.type === "message.received" ? event.message.replyTargetGuid : null;
|
||||
const prompt =
|
||||
event.type === "poll.changed"
|
||||
? await adapter.state.read<PhotonPromptReceipt>(
|
||||
`poll-message:${event.pollMessageGuid}`,
|
||||
)
|
||||
: replyGuid
|
||||
? ((await adapter.state.read<PhotonPromptReceipt>(
|
||||
`prompt-message:${replyGuid}`,
|
||||
)) ??
|
||||
(await adapter.state.read<PhotonPromptReceipt>(
|
||||
`poll-message:${replyGuid}`,
|
||||
)))
|
||||
: null;
|
||||
const reference = command?.reference ?? prompt?.reference;
|
||||
if (!reference) {
|
||||
if (
|
||||
!event.isFromMe &&
|
||||
(replyGuid ||
|
||||
(event.type === "poll.changed" &&
|
||||
(event.delta.type === "voted" || event.delta.type === "unvoted")))
|
||||
) {
|
||||
// A vote can beat the local poll-binding write. Keep it behind the
|
||||
// checkpoint while a publication in this chat is in flight.
|
||||
const pending = await db
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatPublications)
|
||||
.innerJoin(
|
||||
chatConversations,
|
||||
eq(chatConversations.id, chatPublications.conversationId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.endpointId, endpoint.id),
|
||||
eq(chatConversations.externalThreadId, threadId),
|
||||
inArray(chatPublications.state, [
|
||||
"pending",
|
||||
"retry",
|
||||
"streaming",
|
||||
"delivery_unknown",
|
||||
]),
|
||||
isNotNull(sql`${chatPublications.payload}->>'interactionId'`),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (pending.length)
|
||||
throw new PhotonError(
|
||||
"attachment_not_ready",
|
||||
"Waiting for Photon poll binding",
|
||||
);
|
||||
}
|
||||
return (
|
||||
event.type === "poll.changed" ||
|
||||
(event.type === "message.received" &&
|
||||
/^\/(answer|submit)\b/i.test(event.message.content.text ?? ""))
|
||||
);
|
||||
}
|
||||
const action = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.companyId, endpoint.companyId),
|
||||
eq(chatActions.endpointId, endpoint.id),
|
||||
eq(chatActions.kind, "photon_interaction"),
|
||||
eq(chatActions.providerActionId, `photon:${reference}`),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]);
|
||||
if (!action || action.status !== "issued" || !action.conversationId)
|
||||
return true;
|
||||
const binding = action.payload as unknown as PhotonInteractionBinding;
|
||||
if (
|
||||
binding.version !== 1 ||
|
||||
binding.reference !== reference ||
|
||||
!Number.isFinite(Date.parse(binding.expiresAt)) ||
|
||||
Date.parse(binding.expiresAt) <= Date.now()
|
||||
)
|
||||
return true;
|
||||
const conversation = await db
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.id, action.conversationId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]);
|
||||
if (
|
||||
!conversation ||
|
||||
!["active", "waiting"].includes(conversation.state) ||
|
||||
conversation.sessionGeneration !== binding.sessionGeneration ||
|
||||
conversation.externalThreadId !== threadId
|
||||
)
|
||||
return true;
|
||||
const publication = await db
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, endpoint.companyId),
|
||||
eq(chatPublications.endpointId, endpoint.id),
|
||||
eq(chatPublications.id, binding.publicationId),
|
||||
eq(chatPublications.conversationId, conversation.id),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]);
|
||||
if (
|
||||
!publication ||
|
||||
publication.payload.interactionId !== binding.interactionId ||
|
||||
new Date(event.occurredAt).getTime() < action.createdAt.getTime()
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
["pending", "retry", "streaming", "delivery_unknown"].includes(
|
||||
publication.state,
|
||||
)
|
||||
)
|
||||
throw new PhotonError(
|
||||
"attachment_not_ready",
|
||||
"Waiting for Photon prompt publication",
|
||||
);
|
||||
if (publication.state !== "published") return true;
|
||||
const interaction = (
|
||||
await issueThreadInteractionService(db).listForIssue(conversation.issueId)
|
||||
).find((candidate) => candidate.id === binding.interactionId);
|
||||
if (
|
||||
!interaction ||
|
||||
interaction.status !== "pending" ||
|
||||
interaction.companyId !== endpoint.companyId ||
|
||||
interaction.createdByAgentId !== endpoint.assignedAgentId ||
|
||||
!nativePhotonInteraction(interaction)
|
||||
)
|
||||
return true;
|
||||
const actor =
|
||||
event.type === "message.received" ? event.message.sender : event.actor;
|
||||
if (event.isFromMe || !actor?.address || actor.service !== "iMessage")
|
||||
return true;
|
||||
const chat = await adapter.chatInfo(threadId);
|
||||
if (
|
||||
chat.isArchived ||
|
||||
!chat.participants.some(
|
||||
(person) =>
|
||||
person.address === actor.address && person.service === actor.service,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
const principal = await ensurePrincipal(
|
||||
endpoint,
|
||||
{
|
||||
userId: `${actor.service}:${actor.address}`,
|
||||
userName: actor.address,
|
||||
fullName: actor.address,
|
||||
isMe: false,
|
||||
isBot: false,
|
||||
},
|
||||
event,
|
||||
);
|
||||
if (
|
||||
!principal.userId ||
|
||||
principal.linkedDenied ||
|
||||
principal.principal.kind !== "user" ||
|
||||
principal.principal.isBot
|
||||
)
|
||||
return true;
|
||||
const assertCurrent = async () =>
|
||||
db.transaction((tx) =>
|
||||
requireCurrentExternalActionAuthorization(tx, {
|
||||
conversationId: conversation.id,
|
||||
endpointId: endpoint.id,
|
||||
expectedUserId: principal.userId!,
|
||||
principalId: principal.principal.id,
|
||||
runtimeContext: context,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await assertCurrent();
|
||||
} catch (error) {
|
||||
if (isExternalActionAuthorizationChange(error)) return true;
|
||||
throw error;
|
||||
}
|
||||
const draftKey = `draft:${reference}:${principal.principal.id}:${principal.userId}`;
|
||||
let draft = (await adapter.state.read<PhotonDraft>(draftKey)) ?? {
|
||||
schema: 1,
|
||||
interactionId: interaction.id,
|
||||
principalId: principal.principal.id,
|
||||
userId: principal.userId,
|
||||
answers: [],
|
||||
lastSequence: 0,
|
||||
};
|
||||
if (draft.lastSequence > event.sequence) return true;
|
||||
const questionIndex = command?.questionIndex ?? prompt?.questionIndex ?? 0;
|
||||
let answerValue =
|
||||
command?.value ??
|
||||
(event.type === "message.received"
|
||||
? (event.message.content.text ?? "")
|
||||
: "");
|
||||
let pollChoice: string | undefined;
|
||||
if (event.type === "poll.changed") {
|
||||
if (!prompt || event.pollMessageGuid !== prompt.pollMessageGuid)
|
||||
return true;
|
||||
if (event.delta.type !== "voted" && event.delta.type !== "unvoted")
|
||||
return true;
|
||||
pollChoice = prompt.options[event.delta.optionIdentifier];
|
||||
if (!pollChoice) return true;
|
||||
if (event.delta.type === "unvoted") {
|
||||
const questionId =
|
||||
interaction.kind === "ask_user_questions"
|
||||
? interaction.payload.questions[questionIndex]?.id
|
||||
: null;
|
||||
await adapter.state.update<PhotonDraft>(draftKey, () => ({
|
||||
...draft,
|
||||
answers: draft.answers.filter(
|
||||
(answer) =>
|
||||
answer.questionId !== questionId ||
|
||||
!answer.optionIds.includes(pollChoice!),
|
||||
),
|
||||
decision: draft.decision === pollChoice ? undefined : draft.decision,
|
||||
lastSequence: event.sequence,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const enqueueResponse = async (key: string, text: string) => {
|
||||
await assertCurrent();
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(chatActions)
|
||||
.values({
|
||||
companyId: endpoint.companyId,
|
||||
endpointId: endpoint.id,
|
||||
conversationId: conversation.id,
|
||||
kind: "photon_response_notice",
|
||||
providerActionId: `photon-notice:${key}`,
|
||||
payload: {
|
||||
reference,
|
||||
questionIndex,
|
||||
publicationId: binding.publicationId,
|
||||
},
|
||||
status: "processed",
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.insert(chatPublications)
|
||||
.values({
|
||||
companyId: endpoint.companyId,
|
||||
endpointId: endpoint.id,
|
||||
conversationId: conversation.id,
|
||||
issueId: conversation.issueId,
|
||||
idempotencyKey: key,
|
||||
payload: {
|
||||
...publication.payload,
|
||||
card: undefined,
|
||||
text: projectSafeChatPublicationText(text),
|
||||
},
|
||||
state: "pending",
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
});
|
||||
scheduleMessageProcessing(() =>
|
||||
processPendingPublications().then(() => undefined),
|
||||
);
|
||||
};
|
||||
const notice = (text: string) =>
|
||||
enqueueResponse(`photon-response:${reference}:${event.sequence}`, text);
|
||||
try {
|
||||
if (interaction.kind === "ask_user_questions") {
|
||||
if (command?.command !== "submit") {
|
||||
let answer: AskUserQuestionsAnswer;
|
||||
if (pollChoice) {
|
||||
const question = interaction.payload.questions[questionIndex];
|
||||
if (
|
||||
!question ||
|
||||
!question.options.some((option) => option.id === pollChoice)
|
||||
)
|
||||
return true;
|
||||
answer = { questionId: question.id, optionIds: [pollChoice] };
|
||||
} else
|
||||
answer = parsePhotonQuestionAnswer(
|
||||
interaction,
|
||||
questionIndex,
|
||||
answerValue,
|
||||
);
|
||||
draft = {
|
||||
...draft,
|
||||
answers: [
|
||||
...draft.answers.filter(
|
||||
(entry) => entry.questionId !== answer.questionId,
|
||||
),
|
||||
answer,
|
||||
],
|
||||
lastSequence: event.sequence,
|
||||
};
|
||||
}
|
||||
const answers = interaction.payload.questions.flatMap((question) =>
|
||||
draft.answers.filter((answer) => answer.questionId === question.id),
|
||||
);
|
||||
if (
|
||||
interaction.payload.questions.length > 1 &&
|
||||
command?.command !== "submit"
|
||||
) {
|
||||
await adapter.state.update<PhotonDraft>(draftKey, () => draft);
|
||||
const next = interaction.payload.questions.findIndex(
|
||||
(question) =>
|
||||
!answers.some((answer) => answer.questionId === question.id),
|
||||
);
|
||||
if (next >= 0)
|
||||
await enqueueResponse(
|
||||
`photon-question:${reference}:${next}`,
|
||||
"Input needed",
|
||||
);
|
||||
else
|
||||
await notice(
|
||||
`Your answers are saved. Send /submit ${reference} to submit them.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
await validateNativeQuestionResponseInput(interaction, { answers });
|
||||
// Canonical service rechecks required fields, options, audience, and
|
||||
// pending -> answered atomically with the response-delivery receipt.
|
||||
const issue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, endpoint.companyId),
|
||||
eq(issues.id, conversation.issueId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]);
|
||||
if (!issue) return true;
|
||||
const answered = await issueThreadInteractionService(
|
||||
db,
|
||||
).answerQuestions(
|
||||
issue,
|
||||
interaction.id,
|
||||
{ answers },
|
||||
{ userId: principal.userId },
|
||||
{
|
||||
beforeResolveInTransaction: async (tx) => {
|
||||
await requireCurrentExternalActionAuthorization(tx, {
|
||||
conversationId: conversation.id,
|
||||
endpointId: endpoint.id,
|
||||
expectedUserId: principal.userId!,
|
||||
principalId: principal.principal.id,
|
||||
runtimeContext: context,
|
||||
});
|
||||
const current = await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(eq(chatConversations.id, conversation.id))
|
||||
.then((rows) => rows[0]);
|
||||
if (
|
||||
current?.sessionGeneration !== binding.sessionGeneration ||
|
||||
Date.parse(binding.expiresAt) <= Date.now()
|
||||
)
|
||||
throw forbidden(
|
||||
"Photon prompt expired or its task generation changed",
|
||||
);
|
||||
},
|
||||
afterResolveInTransaction: async (tx, resolved) => {
|
||||
const claimed = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
principalId: principal.principal.id,
|
||||
status: "processed",
|
||||
result: {
|
||||
code: "photon_question_answered",
|
||||
interactionId: resolved.id,
|
||||
interactionStatus: resolved.status,
|
||||
answersSha256: nativeSha256(
|
||||
(resolved as AskUserQuestionsInteraction).result?.answers,
|
||||
),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "issued"),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!claimed.length)
|
||||
throw forbidden("Photon prompt has already been resolved");
|
||||
await logActivity(tx as unknown as Db, {
|
||||
companyId: endpoint.companyId,
|
||||
actorType: "user",
|
||||
actorId: principal.userId!,
|
||||
action: "issue.thread_interaction_answered",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
source: "external_chat",
|
||||
provider: endpoint.provider,
|
||||
endpointId: endpoint.id,
|
||||
conversationId: conversation.id,
|
||||
interactionId: resolved.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
scheduleMessageProcessing(async () => {
|
||||
await questionResponses.deliver(answered.id);
|
||||
await processPendingPublications();
|
||||
});
|
||||
} else {
|
||||
if (command?.command === "submit") return true;
|
||||
const matched = /^(accept|reject|1|2)(?:\s+([\s\S]+))?$/i.exec(
|
||||
answerValue.trim(),
|
||||
);
|
||||
const decision =
|
||||
pollChoice === "accept" || pollChoice === "reject"
|
||||
? pollChoice
|
||||
: matched
|
||||
? /^(accept|1)$/i.test(matched[1])
|
||||
? "accept"
|
||||
: "reject"
|
||||
: draft.decision;
|
||||
if (!decision)
|
||||
throw new PhotonAnswerValidationError(
|
||||
`Send /answer ${reference} Accept or /answer ${reference} Reject <reason>.`,
|
||||
);
|
||||
const reason =
|
||||
matched?.[2]?.trim() ??
|
||||
(draft.decision === "reject" && !pollChoice
|
||||
? answerValue.trim()
|
||||
: "");
|
||||
if (
|
||||
decision === "reject" &&
|
||||
interaction.payload.rejectRequiresReason &&
|
||||
!reason
|
||||
) {
|
||||
await adapter.state.update<PhotonDraft>(draftKey, () => ({
|
||||
...draft,
|
||||
decision,
|
||||
lastSequence: event.sequence,
|
||||
}));
|
||||
await notice(
|
||||
`Give a rejection reason with /answer ${reference} Reject <reason>.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const issue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, endpoint.companyId),
|
||||
eq(issues.id, conversation.issueId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]);
|
||||
if (!issue) return true;
|
||||
const mutation = {
|
||||
beforeResolveInTransaction: async (tx: DbTransaction) => {
|
||||
await requireCurrentExternalActionAuthorization(tx, {
|
||||
conversationId: conversation.id,
|
||||
endpointId: endpoint.id,
|
||||
expectedUserId: principal.userId!,
|
||||
principalId: principal.principal.id,
|
||||
runtimeContext: context,
|
||||
});
|
||||
const current = await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(eq(chatConversations.id, conversation.id))
|
||||
.then((rows) => rows[0]);
|
||||
if (
|
||||
current?.sessionGeneration !== binding.sessionGeneration ||
|
||||
Date.parse(binding.expiresAt) <= Date.now()
|
||||
)
|
||||
throw forbidden(
|
||||
"Photon prompt expired or its task generation changed",
|
||||
);
|
||||
},
|
||||
afterResolveInTransaction: async (
|
||||
tx: DbTransaction,
|
||||
resolved: IssueThreadInteraction,
|
||||
) => {
|
||||
const claimed = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
principalId: principal.principal.id,
|
||||
status: "processed",
|
||||
result: {
|
||||
code: "photon_confirmation_resolved",
|
||||
interactionId: resolved.id,
|
||||
interactionStatus: resolved.status,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "issued"),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!claimed.length)
|
||||
throw forbidden("Photon prompt has already been resolved");
|
||||
await logActivity(tx as unknown as Db, {
|
||||
companyId: endpoint.companyId,
|
||||
actorType: "user",
|
||||
actorId: principal.userId!,
|
||||
action:
|
||||
decision === "accept"
|
||||
? "issue.thread_interaction_accepted"
|
||||
: "issue.thread_interaction_rejected",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
source: "external_chat",
|
||||
provider: endpoint.provider,
|
||||
endpointId: endpoint.id,
|
||||
conversationId: conversation.id,
|
||||
interactionId: resolved.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
if (decision === "accept")
|
||||
await issueThreadInteractionService(db).acceptInteraction(
|
||||
issue,
|
||||
interaction.id,
|
||||
{},
|
||||
{ userId: principal.userId },
|
||||
mutation,
|
||||
);
|
||||
else
|
||||
await issueThreadInteractionService(db).rejectInteraction(
|
||||
issue,
|
||||
interaction.id,
|
||||
{ reason },
|
||||
{ userId: principal.userId },
|
||||
mutation,
|
||||
);
|
||||
scheduleMessageProcessing(async () => {
|
||||
await processPendingPublications();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PhotonError) throw error;
|
||||
if (isExternalActionAuthorizationChange(error)) return true;
|
||||
if (error instanceof HttpError && [403, 404, 409].includes(error.status))
|
||||
return true;
|
||||
// Only validator errors become correction copy. Infrastructure errors
|
||||
// leave the checkpoint unchanged and retry through durable recovery.
|
||||
if (
|
||||
(error instanceof HttpError && error.status === 422) ||
|
||||
error instanceof PhotonAnswerValidationError
|
||||
) {
|
||||
const missingIndex = interaction.kind === "ask_user_questions" && command?.command === "submit"
|
||||
? interaction.payload.questions.findIndex((question) =>
|
||||
question.required !== false && !draft.answers.some((answer) => answer.questionId === question.id))
|
||||
: -1;
|
||||
await notice(
|
||||
`${redactError(error)} Send /answer ${reference}.${(missingIndex >= 0 ? missingIndex : questionIndex) + 1} <answer> to correct it.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handlePhotonEvent(
|
||||
endpointId: string,
|
||||
event: PhotonEvent,
|
||||
context: RuntimeContext,
|
||||
) {
|
||||
const record = await runtimeCallbackRecord(endpointId, context, [
|
||||
"verifying",
|
||||
"active",
|
||||
"attention",
|
||||
]);
|
||||
if (!record)
|
||||
throw new Error("Photon receiver no longer owns this endpoint");
|
||||
const adapter = context.endpointRuntime!.getProviderAdapter();
|
||||
if (!(adapter instanceof PhotonChatAdapter))
|
||||
throw new Error("Photon adapter unavailable");
|
||||
if (adapter.authentication.identity.allocation === "shared" && event.type === "group.changed") return;
|
||||
if (
|
||||
event.type === "group.changed" &&
|
||||
(event.change.type === "participantRemoved" ||
|
||||
event.change.type === "participantLeft") &&
|
||||
event.change.participant.address === record.endpoint.botExternalId
|
||||
) {
|
||||
const removedThreadId = photonThreadId({
|
||||
lineId: adapter.authentication.identity.lineId,
|
||||
chatGuid: event.chatGuid,
|
||||
isGroup: true,
|
||||
});
|
||||
await db.transaction(async (tx) => {
|
||||
if (
|
||||
!(await runtimeCallbackEndpoint(tx, endpointId, context, [
|
||||
"verifying",
|
||||
"active",
|
||||
]))
|
||||
)
|
||||
throw new Error("Photon receiver is no longer current");
|
||||
if (
|
||||
(
|
||||
await renewDiscordGatewayOwnershipForMessageAdmission(
|
||||
tx,
|
||||
endpointId,
|
||||
context,
|
||||
)
|
||||
).kind !== "owned"
|
||||
)
|
||||
throw new Error("Photon receiver lease was replaced");
|
||||
// Removal can be the first retained event after activation. Preserve a
|
||||
// tombstone even when the group has never been discovered locally.
|
||||
await tx
|
||||
.insert(chatEndpointResources)
|
||||
.values({
|
||||
companyId: record.endpoint.companyId,
|
||||
endpointId,
|
||||
providerResourceId: removedThreadId,
|
||||
type: "group_chat",
|
||||
label: "Unavailable iMessage group",
|
||||
enabled: false,
|
||||
availability: "unavailable",
|
||||
metadata: { photonRemoved: true },
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
chatEndpointResources.endpointId,
|
||||
chatEndpointResources.type,
|
||||
chatEndpointResources.providerResourceId,
|
||||
],
|
||||
set: {
|
||||
availability: "unavailable",
|
||||
metadata: sql`jsonb_set(${chatEndpointResources.metadata}, '{photonRemoved}', 'true'::jsonb)`,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
await adapter.endTyping(removedThreadId);
|
||||
return;
|
||||
}
|
||||
const groupIdentity = photonThreadId({ lineId: adapter.authentication.identity.lineId, chatGuid: event.chatGuid, isGroup: true });
|
||||
const removedGroup = await db.select({ metadata: chatEndpointResources.metadata }).from(chatEndpointResources).where(and(eq(chatEndpointResources.companyId, record.endpoint.companyId), eq(chatEndpointResources.endpointId, endpointId), eq(chatEndpointResources.providerResourceId, groupIdentity))).limit(1).then((rows) => rows[0]);
|
||||
const readded = event.type === "group.changed" && event.change.type === "participantAdded" && event.change.participant.address === record.endpoint.botExternalId;
|
||||
// Late events from a removed chat must still advance recovery without a
|
||||
// now-forbidden chat lookup holding every other conversation behind them.
|
||||
if (removedGroup?.metadata.photonRemoved === true && !readded) return;
|
||||
const chat = await adapter.client.chats
|
||||
.get(event.chatGuid)
|
||||
.catch((error) => {
|
||||
throw photonFailure(error);
|
||||
});
|
||||
if (chat.guid !== event.chatGuid || chat.service !== "iMessage" || (chat.isGroup && adapter.authentication.identity.allocation === "shared")) return;
|
||||
const threadId = photonThreadId({
|
||||
lineId: adapter.authentication.identity.lineId,
|
||||
chatGuid: chat.guid,
|
||||
isGroup: chat.isGroup,
|
||||
});
|
||||
const removed =
|
||||
event.type === "group.changed" &&
|
||||
(event.change.type === "participantRemoved" ||
|
||||
event.change.type === "participantLeft") &&
|
||||
event.change.participant.address === record.endpoint.botExternalId;
|
||||
const available = await db.transaction(async (tx) => {
|
||||
if (
|
||||
!(await runtimeCallbackEndpoint(tx, endpointId, context, [
|
||||
"verifying",
|
||||
"active",
|
||||
]))
|
||||
)
|
||||
throw new Error("Photon receiver is no longer current");
|
||||
const ownership = await renewDiscordGatewayOwnershipForMessageAdmission(
|
||||
tx,
|
||||
endpointId,
|
||||
context,
|
||||
);
|
||||
if (ownership.kind !== "owned")
|
||||
throw new Error("Photon receiver lease was replaced");
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
eq(chatEndpointResources.providerResourceId, threadId),
|
||||
eq(
|
||||
chatEndpointResources.type,
|
||||
chat.isGroup ? "group_chat" : "direct_message",
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]);
|
||||
const added =
|
||||
event.type === "group.changed" &&
|
||||
event.change.type === "participantAdded" &&
|
||||
event.change.participant.address === record.endpoint.botExternalId;
|
||||
const stillRemoved =
|
||||
removed || (existing?.metadata.photonRemoved === true && !added);
|
||||
const availability =
|
||||
stillRemoved || chat.isArchived ? "unavailable" : "available";
|
||||
const metadata = {
|
||||
photonRemoved: stillRemoved,
|
||||
participants: chat.participants
|
||||
.slice(0, 256)
|
||||
.map((p) => ({ address: p.address, service: p.service })),
|
||||
};
|
||||
const label = (
|
||||
chat.displayName || chat.participants.map((p) => p.address).join(", ")
|
||||
).slice(0, 512);
|
||||
await tx
|
||||
.insert(chatEndpointResources)
|
||||
.values({
|
||||
companyId: record.endpoint.companyId,
|
||||
endpointId,
|
||||
providerResourceId: threadId,
|
||||
type: chat.isGroup ? "group_chat" : "direct_message",
|
||||
label,
|
||||
enabled: !chat.isGroup,
|
||||
availability,
|
||||
metadata,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
chatEndpointResources.endpointId,
|
||||
chatEndpointResources.type,
|
||||
chatEndpointResources.providerResourceId,
|
||||
],
|
||||
set: { label, availability, metadata, updatedAt: new Date() },
|
||||
});
|
||||
return availability === "available";
|
||||
});
|
||||
if (!available) return;
|
||||
if (
|
||||
!event.isFromMe &&
|
||||
(await processPhotonResponse(
|
||||
record.endpoint,
|
||||
event,
|
||||
threadId,
|
||||
adapter,
|
||||
context,
|
||||
))
|
||||
)
|
||||
return;
|
||||
if (event.type === "message.received" && !event.isFromMe) {
|
||||
await handleSdkMessage(
|
||||
{
|
||||
provider: "imessage-photon",
|
||||
endpointId,
|
||||
thread: context.endpointRuntime!.thread(threadId),
|
||||
message: adapter.normalize(event.message, chat),
|
||||
trigger: chat.isGroup ? "unaddressed_message" : "direct_message",
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { handlePhotonFailure, handlePhotonEvent };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,1000 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js";
|
||||
|
||||
import { notifyChatPublicationWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
|
||||
|
||||
import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
|
||||
|
||||
import {
|
||||
randomUUID,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
lte,
|
||||
notExists,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
chatMessageLinks,
|
||||
chatPublications,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
isUuidLike,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
type SlackFileUploadAcceptedReceipt,
|
||||
} from "../../chat-sdk-runtime.js";
|
||||
|
||||
import {
|
||||
stageSlackSessionSync,
|
||||
} from "../../chat-slack-session-outbox.js";
|
||||
|
||||
import { classifyChatPublicationError } from "../../chat-publication-errors.js";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
type SlackFileUploadReceiptPayload = {
|
||||
version: 1;
|
||||
publicationId: string;
|
||||
publicationAttempt: number;
|
||||
threadId: string;
|
||||
fileIds: string[];
|
||||
botExternalId: string;
|
||||
runtimeGeneration: number;
|
||||
credentialFingerprint: string;
|
||||
};
|
||||
|
||||
function slackFileUploadReceiptPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): SlackFileUploadReceiptPayload | null {
|
||||
if (
|
||||
payload.version !== 1 ||
|
||||
typeof payload.publicationId !== "string" ||
|
||||
!isUuidLike(payload.publicationId) ||
|
||||
typeof payload.publicationAttempt !== "number" ||
|
||||
!Number.isSafeInteger(payload.publicationAttempt) ||
|
||||
payload.publicationAttempt < 1 ||
|
||||
typeof payload.threadId !== "string" ||
|
||||
payload.threadId.length < 1 ||
|
||||
payload.threadId.length > 2_048 ||
|
||||
!payload.threadId.startsWith("slack:") ||
|
||||
typeof payload.botExternalId !== "string" ||
|
||||
payload.botExternalId.length < 1 ||
|
||||
payload.botExternalId.length > 255 ||
|
||||
typeof payload.runtimeGeneration !== "number" ||
|
||||
!Number.isSafeInteger(payload.runtimeGeneration) ||
|
||||
payload.runtimeGeneration < 0 ||
|
||||
typeof payload.credentialFingerprint !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(payload.credentialFingerprint) ||
|
||||
!Array.isArray(payload.fileIds) ||
|
||||
payload.fileIds.length < 1 ||
|
||||
payload.fileIds.length > 20 ||
|
||||
payload.fileIds.some(
|
||||
(fileId) =>
|
||||
typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId),
|
||||
) ||
|
||||
new Set(payload.fileIds).size !== payload.fileIds.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return payload as SlackFileUploadReceiptPayload;
|
||||
}
|
||||
|
||||
function operatorConfirmedSlackFileReceipt(
|
||||
result: Record<string, unknown> | null,
|
||||
payload: SlackFileUploadReceiptPayload,
|
||||
): boolean {
|
||||
return (
|
||||
result?.operatorConfirmedPublicationId === payload.publicationId &&
|
||||
result.operatorConfirmedAttempt === payload.publicationAttempt &&
|
||||
typeof result.operatorConfirmedAt === "string" &&
|
||||
Number.isFinite(Date.parse(result.operatorConfirmedAt))
|
||||
);
|
||||
}
|
||||
|
||||
function sameSlackFileUploadReceipt(
|
||||
left: SlackFileUploadReceiptPayload,
|
||||
right: SlackFileUploadReceiptPayload,
|
||||
): boolean {
|
||||
return (
|
||||
left.publicationId === right.publicationId &&
|
||||
left.publicationAttempt === right.publicationAttempt &&
|
||||
left.threadId === right.threadId &&
|
||||
left.botExternalId === right.botExternalId &&
|
||||
left.runtimeGeneration === right.runtimeGeneration &&
|
||||
left.credentialFingerprint === right.credentialFingerprint &&
|
||||
left.fileIds.length === right.fileIds.length &&
|
||||
left.fileIds.every((fileId, index) => fileId === right.fileIds[index])
|
||||
);
|
||||
}
|
||||
const SLACK_FILE_RECEIPT_MAX_AGE_MS = 24 * 60 * 60_000;
|
||||
const SLACK_FILE_RECEIPT_MAX_ATTEMPTS = 12;
|
||||
const SLACK_FILE_RECEIPT_STALE_MS = 60_000;
|
||||
type ConversationRow = typeof chatConversations.$inferSelect;
|
||||
type SlackFileReceiptsOptions = {
|
||||
endpointRecord: (id: string) => Promise<EndpointRecord | null>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
isExplicitOperatorPublication: (publication: typeof chatPublications.$inferSelect) => boolean;
|
||||
recordExactOutboundPublicationLink: (tx: DbOrTransaction, input: { publication: typeof chatPublications.$inferSelect; providerMessageId: string }) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function createSlackFileReceipts(db: Db, options: SlackFileReceiptsOptions) {
|
||||
const { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runtimeFor, isExplicitOperatorPublication, recordExactOutboundPublicationLink } = options;
|
||||
|
||||
|
||||
async function recordSlackFileUploadReceipt(input: {
|
||||
credentialLease: CredentialMutationLeaseGuard;
|
||||
conversation: ConversationRow;
|
||||
endpoint: EndpointRow;
|
||||
publication: typeof chatPublications.$inferSelect;
|
||||
receipt: SlackFileUploadAcceptedReceipt;
|
||||
runtimeContext: LifecycleRuntimeFence;
|
||||
}): Promise<string> {
|
||||
const publicationAttempt = input.publication.attempts + 1;
|
||||
const botExternalId = input.endpoint.botExternalId;
|
||||
const expectedThreadId = `slack:${input.receipt.channelId}:${input.receipt.threadTs ?? ""}`;
|
||||
if (
|
||||
input.endpoint.provider !== "slack" ||
|
||||
!botExternalId ||
|
||||
input.receipt.version !== 1 ||
|
||||
expectedThreadId !== input.conversation.externalThreadId ||
|
||||
input.receipt.fileIds.length !==
|
||||
(input.publication.payload.attachmentIds?.length ?? 0)
|
||||
) {
|
||||
throw new Error(
|
||||
"Slack file upload receipt did not match its publication",
|
||||
);
|
||||
}
|
||||
const payload: SlackFileUploadReceiptPayload = {
|
||||
version: 1,
|
||||
publicationId: input.publication.id,
|
||||
publicationAttempt,
|
||||
threadId: expectedThreadId,
|
||||
fileIds: [...input.receipt.fileIds],
|
||||
botExternalId,
|
||||
runtimeGeneration: input.runtimeContext.generation,
|
||||
credentialFingerprint: input.runtimeContext.credentialFingerprint,
|
||||
};
|
||||
if (!slackFileUploadReceiptPayload(payload)) {
|
||||
throw new Error("Slack file upload receipt was malformed");
|
||||
}
|
||||
const providerActionId = `slack-file-receipt:${input.publication.id}:${publicationAttempt}`;
|
||||
const recordedAt = new Date();
|
||||
return db.transaction(async (tx) => {
|
||||
await input.credentialLease.assertOwned(tx);
|
||||
const publication = await tx
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.id, input.publication.id),
|
||||
eq(chatPublications.companyId, input.publication.companyId),
|
||||
eq(chatPublications.endpointId, input.publication.endpointId),
|
||||
eq(
|
||||
chatPublications.conversationId,
|
||||
input.publication.conversationId,
|
||||
),
|
||||
eq(chatPublications.state, "streaming"),
|
||||
eq(chatPublications.attempts, publicationAttempt),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const endpoint = publication
|
||||
? await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
input.endpoint.id,
|
||||
input.runtimeContext,
|
||||
["verifying", "active"],
|
||||
)
|
||||
: null;
|
||||
const conversation = endpoint
|
||||
? await tx
|
||||
.select({
|
||||
externalThreadId: chatConversations.externalThreadId,
|
||||
id: chatConversations.id,
|
||||
})
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.id, input.conversation.id),
|
||||
eq(chatConversations.companyId, input.publication.companyId),
|
||||
eq(chatConversations.endpointId, input.endpoint.id),
|
||||
inArray(chatConversations.state, [
|
||||
"active",
|
||||
"waiting",
|
||||
"completed",
|
||||
]),
|
||||
),
|
||||
)
|
||||
.for("no key update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
if (
|
||||
!publication ||
|
||||
endpoint?.provider !== "slack" ||
|
||||
endpoint.botExternalId !== botExternalId ||
|
||||
conversation?.externalThreadId !== expectedThreadId
|
||||
) {
|
||||
throw new Error(
|
||||
"Slack file upload ownership changed before receipt persistence",
|
||||
);
|
||||
}
|
||||
await notifyChatPublicationWork(tx);
|
||||
const inserted = await tx
|
||||
.insert(chatActions)
|
||||
.values({
|
||||
companyId: input.publication.companyId,
|
||||
endpointId: input.publication.endpointId,
|
||||
conversationId: input.publication.conversationId,
|
||||
kind: "slack_file_upload_receipt",
|
||||
providerActionId,
|
||||
payload,
|
||||
status: "received",
|
||||
result: { code: "slack_file_upload_identity_pending", attempts: 0 },
|
||||
createdAt: recordedAt,
|
||||
updatedAt: recordedAt,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: chatActions.id });
|
||||
if (inserted[0]) {
|
||||
await input.credentialLease.assertOwned(tx);
|
||||
return inserted[0].id;
|
||||
}
|
||||
const existing = await tx
|
||||
.select({ id: chatActions.id })
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, input.publication.endpointId),
|
||||
eq(chatActions.providerActionId, providerActionId),
|
||||
eq(chatActions.kind, "slack_file_upload_receipt"),
|
||||
sql`${chatActions.payload} = ${JSON.stringify(payload)}::jsonb`,
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) {
|
||||
throw new Error(
|
||||
"Slack file upload receipt conflicted with durable state",
|
||||
);
|
||||
}
|
||||
await input.credentialLease.assertOwned(tx);
|
||||
return existing.id;
|
||||
});
|
||||
}
|
||||
|
||||
async function stageSlackFilePublicationSuccess(
|
||||
tx: DbTransaction,
|
||||
input: {
|
||||
committedAt: Date;
|
||||
endpoint: EndpointRow;
|
||||
publication: typeof chatPublications.$inferSelect;
|
||||
providerMessageId: string;
|
||||
runtimeContext: LifecycleRuntimeFence;
|
||||
stageSessionSync: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (
|
||||
!(await recordExactOutboundPublicationLink(tx, {
|
||||
publication: input.publication,
|
||||
providerMessageId: input.providerMessageId,
|
||||
}))
|
||||
) {
|
||||
throw new Error(
|
||||
"Slack file upload message identity conflicts with another publication",
|
||||
);
|
||||
}
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
lastPublicationAt: input.committedAt,
|
||||
updatedAt: input.committedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpoints.id, input.endpoint.id),
|
||||
inArray(chatEndpoints.status, ["verifying", "active"]),
|
||||
sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${input.runtimeContext.generation}`,
|
||||
),
|
||||
);
|
||||
if (input.stageSessionSync) {
|
||||
await stageSlackSessionSync(tx, {
|
||||
companyId: input.publication.companyId,
|
||||
endpointId: input.publication.endpointId,
|
||||
conversationId: input.publication.conversationId,
|
||||
runtimeGeneration: input.runtimeContext.generation,
|
||||
credentialFingerprint: input.runtimeContext.credentialFingerprint,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function lockSlackFileReceiptAuthorization(
|
||||
tx: DbTransaction,
|
||||
input: {
|
||||
actionId: string;
|
||||
payload: SlackFileUploadReceiptPayload;
|
||||
},
|
||||
): Promise<
|
||||
| {
|
||||
action: typeof chatActions.$inferSelect;
|
||||
conversation: ConversationRow;
|
||||
endpoint: EndpointRow;
|
||||
mode: "delivery_unknown" | "operator_confirmed";
|
||||
publication: typeof chatPublications.$inferSelect;
|
||||
}
|
||||
| { action: typeof chatActions.$inferSelect; mode: "defer" | "settled" }
|
||||
| null
|
||||
> {
|
||||
const publication = await tx
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.id, input.payload.publicationId),
|
||||
eq(chatPublications.attempts, input.payload.publicationAttempt),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const action = publication
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, input.actionId),
|
||||
eq(chatActions.companyId, publication.companyId),
|
||||
eq(chatActions.endpointId, publication.endpointId),
|
||||
eq(chatActions.conversationId, publication.conversationId),
|
||||
eq(chatActions.kind, "slack_file_upload_receipt"),
|
||||
eq(
|
||||
chatActions.providerActionId,
|
||||
`slack-file-receipt:${publication.id}:${publication.attempts}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const currentPayload = action
|
||||
? slackFileUploadReceiptPayload(action.payload)
|
||||
: null;
|
||||
if (
|
||||
!publication ||
|
||||
!action ||
|
||||
!publication.payload.attachmentIds?.length ||
|
||||
publication.payload.card ||
|
||||
publication.payload.interactionId ||
|
||||
publication.idempotencyKey.startsWith("control:") ||
|
||||
!currentPayload ||
|
||||
!sameSlackFileUploadReceipt(currentPayload, input.payload)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (publication.state === "streaming") {
|
||||
return { action, mode: "defer" };
|
||||
}
|
||||
if (publication.state === "published" && publication.providerMessageId) {
|
||||
const exactLink = await tx
|
||||
.select({ id: chatMessageLinks.id })
|
||||
.from(chatMessageLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, publication.companyId),
|
||||
eq(chatMessageLinks.endpointId, publication.endpointId),
|
||||
eq(chatMessageLinks.conversationId, publication.conversationId),
|
||||
eq(chatMessageLinks.publicationId, publication.id),
|
||||
publication.commentId
|
||||
? eq(chatMessageLinks.commentId, publication.commentId)
|
||||
: isNull(chatMessageLinks.commentId),
|
||||
eq(
|
||||
chatMessageLinks.providerMessageId,
|
||||
publication.providerMessageId,
|
||||
),
|
||||
eq(chatMessageLinks.direction, "outbound"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return exactLink ? { action, mode: "settled" } : null;
|
||||
}
|
||||
const mode =
|
||||
publication.state === "delivery_unknown"
|
||||
? "delivery_unknown"
|
||||
: publication.state === "published" &&
|
||||
!publication.providerMessageId &&
|
||||
operatorConfirmedSlackFileReceipt(action.result, input.payload)
|
||||
? "operator_confirmed"
|
||||
: null;
|
||||
if (!mode) return null;
|
||||
const endpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
publication.endpointId,
|
||||
{
|
||||
generation: input.payload.runtimeGeneration,
|
||||
credentialFingerprint: input.payload.credentialFingerprint,
|
||||
},
|
||||
["verifying", "active"],
|
||||
);
|
||||
const conversation = endpoint
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.id, publication.conversationId),
|
||||
eq(chatConversations.companyId, publication.companyId),
|
||||
eq(chatConversations.endpointId, publication.endpointId),
|
||||
eq(chatConversations.externalThreadId, input.payload.threadId),
|
||||
inArray(chatConversations.state, [
|
||||
"active",
|
||||
"waiting",
|
||||
"completed",
|
||||
]),
|
||||
),
|
||||
)
|
||||
.for("no key update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const resource =
|
||||
endpoint && conversation?.resourceId && !conversation.isDirectMessage
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.id, conversation.resourceId),
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
),
|
||||
)
|
||||
.for("no key update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
if (
|
||||
endpoint?.provider !== "slack" ||
|
||||
endpoint.botExternalId !== input.payload.botExternalId ||
|
||||
!conversation ||
|
||||
!(conversation.isDirectMessage
|
||||
? endpoint.allowDirectMessages
|
||||
: nonDirectDestinationAllowed(endpoint, resource))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { action, conversation, endpoint, mode, publication };
|
||||
}
|
||||
|
||||
async function processPendingSlackFileUploadReceipts(limit = 25) {
|
||||
const selectedAt = new Date();
|
||||
const actions = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
slackReceiptEligibility(selectedAt),
|
||||
)
|
||||
.orderBy(asc(chatActions.updatedAt), asc(chatActions.id))
|
||||
.limit(limit);
|
||||
let processed = 0;
|
||||
for (const selected of actions) {
|
||||
const payload = slackFileUploadReceiptPayload(selected.payload);
|
||||
if (!payload) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatPublicationWork(tx);
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: {
|
||||
code: "slack_file_upload_receipt_payload_invalid",
|
||||
retryable: false,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, selected.id),
|
||||
eq(chatActions.kind, selected.kind),
|
||||
eq(chatActions.status, selected.status),
|
||||
sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`,
|
||||
selected.result === null
|
||||
? isNull(chatActions.result)
|
||||
: sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`,
|
||||
),
|
||||
);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const record = await endpointRecord(selected.endpointId);
|
||||
if (!record) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatPublicationWork(tx);
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: { code: "slack_file_upload_receipt_endpoint_removed" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, selected.id),
|
||||
eq(chatActions.kind, selected.kind),
|
||||
eq(chatActions.status, selected.status),
|
||||
sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`,
|
||||
selected.result === null
|
||||
? isNull(chatActions.result)
|
||||
: sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`,
|
||||
),
|
||||
);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (["paused", "attention"].includes(record.endpoint.status)) continue;
|
||||
const ownerToken = randomUUID();
|
||||
let attempt = 1;
|
||||
try {
|
||||
await withCredentialMutationLease(record.endpoint, async (lease) => {
|
||||
const claim = await db.transaction(async (tx) => {
|
||||
await lease.assertOwned(tx);
|
||||
const locked = await lockSlackFileReceiptAuthorization(tx, {
|
||||
actionId: selected.id,
|
||||
payload,
|
||||
});
|
||||
const decisionAt = new Date();
|
||||
const priorAttempts =
|
||||
typeof locked?.action.result?.attempts === "number" &&
|
||||
Number.isSafeInteger(locked.action.result.attempts)
|
||||
? locked.action.result.attempts
|
||||
: 0;
|
||||
attempt = priorAttempts + 1;
|
||||
const retryAt =
|
||||
typeof locked?.action.result?.retryAt === "string"
|
||||
? Date.parse(locked.action.result.retryAt)
|
||||
: Number.NaN;
|
||||
const eligible = locked
|
||||
? locked.action.status === "received" ||
|
||||
(locked.action.status === "failed" &&
|
||||
locked.action.result?.retryable === true &&
|
||||
Number.isFinite(retryAt) &&
|
||||
retryAt <= decisionAt.getTime()) ||
|
||||
(locked.action.status === "processing" &&
|
||||
locked.action.updatedAt.getTime() <=
|
||||
decisionAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS)
|
||||
: false;
|
||||
if (locked && !eligible) return null;
|
||||
if (locked?.mode === "defer") {
|
||||
if (locked.action.status === "processing") {
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "received",
|
||||
result: {
|
||||
code: "slack_file_upload_identity_pending",
|
||||
attempts: priorAttempts,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatActions.id, locked.action.id));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (locked?.mode === "settled") {
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processed",
|
||||
result: {
|
||||
code: "slack_file_upload_identity_confirmed",
|
||||
attempts: priorAttempts,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatActions.id, locked.action.id));
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!locked ||
|
||||
decisionAt.getTime() -
|
||||
(locked?.action.createdAt ?? selected.createdAt).getTime() >
|
||||
SLACK_FILE_RECEIPT_MAX_AGE_MS ||
|
||||
attempt > SLACK_FILE_RECEIPT_MAX_ATTEMPTS
|
||||
) {
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
code: locked
|
||||
? "slack_file_upload_receipt_expired"
|
||||
: "slack_file_upload_receipt_authorization_changed",
|
||||
attempts: priorAttempts,
|
||||
},
|
||||
updatedAt: decisionAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, selected.id),
|
||||
inArray(chatActions.status, [
|
||||
"received",
|
||||
"failed",
|
||||
"processing",
|
||||
]),
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!["received", "failed", "processing"].includes(
|
||||
locked.action.status,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
await notifyChatPublicationWork(tx);
|
||||
const [claimed] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processing",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
ownerToken,
|
||||
...(locked.mode === "operator_confirmed"
|
||||
? {
|
||||
operatorConfirmedPublicationId:
|
||||
locked.action.result?.operatorConfirmedPublicationId,
|
||||
operatorConfirmedAttempt:
|
||||
locked.action.result?.operatorConfirmedAttempt,
|
||||
operatorConfirmedAt:
|
||||
locked.action.result?.operatorConfirmedAt,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
updatedAt: decisionAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, locked.action.id),
|
||||
eq(chatActions.status, locked.action.status),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
await lease.assertOwned(tx);
|
||||
return claimed ? locked : null;
|
||||
});
|
||||
if (!claim || !("endpoint" in claim)) return;
|
||||
processed += 1;
|
||||
const endpointRuntime = await runtimeFor(claim.endpoint);
|
||||
await lease.assertOwned();
|
||||
const providerMessageId =
|
||||
await endpointRuntime.resolveSlackFileUploadReceipt(
|
||||
payload.threadId,
|
||||
payload.fileIds,
|
||||
);
|
||||
if (!providerMessageId) {
|
||||
await db.transaction(async (tx) => {
|
||||
await lease.assertOwned(tx);
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify(
|
||||
{
|
||||
attempts: attempt,
|
||||
code: "slack_file_upload_identity_pending",
|
||||
retryable: true,
|
||||
retryAt: new Date(
|
||||
Date.now() + Math.min(60_000, 1_000 * 2 ** attempt),
|
||||
).toISOString(),
|
||||
},
|
||||
)}::jsonb`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, selected.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
await lease.assertOwned(tx);
|
||||
const locked = await lockSlackFileReceiptAuthorization(tx, {
|
||||
actionId: selected.id,
|
||||
payload,
|
||||
});
|
||||
if (
|
||||
!locked ||
|
||||
locked.mode === "defer" ||
|
||||
locked.mode === "settled" ||
|
||||
!("publication" in locked) ||
|
||||
locked.action.status !== "processing" ||
|
||||
locked.action.result?.ownerToken !== ownerToken
|
||||
) {
|
||||
if (!locked) {
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code: "slack_file_upload_receipt_authorization_changed",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, selected.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`,
|
||||
),
|
||||
);
|
||||
} else if (locked.mode === "defer") {
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "received",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code: "slack_file_upload_identity_pending",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, selected.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status:
|
||||
locked.mode === "settled" ? "processed" : "cancelled",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code:
|
||||
locked.mode === "settled"
|
||||
? "slack_file_upload_identity_confirmed"
|
||||
: "slack_file_upload_receipt_superseded",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, selected.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const committedAt = new Date();
|
||||
if (
|
||||
!(await recordExactOutboundPublicationLink(tx, {
|
||||
publication: locked.publication,
|
||||
providerMessageId,
|
||||
}))
|
||||
) {
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code: "slack_file_upload_receipt_message_conflict",
|
||||
retryable: false,
|
||||
},
|
||||
updatedAt: committedAt,
|
||||
})
|
||||
.where(eq(chatActions.id, selected.id));
|
||||
return;
|
||||
}
|
||||
if (locked.mode === "delivery_unknown") {
|
||||
await notifyChatPublicationWork(tx);
|
||||
const [settled] = await tx
|
||||
.update(chatPublications)
|
||||
.set({
|
||||
state: "published",
|
||||
providerMessageId,
|
||||
publishedAt: committedAt,
|
||||
nextAttemptAt: null,
|
||||
redactedError: null,
|
||||
updatedAt: committedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.id, locked.publication.id),
|
||||
eq(chatPublications.state, "delivery_unknown"),
|
||||
eq(chatPublications.attempts, payload.publicationAttempt),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatPublications.id });
|
||||
if (!settled) {
|
||||
throw new Error(
|
||||
"Slack file upload publication changed before receipt commit",
|
||||
);
|
||||
}
|
||||
await stageSlackFilePublicationSuccess(tx, {
|
||||
committedAt,
|
||||
endpoint: locked.endpoint,
|
||||
publication: locked.publication,
|
||||
providerMessageId,
|
||||
runtimeContext: {
|
||||
generation: payload.runtimeGeneration,
|
||||
credentialFingerprint: payload.credentialFingerprint,
|
||||
},
|
||||
stageSessionSync:
|
||||
!isExplicitOperatorPublication(locked.publication) &&
|
||||
!locked.publication.idempotencyKey.startsWith(
|
||||
"control:status:",
|
||||
),
|
||||
});
|
||||
} else {
|
||||
await notifyChatPublicationWork(tx);
|
||||
const [enriched] = await tx
|
||||
.update(chatPublications)
|
||||
.set({ providerMessageId })
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.id, locked.publication.id),
|
||||
eq(chatPublications.state, "published"),
|
||||
eq(chatPublications.attempts, payload.publicationAttempt),
|
||||
isNull(chatPublications.providerMessageId),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatPublications.id });
|
||||
if (!enriched) {
|
||||
throw new Error(
|
||||
"Slack file upload publication changed before identity enrichment",
|
||||
);
|
||||
}
|
||||
}
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processed",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code: "slack_file_upload_identity_confirmed",
|
||||
},
|
||||
updatedAt: committedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, selected.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`,
|
||||
),
|
||||
);
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
const disposition = classifyChatPublicationError(error, attempt);
|
||||
const retryable =
|
||||
disposition.kind === "retry" ||
|
||||
disposition.kind === "delivery_unknown";
|
||||
const retryMs =
|
||||
disposition.kind === "retry"
|
||||
? disposition.retryAfterMs
|
||||
: Math.min(60_000, 1_000 * 2 ** Math.min(attempt, 8));
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatPublicationWork(tx);
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: retryable ? "failed" : "cancelled",
|
||||
result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify(
|
||||
{
|
||||
attempts: attempt,
|
||||
code: retryable
|
||||
? "slack_file_upload_receipt_lookup_retry"
|
||||
: "slack_file_upload_receipt_lookup_rejected",
|
||||
retryable,
|
||||
...(retryable
|
||||
? { retryAt: new Date(Date.now() + retryMs).toISOString() }
|
||||
: {}),
|
||||
},
|
||||
)}::jsonb`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, selected.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
function slackReceiptEligibility(selectedAt?: Date) {
|
||||
return and(
|
||||
eq(chatActions.kind, "slack_file_upload_receipt"),
|
||||
notExists(
|
||||
db
|
||||
.select({ id: chatEndpoints.id })
|
||||
.from(chatEndpoints)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpoints.id, chatActions.endpointId),
|
||||
inArray(chatEndpoints.status, ["paused", "attention"]),
|
||||
),
|
||||
),
|
||||
),
|
||||
// The normal publication owner is still polling Slack. Receipt
|
||||
// recovery must neither contend with it nor let its not-yet-due
|
||||
// action occupy the bounded recovery page.
|
||||
notExists(
|
||||
db
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
sql`${chatPublications.id}::text = ${chatActions.payload}->>'publicationId'`,
|
||||
sql`${chatPublications.attempts}::text = ${chatActions.payload}->>'publicationAttempt'`,
|
||||
eq(chatPublications.state, "streaming"),
|
||||
),
|
||||
),
|
||||
),
|
||||
or(
|
||||
eq(chatActions.status, "received"),
|
||||
and(
|
||||
eq(chatActions.status, "failed"),
|
||||
sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'true'`,
|
||||
selectedAt ? sql`(${chatActions.result}->>'retryAt')::timestamptz <= ${selectedAt.toISOString()}::timestamptz` : sql`${chatActions.result}->>'retryAt' is not null`,
|
||||
),
|
||||
and(
|
||||
eq(chatActions.status, "processing"),
|
||||
selectedAt ? lte(chatActions.updatedAt, new Date(selectedAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS)) : undefined,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function nextSlackReceiptAt(): Promise<number | null> {
|
||||
const [row] = await db.select({ at: sql<string | null>`min(case
|
||||
when ${chatActions.status} = 'processing' then ${chatActions.updatedAt} + ${SLACK_FILE_RECEIPT_STALE_MS} * interval '1 millisecond'
|
||||
when ${chatActions.status} = 'failed' then (${chatActions.result}->>'retryAt')::timestamptz
|
||||
else now() end)` }).from(chatActions).where(slackReceiptEligibility());
|
||||
return row?.at == null ? null : new Date(row.at).getTime();
|
||||
}
|
||||
return { recordSlackFileUploadReceipt, stageSlackFilePublicationSuccess, processPendingSlackFileUploadReceipts, nextSlackReceiptAt };
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js";
|
||||
|
||||
import { dueChatAction } from "../../chat-action-work.js";
|
||||
|
||||
import { notifyChatActionWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js";
|
||||
|
||||
import {
|
||||
randomUUID,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
inArray,
|
||||
lte,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
chatPublications,
|
||||
heartbeatRuns,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
|
||||
import {
|
||||
setSlackSessionStatus,
|
||||
slackSessionStatusForPublication,
|
||||
} from "../../chat-slack-sessions.js";
|
||||
import {
|
||||
slackSessionSyncPayload,
|
||||
} from "../../chat-slack-session-outbox.js";
|
||||
|
||||
import { classifyChatPublicationError } from "../../chat-publication-errors.js";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
type SlackSessionStatusOptions = {
|
||||
endpointRecord: (id: string) => Promise<EndpointRecord | null>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean;
|
||||
runIdFromMilestonePublication: (publication: typeof chatPublications.$inferSelect) => string | null;
|
||||
resolveCredentials: (endpoint: EndpointRow, database?: DbOrTransaction) => Promise<Record<string, string>>;
|
||||
fetchImpl: typeof globalThis.fetch;
|
||||
redactError: (error: unknown) => string;
|
||||
slackSessionSyncSelectionBarrier?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createSlackSessionStatus(db: Db, options: SlackSessionStatusOptions) {
|
||||
const { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runIdFromMilestonePublication, resolveCredentials, fetchImpl, redactError } = options;
|
||||
|
||||
|
||||
async function processPendingSlackSessionSyncs(
|
||||
limit = 25,
|
||||
onlyActionId?: string,
|
||||
) {
|
||||
const now = new Date();
|
||||
const actions = await db
|
||||
.select({ action: chatActions })
|
||||
.from(chatActions)
|
||||
.leftJoin(chatEndpoints, and(eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.companyId, chatActions.companyId)))
|
||||
.where(and(
|
||||
onlyActionId ? eq(chatActions.id, onlyActionId) : undefined,
|
||||
dueChatAction("slack_session_sync", now.getTime()),
|
||||
))
|
||||
.orderBy(asc(chatActions.updatedAt))
|
||||
.limit(limit)
|
||||
.then(rows => rows.map(row => row.action));
|
||||
await options.slackSessionSyncSelectionBarrier?.();
|
||||
for (const action of actions) {
|
||||
const payload = slackSessionSyncPayload(action.payload);
|
||||
if (!payload || !action.conversationId) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "slack_session_sync");
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: { code: "slack_session_payload_invalid" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.updatedAt, action.updatedAt),
|
||||
),
|
||||
);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const revisionWhere = and(
|
||||
eq(chatActions.id, action.id),
|
||||
sql`(${chatActions.payload}->>'revision')::bigint = ${payload.revision}`,
|
||||
);
|
||||
const selectedSnapshotWhere = and(
|
||||
revisionWhere,
|
||||
eq(chatActions.status, action.status),
|
||||
eq(chatActions.updatedAt, action.updatedAt),
|
||||
);
|
||||
const record = await endpointRecord(action.endpointId);
|
||||
if (record && ["paused", "attention"].includes(record.endpoint.status)) continue;
|
||||
if (!record || record.endpoint.provider !== "slack") {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "slack_session_sync");
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: { code: "slack_session_endpoint_unavailable" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(selectedSnapshotWhere);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const attempts =
|
||||
(typeof action.result?.attempts === "number"
|
||||
? action.result.attempts
|
||||
: 0) + 1;
|
||||
const ownerToken = randomUUID();
|
||||
let ownsAttempt = false;
|
||||
try {
|
||||
await withCredentialMutationLease(record.endpoint, async (lease) => {
|
||||
const claim = await db.transaction(async (tx) => {
|
||||
await lease.assertOwned(tx);
|
||||
const endpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
action.endpointId,
|
||||
{
|
||||
generation: payload.runtimeGeneration,
|
||||
credentialFingerprint: payload.credentialFingerprint,
|
||||
},
|
||||
["active", "verifying"],
|
||||
);
|
||||
const conversation = endpoint
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.id, action.conversationId!),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
inArray(chatConversations.state, [
|
||||
"active",
|
||||
"waiting",
|
||||
"completed",
|
||||
]),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const resource =
|
||||
endpoint && conversation?.resourceId
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.id, conversation.resourceId),
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const superseded = conversation
|
||||
? await tx
|
||||
.select({ id: chatConversations.id })
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, conversation.companyId),
|
||||
eq(chatConversations.endpointId, conversation.endpointId),
|
||||
eq(
|
||||
chatConversations.externalThreadId,
|
||||
conversation.externalThreadId,
|
||||
),
|
||||
gt(
|
||||
chatConversations.sessionGeneration,
|
||||
conversation.sessionGeneration,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0)
|
||||
: false;
|
||||
if (
|
||||
!endpoint ||
|
||||
!conversation ||
|
||||
superseded ||
|
||||
!(conversation.isDirectMessage
|
||||
? endpoint.allowDirectMessages
|
||||
: nonDirectDestinationAllowed(endpoint, resource))
|
||||
) {
|
||||
await notifyChatActionWork(tx, "slack_session_sync");
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: { code: "slack_session_destination_changed" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(selectedSnapshotWhere);
|
||||
return null;
|
||||
}
|
||||
// Recompute at dispatch: a delayed processing retry must not
|
||||
// overwrite a newer final response or a later question card.
|
||||
const publication = await tx
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, endpoint.companyId),
|
||||
eq(chatPublications.endpointId, endpoint.id),
|
||||
eq(chatPublications.conversationId, conversation.id),
|
||||
eq(chatPublications.state, "published"),
|
||||
sql`${chatPublications.idempotencyKey} not like 'explicit%'`,
|
||||
sql`${chatPublications.idempotencyKey} not like 'control:status:%'`,
|
||||
sql`${chatPublications.idempotencyKey} not like 'wake:%'`,
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
desc(chatPublications.publishedAt),
|
||||
desc(chatPublications.createdAt),
|
||||
desc(chatPublications.id),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!publication) {
|
||||
await notifyChatActionWork(tx, "slack_session_sync");
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: { code: "slack_session_no_published_state" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(selectedSnapshotWhere);
|
||||
return null;
|
||||
}
|
||||
let status = slackSessionStatusForPublication(
|
||||
publication.payload,
|
||||
conversation.state === "completed",
|
||||
);
|
||||
const runId = runIdFromMilestonePublication(publication);
|
||||
if (status === "processing" && runId) {
|
||||
const run = await tx
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, runId),
|
||||
eq(heartbeatRuns.companyId, endpoint.companyId),
|
||||
eq(heartbeatRuns.agentId, endpoint.assignedAgentId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
// Stop may finish before its confirmation publication. Never
|
||||
// resurrect processing from that run's earlier working receipt.
|
||||
if (!run || !["queued", "running"].includes(run.status))
|
||||
status = "active";
|
||||
}
|
||||
await notifyChatActionWork(tx, "slack_session_sync");
|
||||
const claimed = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processing",
|
||||
result: { attempts, ownerToken },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
selectedSnapshotWhere,
|
||||
or(
|
||||
and(
|
||||
eq(chatActions.status, "received"),
|
||||
sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${new Date().toISOString()}::timestamptz)`,
|
||||
),
|
||||
and(
|
||||
eq(chatActions.status, "processing"),
|
||||
lte(chatActions.updatedAt, new Date(Date.now() - 60_000)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
await lease.assertOwned(tx);
|
||||
return claimed.length ? { endpoint, conversation, status } : null;
|
||||
});
|
||||
if (!claim) return;
|
||||
ownsAttempt = true;
|
||||
const credentials = await resolveCredentials(claim.endpoint);
|
||||
await lease.assertOwned();
|
||||
const outcome = await setSlackSessionStatus({
|
||||
botToken: credentials.botToken,
|
||||
threadId: claim.conversation.externalThreadId,
|
||||
status: claim.status,
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
await db.transaction(async (tx) => {
|
||||
await lease.assertOwned(tx);
|
||||
// Refresh long work before Slack's one-hour processing timeout.
|
||||
// Unsupported sessions settle until a new publication restages
|
||||
// them; completed threads must not create perpetual polling work.
|
||||
const retryMs =
|
||||
outcome === "updated" && claim.status === "processing"
|
||||
? 30 * 60_000
|
||||
: null;
|
||||
await notifyChatActionWork(tx, "slack_session_sync");
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: retryMs === null ? "processed" : "received",
|
||||
result: {
|
||||
attempts,
|
||||
outcome,
|
||||
sessionStatus: claim.status,
|
||||
...(retryMs === null
|
||||
? {}
|
||||
: {
|
||||
retryAt: new Date(Date.now() + retryMs).toISOString(),
|
||||
}),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
revisionWhere,
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
const disposition = classifyChatPublicationError(error, attempts);
|
||||
// Unlike posting a message, repeating setStatus cannot duplicate any
|
||||
// content. Ambiguous transport/receipt failures remain retryable, but
|
||||
// a definite scope/auth/destination rejection waits for new activity.
|
||||
const retryable =
|
||||
disposition.kind === "retry" ||
|
||||
disposition.kind === "delivery_unknown";
|
||||
const retryMs =
|
||||
disposition.kind === "retry"
|
||||
? disposition.retryAfterMs
|
||||
: Math.min(5 * 60_000, 1_000 * 2 ** Math.min(attempts, 8));
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "slack_session_sync");
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: retryable ? "received" : "failed",
|
||||
result: {
|
||||
attempts,
|
||||
code: retryable
|
||||
? "slack_session_sync_retry"
|
||||
: "slack_session_sync_rejected",
|
||||
...(retryable
|
||||
? { retryAt: new Date(Date.now() + retryMs).toISOString() }
|
||||
: {}),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
revisionWhere,
|
||||
ownsAttempt
|
||||
? and(
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`,
|
||||
)
|
||||
: and(
|
||||
eq(chatActions.status, "received"),
|
||||
eq(chatActions.updatedAt, action.updatedAt),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: action.endpointId,
|
||||
actionId: action.id,
|
||||
retryable,
|
||||
error: redactError(error),
|
||||
},
|
||||
"Slack session status update did not complete; message delivery is unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
return actions.length;
|
||||
}
|
||||
return { processPendingSlackSessionSyncs };
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js";
|
||||
|
||||
import { notifyChatPublicationWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import { type ChatRuntime } from "../../chat-runtime.js";
|
||||
import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
|
||||
|
||||
import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatPublications,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ToolCredentialSecretRef,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
TELEGRAM_DRAFT_ACTION_KIND,
|
||||
TELEGRAM_DRAFT_STOPPED_REASON,
|
||||
parseTelegramDraftBinding,
|
||||
telegramDraftTextSha256,
|
||||
telegramGenerationStoppedReceipt,
|
||||
telegramPrivateDraftDestination,
|
||||
type TelegramDraftControl,
|
||||
type TelegramGenerationStoppedProof,
|
||||
} from "../../chat-telegram-draft-stop.js";
|
||||
|
||||
import {
|
||||
type ChatSdkCallbackEvent,
|
||||
type ChatSdkRuntime,
|
||||
} from "../../chat-sdk-runtime.js";
|
||||
|
||||
import {
|
||||
renderPublicationTransportText,
|
||||
} from "../../chat-publication-text-parts.js";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
type ConversationRow = typeof chatConversations.$inferSelect;
|
||||
type TelegramPublicationDraftsOptions = {
|
||||
runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise<EndpointRecord | null>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
runtime: Pick<ChatSdkRuntime | ChatRuntime, "get">;
|
||||
runtimeContexts: WeakMap<object, RuntimeContext>;
|
||||
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
|
||||
authorizeRetainedChatSourcePublication: (tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function createTelegramPublicationDrafts(db: Db, options: TelegramPublicationDraftsOptions) {
|
||||
const { runtimeCallbackRecord, runtimeCallbackEndpoint, runtimeFor, runtime, runtimeContexts, credentialFingerprint, authorizeRetainedChatSourcePublication } = options;
|
||||
|
||||
|
||||
async function handleTelegramGenerationStopped(
|
||||
callback: ChatSdkCallbackEvent<TelegramGenerationStoppedProof>,
|
||||
context: RuntimeContext,
|
||||
): Promise<void> {
|
||||
const receipt = telegramGenerationStoppedReceipt(callback.event);
|
||||
if (callback.provider !== "telegram" || !receipt) return;
|
||||
const record = await runtimeCallbackRecord(callback.endpointId, context, [
|
||||
"verifying",
|
||||
"active",
|
||||
]);
|
||||
if (
|
||||
record?.endpoint.provider !== "telegram" ||
|
||||
record.endpoint.botExternalId !== receipt.botUserId
|
||||
)
|
||||
return;
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.companyId, record.endpoint.companyId),
|
||||
eq(chatActions.endpointId, callback.endpointId),
|
||||
eq(chatActions.kind, TELEGRAM_DRAFT_ACTION_KIND),
|
||||
sql`${chatActions.payload}->>'botUserId' = ${receipt.botUserId}`,
|
||||
sql`${chatActions.payload}->>'draftId' = ${String(receipt.draftId)}`,
|
||||
),
|
||||
)
|
||||
.limit(2);
|
||||
if (candidates.length !== 1) return;
|
||||
const candidate = candidates[0]!;
|
||||
const binding = parseTelegramDraftBinding(candidate.payload);
|
||||
if (
|
||||
!binding ||
|
||||
binding.chatId !== receipt.chatId ||
|
||||
binding.messageThreadId !== receipt.messageThreadId ||
|
||||
binding.runtimeGeneration !== context.generation ||
|
||||
binding.credentialFingerprint !== context.credentialFingerprint
|
||||
)
|
||||
return;
|
||||
await db.transaction(async (tx) => {
|
||||
// The sender holds a renewable credential lease during draft HTTP. Do
|
||||
// not acquire that lease here: a Stop can arrive inside the first RPC.
|
||||
// Both Stop and final-send use the publication→endpoint→draft lock order.
|
||||
const publication = await tx
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, record.endpoint.companyId),
|
||||
eq(chatPublications.endpointId, callback.endpointId),
|
||||
eq(chatPublications.conversationId, binding.conversationId),
|
||||
eq(chatPublications.id, binding.publicationId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const endpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
callback.endpointId,
|
||||
context,
|
||||
["verifying", "active"],
|
||||
);
|
||||
if (
|
||||
!publication ||
|
||||
!endpoint ||
|
||||
endpoint.provider !== "telegram" ||
|
||||
endpoint.botExternalId !== receipt.botUserId ||
|
||||
runtime.get(endpoint.id) !== context.endpointRuntime ||
|
||||
publication.attempts !== binding.publicationAttempt ||
|
||||
telegramDraftTextSha256(
|
||||
renderPublicationTransportText(publication.payload),
|
||||
) !== binding.textSha256
|
||||
)
|
||||
return;
|
||||
const connection = await tx
|
||||
.select()
|
||||
.from(toolConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnections.companyId, endpoint.companyId),
|
||||
eq(toolConnections.id, endpoint.connectionId),
|
||||
),
|
||||
)
|
||||
.for("share")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!connection?.enabled ||
|
||||
connection.status !== "active" ||
|
||||
credentialFingerprint(connection.credentialSecretRefs) !==
|
||||
binding.credentialFingerprint
|
||||
)
|
||||
return;
|
||||
const conversation = await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.id, binding.conversationId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!conversation ||
|
||||
!conversation.isDirectMessage ||
|
||||
conversation.sessionGeneration !== binding.sessionGeneration ||
|
||||
conversation.externalThreadId !==
|
||||
`telegram:${binding.chatId}${binding.messageThreadId === null ? "" : `:${binding.messageThreadId}`}`
|
||||
)
|
||||
return;
|
||||
const action = await tx
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(eq(chatActions.id, candidate.id))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!action ||
|
||||
JSON.stringify(action.payload) !== JSON.stringify(candidate.payload)
|
||||
)
|
||||
return;
|
||||
// final_sending is an irreversible local I/O claim, not a delivery
|
||||
// receipt. A late Stop must not claim that an in-flight final was undone.
|
||||
if (
|
||||
action.result?.phase !== "drafting" ||
|
||||
action.status !== "processing" ||
|
||||
!["streaming", "delivery_unknown"].includes(publication.state)
|
||||
)
|
||||
return;
|
||||
const stoppedAt = new Date();
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
phase: "stopped",
|
||||
updateId: receipt.updateId,
|
||||
stoppedAt: stoppedAt.toISOString(),
|
||||
},
|
||||
updatedAt: stoppedAt,
|
||||
})
|
||||
.where(eq(chatActions.id, action.id));
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx
|
||||
.update(chatPublications)
|
||||
.set({
|
||||
state: "cancelled",
|
||||
nextAttemptAt: null,
|
||||
redactedError: TELEGRAM_DRAFT_STOPPED_REASON,
|
||||
updatedAt: stoppedAt,
|
||||
})
|
||||
.where(eq(chatPublications.id, publication.id));
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
code: "telegram_draft_presentation_stopped",
|
||||
attempts: publication.attempts,
|
||||
},
|
||||
updatedAt: stoppedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.companyId, publication.companyId),
|
||||
eq(chatActions.endpointId, publication.endpointId),
|
||||
eq(
|
||||
chatActions.providerActionId,
|
||||
`task-control-authorization:${publication.id}`,
|
||||
),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`${chatActions.result}->>'attempts' = ${String(publication.attempts)}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function reserveTelegramPublicationDraft(input: {
|
||||
endpoint: EndpointRow;
|
||||
conversation: ConversationRow;
|
||||
publication: typeof chatPublications.$inferSelect;
|
||||
text: string;
|
||||
credentialLease: CredentialMutationLeaseGuard;
|
||||
runtimeContext: RuntimeContext;
|
||||
}): Promise<{ actionId: string; control: TelegramDraftControl }> {
|
||||
const endpointRuntime = await runtimeFor(input.endpoint);
|
||||
const registeredContext = runtimeContexts.get(endpointRuntime as object);
|
||||
if (
|
||||
!registeredContext ||
|
||||
registeredContext.generation !== input.runtimeContext.generation ||
|
||||
registeredContext.credentialFingerprint !==
|
||||
input.runtimeContext.credentialFingerprint ||
|
||||
registeredContext.version !== input.runtimeContext.version
|
||||
)
|
||||
throw new Error("Telegram draft runtime authority changed");
|
||||
const draftRuntimeContext = { ...input.runtimeContext, endpointRuntime };
|
||||
const destination = telegramPrivateDraftDestination(
|
||||
input.conversation.externalThreadId,
|
||||
);
|
||||
if (
|
||||
!destination ||
|
||||
!input.conversation.isDirectMessage ||
|
||||
!input.endpoint.botExternalId
|
||||
)
|
||||
throw new Error("Telegram private draft destination is invalid");
|
||||
const bindingBase = {
|
||||
version: 1 as const,
|
||||
publicationId: input.publication.id,
|
||||
publicationAttempt: input.publication.attempts + 1,
|
||||
conversationId: input.conversation.id,
|
||||
sessionGeneration: input.conversation.sessionGeneration,
|
||||
runtimeGeneration: input.runtimeContext.generation,
|
||||
credentialFingerprint: input.runtimeContext.credentialFingerprint,
|
||||
botUserId: input.endpoint.botExternalId,
|
||||
...destination,
|
||||
textSha256: telegramDraftTextSha256(input.text),
|
||||
};
|
||||
const reserved = await db.transaction(async (tx) => {
|
||||
await input.credentialLease.assertOwned(tx);
|
||||
const publication = await tx
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.id, input.publication.id),
|
||||
eq(chatPublications.state, "streaming"),
|
||||
eq(chatPublications.attempts, bindingBase.publicationAttempt),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!publication)
|
||||
throw new Error("Telegram draft publication ownership changed");
|
||||
const providerActionId = `telegram-draft-publication:${publication.id}:${publication.attempts}`;
|
||||
const existing = await tx
|
||||
.select({ id: chatActions.id })
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, input.endpoint.id),
|
||||
eq(chatActions.providerActionId, providerActionId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
// A second invocation of the same attempt cannot create another draft or
|
||||
// replay an uncertain final. A new explicit publication attempt gets a
|
||||
// fresh, never-before-used bot-global ID.
|
||||
if (existing.length)
|
||||
throw new Error("Telegram draft attempt already exists");
|
||||
// NO CYCLE and no company/endpoint ownership: rollback, endpoint deletion
|
||||
// and bot re-binding cannot recycle a provider-visible draft identity.
|
||||
// Exhaustion is a refusal, never a fallback to a process-local/random ID.
|
||||
const allocated = await tx.execute(
|
||||
sql`select nextval('chat_telegram_draft_ids')::integer as "draftId"`,
|
||||
);
|
||||
const binding = parseTelegramDraftBinding({
|
||||
...bindingBase,
|
||||
draftId: allocated[0]?.draftId,
|
||||
});
|
||||
if (!binding)
|
||||
throw new Error("Telegram draft identity allocation was invalid");
|
||||
const [action] = await tx
|
||||
.insert(chatActions)
|
||||
.values({
|
||||
companyId: input.endpoint.companyId,
|
||||
endpointId: input.endpoint.id,
|
||||
conversationId: input.conversation.id,
|
||||
kind: TELEGRAM_DRAFT_ACTION_KIND,
|
||||
providerActionId,
|
||||
payload: binding,
|
||||
status: "issued",
|
||||
result: { phase: "reserved" },
|
||||
})
|
||||
.returning();
|
||||
return { action: action!, binding };
|
||||
});
|
||||
const before = (final: boolean): Promise<boolean> =>
|
||||
db.transaction(async (tx) => {
|
||||
await input.credentialLease.assertOwned(tx);
|
||||
const publication = await tx
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, input.endpoint.companyId),
|
||||
eq(chatPublications.id, input.publication.id),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const endpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
input.endpoint.id,
|
||||
draftRuntimeContext,
|
||||
["verifying", "active"],
|
||||
);
|
||||
if (
|
||||
!endpoint ||
|
||||
endpoint.provider !== "telegram" ||
|
||||
!endpoint.allowDirectMessages ||
|
||||
endpoint.botExternalId !== reserved.binding.botUserId ||
|
||||
runtime.get(endpoint.id) !== endpointRuntime
|
||||
)
|
||||
throw new Error("Telegram draft endpoint authority changed");
|
||||
const connection = await tx
|
||||
.select()
|
||||
.from(toolConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnections.companyId, endpoint.companyId),
|
||||
eq(toolConnections.id, endpoint.connectionId),
|
||||
),
|
||||
)
|
||||
.for("share")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!connection?.enabled ||
|
||||
connection.status !== "active" ||
|
||||
credentialFingerprint(connection.credentialSecretRefs) !==
|
||||
reserved.binding.credentialFingerprint
|
||||
)
|
||||
throw new Error("Telegram draft credential authority changed");
|
||||
const conversation = await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.id, input.conversation.id),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const action = await tx
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(eq(chatActions.id, reserved.action.id))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!action ||
|
||||
JSON.stringify(action.payload) !==
|
||||
JSON.stringify(reserved.action.payload) ||
|
||||
!publication ||
|
||||
publication.attempts !== reserved.binding.publicationAttempt
|
||||
)
|
||||
throw new Error("Telegram draft attempt authority changed");
|
||||
if (
|
||||
action.status === "cancelled" &&
|
||||
action.result?.phase === "stopped" &&
|
||||
publication.state === "cancelled"
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
publication.state !== "streaming" ||
|
||||
!conversation ||
|
||||
telegramDraftTextSha256(
|
||||
renderPublicationTransportText(publication.payload),
|
||||
) !== reserved.binding.textSha256 ||
|
||||
conversation.sessionGeneration !==
|
||||
reserved.binding.sessionGeneration ||
|
||||
conversation.externalThreadId !==
|
||||
input.conversation.externalThreadId ||
|
||||
!["active", "waiting", "completed"].includes(conversation.state) ||
|
||||
!["reserved", "drafting"].includes(String(action.result?.phase)) ||
|
||||
!["issued", "processing"].includes(action.status) ||
|
||||
!(await authorizeRetainedChatSourcePublication(tx, publication))
|
||||
)
|
||||
throw new Error("Telegram draft source authority changed");
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processing",
|
||||
result: {
|
||||
phase: final ? "final_sending" : "drafting",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatActions.id, action.id));
|
||||
await input.credentialLease.assertOwned(tx);
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
actionId: reserved.action.id,
|
||||
control: {
|
||||
version: 1,
|
||||
draftId: reserved.binding.draftId,
|
||||
beforeDraft: () => before(false),
|
||||
beforeFinal: () => before(true),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { handleTelegramGenerationStopped, reserveTelegramPublicationDraft };
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readProcessLossDiagnostic } from "./process-loss-diagnostics.js";
|
||||
import { WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared";
|
||||
import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction";
|
||||
import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics";
|
||||
import { ACP_TOOL_KINDS } from "@paperclipai/adapter-utils/acpx-engine/constants";
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js";
|
||||
import { readNativeModelRejectionDiagnostic } from "./native-runtime/native-provider-failure.js";
|
||||
@@ -41,6 +42,15 @@ export interface RunFailureDiagnostics {
|
||||
truncatedFields: string[];
|
||||
}
|
||||
|
||||
const ACP_PENDING_TOOL_KIND_LABELS: ReadonlySet<string> = new Set([...ACP_TOOL_KINDS, "unknown"]);
|
||||
|
||||
/** Closed ACP tool-kind labels only; any other content drops the whole field. */
|
||||
function readAcpPendingToolKinds(value: unknown): string | null {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 200) return null;
|
||||
const kinds = value.split(",");
|
||||
return kinds.every((kind) => ACP_PENDING_TOOL_KIND_LABELS.has(kind)) ? value : null;
|
||||
}
|
||||
|
||||
function read(value: unknown, key: string): unknown {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
try {
|
||||
@@ -197,10 +207,15 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport
|
||||
"mode", "stopReason", "timeoutFired", "timeoutSource", "timeoutConfigured",
|
||||
"effectiveTimeoutSec", "errorFamily",
|
||||
]));
|
||||
for (const field of ["acpLastEventAgeMs", "acpObservedEventCount", "acpPendingToolCount"]) {
|
||||
for (const field of [
|
||||
"acpLastEventAgeMs", "acpObservedEventCount", "acpPendingToolCount",
|
||||
"acpLastEventAgeAtStopMs", "acpOldestPendingToolAgeAtStopMs",
|
||||
]) {
|
||||
const value = read(result, field);
|
||||
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) execution[field] = value;
|
||||
}
|
||||
const pendingToolKinds = readAcpPendingToolKinds(read(result, "acpPendingToolKinds"));
|
||||
if (pendingToolKinds) execution.acpPendingToolKinds = pendingToolKinds;
|
||||
const toolInventoryComplete = read(result, "acpToolInventoryComplete");
|
||||
if (typeof toolInventoryComplete === "boolean") execution.acpToolInventoryComplete = toolInventoryComplete;
|
||||
const restoreFailure = read(result, "workspaceRestoreFailure");
|
||||
|
||||
Reference in new issue
Block a user