mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
feat: add opt-in chat provider and data foundation (#13100)
Add dormant provider contracts, qualified patched adapters, tenant-scoped persistence and lifecycle ownership without activating chat routes. Preserve the experimental integration as dependent PR #13038. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7d84b183fb
commit
6abeb67334
143 files changed
+789391
-162
No files matched your search
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
configureChatEndpointSchema,
|
||||
microsoftTeamsCredentialIdSchema,
|
||||
resolveChatActionSchema,
|
||||
resolveChatPublicationSchema,
|
||||
chatPublicationStateSchema,
|
||||
} from "./chat-channels.js";
|
||||
|
||||
describe("Microsoft Teams chat credential validation", () => {
|
||||
it("normalizes canonical Entra application and tenant UUIDs", () => {
|
||||
const parsed = configureChatEndpointSchema.parse({
|
||||
action: "configure",
|
||||
credentials: {
|
||||
clientId: " 76D0CB17-5EC4-4B3D-983B-DA8A01DC02C4 ",
|
||||
tenantId: "F8CDEF31-A31E-4B4A-93E4-5F571E91255A",
|
||||
clientSecret: "keep-case-sensitive-secret",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.credentials).toEqual({
|
||||
clientId: "76d0cb17-5ec4-4b3d-983b-da8a01dc02c4",
|
||||
tenantId: "f8cdef31-a31e-4b4a-93e4-5f571e91255a",
|
||||
clientSecret: "keep-case-sensitive-secret",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"common",
|
||||
"organizations",
|
||||
"contoso.onmicrosoft.com",
|
||||
"76d0cb175ec44b3d983bda8a01dc02c4",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
])("rejects non-canonical Teams credential id %s", (value) => {
|
||||
expect(microsoftTeamsCredentialIdSchema.safeParse(value).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
configureChatEndpointSchema.safeParse({
|
||||
action: "configure",
|
||||
credentials: {
|
||||
clientId: "76d0cb17-5ec4-4b3d-983b-da8a01dc02c4",
|
||||
tenantId: value,
|
||||
clientSecret: "secret",
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not impose Teams UUID rules on unrelated provider credentials", () => {
|
||||
expect(
|
||||
configureChatEndpointSchema.parse({
|
||||
action: "configure",
|
||||
credentials: {
|
||||
botToken: "xoxb-test",
|
||||
signingSecret: "secret",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
action: "configure",
|
||||
credentials: {
|
||||
botToken: "xoxb-test",
|
||||
signingSecret: "secret",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat provider-action resolution validation", () => {
|
||||
it("accepts consent waiting and a closed versioned file-stage precondition", () => {
|
||||
expect(chatPublicationStateSchema.parse("awaiting_consent")).toBe(
|
||||
"awaiting_consent",
|
||||
);
|
||||
const request = {
|
||||
action: "cancel",
|
||||
fileTransfer: { phase: "upload_unknown", version: 3 },
|
||||
};
|
||||
expect(resolveChatPublicationSchema.parse(request)).toEqual(request);
|
||||
expect(resolveChatPublicationSchema.parse({ action: "cancel" })).toEqual({
|
||||
action: "cancel",
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ phase: "upload_unknown", version: 0 },
|
||||
{ phase: "upload_unknown", version: 1.5 },
|
||||
{ phase: "upload_unknown", version: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ phase: "new_unreviewed_phase", version: 1 },
|
||||
{ phase: "upload_unknown" },
|
||||
{ phase: "upload_unknown", version: 1, uploadUrl: "private" },
|
||||
])(
|
||||
"rejects malformed file-stage resolution preconditions %#",
|
||||
(fileTransfer) => {
|
||||
expect(
|
||||
resolveChatPublicationSchema.safeParse({
|
||||
action: "cancel",
|
||||
fileTransfer,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
it.each(["mark_delivered", "retry_anyway", "cancel"] as const)(
|
||||
"accepts the explicit %s resolution",
|
||||
(action) => {
|
||||
expect(resolveChatActionSchema.parse({ action })).toEqual({ action });
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects automatic or unknown provider-effect replay modes", () => {
|
||||
expect(resolveChatActionSchema.safeParse({ action: "retry" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { z } from "zod";
|
||||
import { multilineTextSchema } from "./text.js";
|
||||
import {
|
||||
CHAT_CONCURRENCY_POLICIES,
|
||||
CHAT_DELIVERY_STATES,
|
||||
CHAT_ENDPOINT_STATUSES,
|
||||
CHAT_EVENT_KINDS,
|
||||
CHAT_FILE_TRANSFER_PHASES,
|
||||
CHAT_IDENTITY_LINK_STATUSES,
|
||||
CHAT_PRINCIPAL_KINDS,
|
||||
CHAT_PROVIDERS,
|
||||
CHAT_PUBLICATION_STATES,
|
||||
CHAT_RESOURCE_AVAILABILITIES,
|
||||
} from "../types/chat-channels.js";
|
||||
|
||||
export const chatProviderSchema = z.enum(CHAT_PROVIDERS);
|
||||
export const chatEndpointStatusSchema = z.enum(CHAT_ENDPOINT_STATUSES);
|
||||
export const chatConcurrencyPolicySchema = z.enum(CHAT_CONCURRENCY_POLICIES);
|
||||
export const chatEventKindSchema = z.enum(CHAT_EVENT_KINDS);
|
||||
export const chatDeliveryStateSchema = z.enum(CHAT_DELIVERY_STATES);
|
||||
export const chatPublicationStateSchema = z.enum(CHAT_PUBLICATION_STATES);
|
||||
export const chatPrincipalKindSchema = z.enum(CHAT_PRINCIPAL_KINDS);
|
||||
export const chatIdentityLinkStatusSchema = z.enum(CHAT_IDENTITY_LINK_STATUSES);
|
||||
export const chatResourceAvailabilitySchema = z.enum(
|
||||
CHAT_RESOURCE_AVAILABILITIES,
|
||||
);
|
||||
|
||||
/**
|
||||
* Microsoft emits Entra application and tenant identifiers in canonical UUID
|
||||
* form in Bot Framework activities. Tenant aliases such as `common` or an
|
||||
* `onmicrosoft.com` domain can be accepted by the token endpoint, but cannot
|
||||
* be compared safely with the activity tenant id used by Paperclip's runtime
|
||||
* fence. Normalize the UUIDs at the API boundary instead.
|
||||
*/
|
||||
export const microsoftTeamsCredentialIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.uuid()
|
||||
.refine((value) => value !== "00000000-0000-0000-0000-000000000000", {
|
||||
message: "Microsoft Teams credential IDs cannot be the nil UUID",
|
||||
})
|
||||
.transform((value) => value.toLowerCase());
|
||||
|
||||
const chatEndpointCredentialsSchema = z
|
||||
.record(z.string(), z.string().min(1))
|
||||
.superRefine((credentials, ctx) => {
|
||||
for (const key of ["clientId", "tenantId"] as const) {
|
||||
const value = credentials[key];
|
||||
if (value === undefined) continue;
|
||||
const parsed = microsoftTeamsCredentialIdSchema.safeParse(value);
|
||||
if (parsed.success) continue;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [key],
|
||||
message: `${key} must be a canonical Microsoft Entra UUID`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.transform((credentials) => {
|
||||
const normalized = { ...credentials };
|
||||
for (const key of ["clientId", "tenantId"] as const) {
|
||||
const value = normalized[key];
|
||||
if (value !== undefined) {
|
||||
normalized[key] = microsoftTeamsCredentialIdSchema.parse(value);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
|
||||
export const createChatEndpointSchema = z
|
||||
.object({
|
||||
provider: chatProviderSchema,
|
||||
assignedAgentId: z.string().uuid(),
|
||||
applicationId: z.string().uuid().optional(),
|
||||
name: z.string().trim().min(1).max(160).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const updateChatEndpointSchema = z
|
||||
.object({
|
||||
allowDirectMessages: z.boolean().optional(),
|
||||
allowGroupChats: z.boolean().optional(),
|
||||
allowUnlinkedPeople: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: "At least one chat endpoint field is required",
|
||||
});
|
||||
|
||||
export const configureChatEndpointSchema = z
|
||||
.object({
|
||||
action: z.enum([
|
||||
"configure",
|
||||
"verify",
|
||||
"pause",
|
||||
"resume",
|
||||
"reconnect",
|
||||
"remove",
|
||||
]),
|
||||
credentials: chatEndpointCredentialsSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
value.credentials &&
|
||||
value.action !== "configure" &&
|
||||
value.action !== "reconnect"
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["credentials"],
|
||||
message: `Credentials are not accepted for the ${value.action} action`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const replaceChatEndpointResourcesSchema = z
|
||||
.object({
|
||||
resources: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().uuid(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.max(500),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const publishChatCommentSchema = z
|
||||
.object({
|
||||
commentId: z.string().uuid(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const publishChatBoardMessageSchema = z
|
||||
.object({
|
||||
body: multilineTextSchema.pipe(z.string().trim().min(1).max(100_000)),
|
||||
idempotencyKey: z.string().trim().min(16).max(200),
|
||||
attachmentIds: z
|
||||
.array(z.string().uuid())
|
||||
.max(20)
|
||||
.refine((ids) => new Set(ids).size === ids.length, {
|
||||
message: "Attachment ids must be unique",
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const publishChatPublicationSchema = z.union([
|
||||
publishChatCommentSchema,
|
||||
publishChatBoardMessageSchema,
|
||||
]);
|
||||
|
||||
export const resolveChatPublicationSchema = z
|
||||
.object({
|
||||
action: z.enum(["mark_delivered", "retry_anyway", "cancel"]),
|
||||
fileTransfer: z
|
||||
.object({
|
||||
phase: z.enum(CHAT_FILE_TRANSFER_PHASES),
|
||||
version: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const resolveChatActionSchema = z
|
||||
.object({
|
||||
action: z.enum(["mark_delivered", "retry_anyway", "cancel"]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const createChatIdentityLinkIntentSchema = z
|
||||
.object({
|
||||
expiresInSeconds: z.number().int().min(300).max(86_400).default(1_800),
|
||||
})
|
||||
.strict()
|
||||
.default({ expiresInSeconds: 1_800 });
|
||||
|
||||
export const confirmChatIdentityLinkSchema = z
|
||||
.object({
|
||||
token: z.string().min(32).max(4096),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const replayChatDeliverySchema = z.object({}).strict();
|
||||
|
||||
export const chatPublicEndpointIdSchema = z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_-]{32,128}$/);
|
||||
Reference in new issue
Block a user