mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
test(evals): prepare owned real Slack endpoint fixtures
This commit is contained in:
1 parent
e4b62f48aa
commit
9d850991d9
4 files changed
+136
-7
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import {describe,it,expect} from "vitest";
|
||||
import {slackCallbackManifest} from "./external-slack-fixture.js";
|
||||
const webhook="https://isolated.example.test/api/chat-webhooks/actual-public-id/slack";
|
||||
const original=()=>({oauth_config:{scopes:{bot:["channels:history","chat:write"]}},settings:{event_subscriptions:{request_url:"https://old.example.test/events",bot_events:["message.channels"]},interactivity:{is_enabled:true,request_url:"https://old.example.test/actions"}},features:{slash_commands:[{command:"/owned",url:"https://old.example.test/command"}]}});
|
||||
describe("actual disposable Slack app callback preparation",()=>{
|
||||
it("updates existing callback surfaces while retaining independent scopes/events and original restore manifest",()=>{
|
||||
const prior=original(),copy=structuredClone(prior),next=slackCallbackManifest(prior,webhook);
|
||||
expect(prior).toEqual(copy);expect(next.oauth_config).toEqual(copy.oauth_config);expect(next.settings.event_subscriptions.bot_events).toEqual(["message.channels"]);
|
||||
expect(next.settings.event_subscriptions.request_url).toBe(webhook);expect(next.settings.interactivity.request_url).toBe(webhook);expect(next.features.slash_commands[0].url).toBe(webhook);
|
||||
});
|
||||
it("cannot turn an unconfigured app or arbitrary callback URL into a passing Slack fixture",()=>{
|
||||
const noEvents=original();noEvents.settings.event_subscriptions.bot_events=[];
|
||||
expect(()=>slackCallbackManifest(noEvents,webhook)).toThrow(/already have authorized/);
|
||||
for(const path of ["http://isolated.example.test/api/chat-webhooks/id/slack","https://elsewhere.example.test/arbitrary-hook","https://isolated.example.test/api/chat-webhooks/id/slack?token=private"])
|
||||
expect(()=>slackCallbackManifest(original(),path)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ChatCatalogContext,ChatSlackIngressReceipt } from "../chat-catalog-contract.js";
|
||||
import type { ChatConversation,ChatEndpoint,ChatEndpointResource } from "../../../packages/shared/src/types/chat-channels.js";
|
||||
import { selectedExternalBinding,selectedExternalCredential } from "./external-fixtures.js";
|
||||
import { slackProvider,assertSlackFixture,slackCompanionFields } from "./external-slack-provider.js";
|
||||
import { startSlackRelay } from "./external-slack-relay.js";
|
||||
import { pollUntil } from "../api.js";
|
||||
import { root } from "./external-support.js";
|
||||
export { slackCompanionFields };
|
||||
interface Principal {principalId:string;externalDetail:string;paperclipUserId:string|null;status:string}
|
||||
export interface SlackMessage {ts:string;thread_ts?:string;user?:string;bot_id?:string;text?:string;files?:Array<{id:string;permalink?:string;url_private_download?:string;size?:number}>}
|
||||
/** Preserve the selected app's actual scopes/events; only point its existing
|
||||
* callback surfaces at this attempt's signed product webhook. */
|
||||
export function slackCallbackManifest(original:Record<string,unknown>,webhook:string){
|
||||
const url=new URL(webhook);if(url.protocol!=="https:"||!/^\/api\/chat-webhooks\/[a-zA-Z0-9_-]+\/slack$/.test(url.pathname)||url.search||url.hash||url.username||url.password)throw new Error("Disposable Slack callback is not the actual supported HTTPS product route");
|
||||
const manifest=structuredClone(original) as Record<string,any>;manifest.settings??={};manifest.settings.event_subscriptions??={};
|
||||
if(!Array.isArray(manifest.settings.event_subscriptions.bot_events)||!manifest.settings.event_subscriptions.bot_events.some((event:string)=>["message.channels","message.groups"].includes(event)))throw new Error("Setup blocker: allowlisted Slack app must already have authorized message event subscriptions; fixture will not silently widen scopes");
|
||||
manifest.settings.event_subscriptions.request_url=webhook;
|
||||
manifest.settings.interactivity={...manifest.settings.interactivity,is_enabled:true,request_url:webhook};
|
||||
for(const command of manifest.features?.slash_commands??[])command.url=webhook;
|
||||
return manifest;
|
||||
}
|
||||
export async function prepareSlackFixture(ctx:ChatCatalogContext){
|
||||
const binding=selectedExternalBinding(ctx);assertSlackFixture(binding);const p=binding.prerequisites;
|
||||
if(!ctx.input.privateSecrets)throw new Error("Slack fixture requires private credential redaction");
|
||||
const port=Number(p.webhookRelayPort);if(!Number.isInteger(port)||port<1024||port>65535)throw new Error("Setup blocker: dedicated webhookRelayPort for the isolated HTTPS tunnel is required");
|
||||
const provider=slackProvider(ctx,binding);let endpoint:ChatEndpoint|undefined,relay:Awaited<ReturnType<typeof startSlackRelay>>|undefined,originalManifest:Record<string,unknown>|undefined,manifestChanged=false;
|
||||
const createdThreads:string[]=[],createdFiles:string[]=[];
|
||||
async function cleanup(){
|
||||
try{if(endpoint)await ctx.input.api.post(`/api/chat-endpoints/${endpoint.id}/setup`,{action:"remove"});}
|
||||
finally{try{if(manifestChanged&&originalManifest)await provider.call("configuration","apps.manifest.update",{app_id:p.appId,manifest:originalManifest});}finally{try{await provider.cleanupOwned();}finally{await relay?.close();}}}
|
||||
}
|
||||
try{
|
||||
await ctx.input.api.patch("/api/instance/settings/experimental",{enableChatConnectors:true});
|
||||
endpoint=await ctx.input.api.post<ChatEndpoint>(`${root(ctx)}/chat-endpoints`,{provider:"slack",assignedAgentId:ctx.input.fixtures.agent.id,name:"Disposable catalog Slack",slackSetupMethod:"manual"});
|
||||
const webhook=endpoint.setup.webhookUrl;if(!webhook||new URL(webhook).origin!==new URL(p.registeredWebhookOrigin!).origin)throw new Error("Setup blocker: actual server PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL must match designated registeredWebhookOrigin before any Slack operation");
|
||||
relay=await startSlackRelay({port,endpointPublicId:endpoint.publicId,baseURL:ctx.input.api.baseURL,signingSecret:selectedExternalCredential(ctx,binding,"signingSecretEnv"),workspaceId:p.workspaceId!,senderUserId:p.senderUserId!,channelId:p.channelId!});
|
||||
const sender=await provider.call<{ok:true;team_id:string;user_id:string;bot_id?:string}>("sender","auth.test",{}),bot=await provider.call<{ok:true;team_id:string;user_id:string;bot_id?:string}>("bot","auth.test",{});
|
||||
if(sender.team_id!==p.workspaceId||sender.user_id!==p.senderUserId||sender.bot_id||bot.team_id!==p.workspaceId||bot.user_id!==p.botUserId||!bot.bot_id)throw new Error("Setup blocker: selected Slack credentials do not identify the designated disposable workspace/human/bot");
|
||||
const app=await provider.call<{ok:true;bot:{app_id:string}}>("bot","bots.info",{bot:bot.bot_id});if(app.bot?.app_id!==p.appId)throw new Error("Setup blocker: actual Slack bot token belongs to a different app than the allowlisted disposable app");
|
||||
const human=await provider.call<{ok:true;user:{id:string;team_id:string;name:string;is_bot?:boolean;deleted?:boolean}}>("bot","users.info",{user:p.senderUserId});if(human.user.id!==p.senderUserId||human.user.team_id!==p.workspaceId||human.user.is_bot||human.user.deleted||!human.user.name)throw new Error("Setup blocker: designated Slack sender is not the selected active human");
|
||||
endpoint=await ctx.input.api.postSensitive<ChatEndpoint>(`/api/chat-endpoints/${endpoint.id}/setup`,{action:"configure",credentials:{botToken:selectedExternalCredential(ctx,binding,"botTokenEnv"),signingSecret:selectedExternalCredential(ctx,binding,"signingSecretEnv")}});
|
||||
const exported=await provider.call<{ok:true;manifest:Record<string,unknown>}>("configuration","apps.manifest.export",{app_id:p.appId});originalManifest=exported.manifest;
|
||||
if(!originalManifest||typeof originalManifest!=="object")throw new Error("Setup blocker: disposable Slack app manifest could not be independently read");
|
||||
const manifest=slackCallbackManifest(originalManifest,webhook);
|
||||
manifestChanged=true;await provider.call("configuration","apps.manifest.update",{app_id:p.appId,manifest});
|
||||
endpoint=await pollUntil({label:"actual Slack signed callback verification",deadlineAt:Date.now()+60000,load:()=>ctx.input.api.get<ChatEndpoint>(`/api/chat-endpoints/${endpoint!.id}`),accept:row=>!!row.setup.webhookVerifiedAt});
|
||||
endpoint=await ctx.input.api.post<ChatEndpoint>(`/api/chat-endpoints/${endpoint.id}/setup`,{action:"verify"});
|
||||
await ctx.input.api.patch(`/api/chat-endpoints/${endpoint.id}`,{requireAtMention:false,allowGroupChats:true,allowUnlinkedPeople:false});
|
||||
const resources=await ctx.input.api.get<ChatEndpointResource[]>(`/api/chat-endpoints/${endpoint.id}/resources`);if(!resources.some(row=>row.providerResourceId===p.channelId&&row.availability==="available"))throw new Error("Setup blocker: app bot is not an actual member of the allowlisted disposable Slack channel");
|
||||
await ctx.input.api.putSensitive(`/api/chat-endpoints/${endpoint.id}/resources`,{resources:resources.map(row=>({id:row.id,enabled:row.providerResourceId===p.channelId}))});
|
||||
async function postRoot(text:string){const result=await provider.call<{ok:true;channel:string;ts:string;message:{user:string;text:string}}>("sender","chat.postMessage",{channel:p.channelId,text,unfurl_links:false,unfurl_media:false});if(result.channel!==p.channelId||result.message?.user!==p.senderUserId||!/^\d+\.\d{6}$/.test(result.ts))throw new Error("Actual Slack root did not bind the designated human/channel");createdThreads.push(result.ts);return result.ts;}
|
||||
const threadId=await postRoot(`Disposable launch discussion ${ctx.input.nonce}`);
|
||||
const principals=await pollUntil({label:"actual unlinked Slack sender discovery",deadlineAt:Date.now()+60000,load:()=>ctx.input.api.get<Principal[]>(`/api/chat-endpoints/${endpoint!.id}/principals`),accept:rows=>rows.some(row=>row.externalDetail===`@${human.user.name}`)});
|
||||
const principal=principals.filter(row=>row.externalDetail===`@${human.user.name}`);if(principal.length!==1||principal[0]!.status==="linked")throw new Error("Setup blocker: the genuine Slack human must have one unlinked discovered identity in the new endpoint");
|
||||
const intent=await ctx.input.api.postSensitive<{confirmationUrl:string}>(`/api/chat-endpoints/${endpoint.id}/principals/${principal[0]!.principalId}/link-intent`,{expiresInSeconds:600}),url=new URL(intent.confirmationUrl,ctx.input.api.baseURL),token=url.searchParams.get("token");if(url.pathname!=="/chat-identity/confirm"||!token)throw new Error("Actual Slack identity link returned an unsupported confirmation surface");ctx.input.privateSecrets.push(token);
|
||||
// Fixture setup uses the same supported signed-in confirmation route as the
|
||||
// human UI, through the noninstrumented sensitive transport. The bearer link
|
||||
// never enters a Test browser URL/trace; this is labelled operator API setup.
|
||||
await ctx.input.api.postSensitive("/api/chat-identity-links/confirm",{token});
|
||||
const linked=(await ctx.input.api.get<Principal[]>(`/api/chat-endpoints/${endpoint.id}/principals`)).filter(row=>row.principalId===principal[0]!.principalId&&row.status==="linked");if(linked.length!==1||!linked[0]!.paperclipUserId)throw new Error("Actual signed-in person did not complete the selected Slack identity link");
|
||||
endpoint=await ctx.input.api.post<ChatEndpoint>(`/api/chat-endpoints/${endpoint.id}/finish`,{});if(endpoint.status!=="active")throw new Error("Setup blocker: supported Slack finish did not activate the actual endpoint");
|
||||
return {binding,provider,relay,endpoint,threadId,createdThreads,createdFiles,linkedPaperclipUserId:linked[0]!.paperclipUserId,
|
||||
async createDistractor(text:string){return postRoot(text);},
|
||||
async readThread(rootTs:string){if(!createdThreads.includes(rootTs))throw new Error("Slack egress read escaped an owned thread");const result=await provider.call<{ok:true;messages:SlackMessage[];has_more?:boolean}>("bot","conversations.replies",{channel:p.channelId,ts:rootTs,limit:100});if(result.has_more||!Array.isArray(result.messages))throw new Error("Slack thread exceeds bounded fixture observation");return result.messages;},
|
||||
async resolveIngress(query:string,messageId:string,providerCalls:number):Promise<ChatSlackIngressReceipt>{
|
||||
const conversations=await pollUntil({label:"actual Slack canonical source persistence",deadlineAt:Date.now()+60000,load:()=>ctx.input.api.get<ChatConversation[]>(`/api/chat-endpoints/${endpoint!.id}/conversations`),accept:rows=>rows.some(row=>row.externalThreadId===`slack:${p.channelId}:${threadId}`)}),conversation=conversations.find(row=>row.externalThreadId===`slack:${p.channelId}:${threadId}`)!;
|
||||
const comments=await ctx.input.api.get<Array<{id:string;body:string;authorUserId:string;metadata?:{sourceChannel?:string}}>>(`/api/issues/${conversation.issueId}/comments?order=asc`),humanComments=comments.filter(row=>row.body===query&&row.authorUserId===linked[0]!.paperclipUserId&&row.metadata?.sourceChannel==="slack");
|
||||
if(!humanComments.length)throw new Error("Canonical Slack input did not persist under the linked human identity");return {query,endpointId:endpoint!.id,conversationId:conversation.id,issueId:conversation.issueId,humanCommentId:humanComments.at(-1)!.id,linkedPaperclipUserId:linked[0]!.paperclipUserId!,providerCalls,provider:{workspaceId:p.workspaceId!,channelId:p.channelId!,senderUserId:p.senderUserId!,threadId,canonicalMessageId:messageId}};
|
||||
},cleanup};
|
||||
}catch(error){await cleanup();throw error;}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe,expect,it } from "vitest";
|
||||
import type { ChatConversation,ChatEndpoint,ExternalChannelBindingSummary } from "../../../packages/shared/src/types/chat-channels.js";
|
||||
import { assertSlackCall,assertSlackFixture,slackIngressMatches,type SlackIngressReceipt } from "./external-slack-provider.js";
|
||||
function binding(){return {disposable:true as const,resourceIds:["TWORKSPACE","CCHANNEL","USENDER","UBOT","AAPP"],credentialEnv:["PAPERCLIP_CHAT_EVAL_SLACK_BOT","PAPERCLIP_CHAT_EVAL_SLACK_SENDER","PAPERCLIP_CHAT_EVAL_SLACK_SIGNING","PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"],prerequisites:{workspaceId:"TWORKSPACE",channelId:"CCHANNEL",senderUserId:"USENDER",botUserId:"UBOT",appId:"AAPP",registeredWebhookOrigin:"https://isolated.example.test",botTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_BOT",senderTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SENDER",signingSecretEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SIGNING",configurationTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"}};}
|
||||
function binding(){return {disposable:true as const,resourceIds:["TWORKSPACE","CCHANNEL","USENDER","UBOT","AAPP"],credentialEnv:["PAPERCLIP_CHAT_EVAL_SLACK_BOT","PAPERCLIP_CHAT_EVAL_SLACK_SENDER","PAPERCLIP_CHAT_EVAL_SLACK_SIGNING","PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"],prerequisites:{workspaceId:"TWORKSPACE",channelId:"CCHANNEL",senderUserId:"USENDER",botUserId:"UBOT",appId:"AAPP",registeredWebhookOrigin:"https://isolated.example.test",webhookRelayPort:"3399",botTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_BOT",senderTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SENDER",signingSecretEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SIGNING",configurationTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"}};}
|
||||
function ingress(){const receipt:SlackIngressReceipt={providerCalls:1,query:"Exact canonical query.",endpointId:"endpoint",conversationId:"conversation",issueId:"issue",humanCommentId:"comment",linkedPaperclipUserId:"human",provider:{workspaceId:"TWORKSPACE",channelId:"CCHANNEL",threadId:"1793050000.123456",canonicalMessageId:"1793050001.123456",senderUserId:"USENDER"}};return {receipt,endpoint:{id:"endpoint",companyId:"company",assignedAgentId:"ceo",provider:"slack",providerAccountId:"TWORKSPACE",botExternalId:"UBOT"} as ChatEndpoint,conversation:{id:"conversation",endpointId:"endpoint",companyId:"company",issueId:"issue",externalConversationId:"slack:CCHANNEL",externalThreadId:"slack:CCHANNEL:1793050000.123456"} as ChatConversation,binding:{endpointId:"endpoint",conversationId:"conversation",provider:"slack",assignedAgentLocked:true} as ExternalChannelBindingSummary,comment:{id:"comment",body:"Exact canonical query.",authorUserId:"human",metadata:{sourceChannel:"slack"}},canonicalQuery:"Exact canonical query.",companyId:"company",agentId:"ceo"};}
|
||||
describe("finite real Slack companion prerequisites and source binding",()=>{
|
||||
it("requires each explicit disposable provider resource and a distinct actual human sender",()=>{
|
||||
@@ -26,3 +26,17 @@ describe("finite real Slack companion prerequisites and source binding",()=>{
|
||||
]){const i=ingress();mutate(i);expect(slackIngressMatches(i)).toBe(false);}
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans only messages proven created by this exact private client; arbitrary delete remains unavailable",async()=>{
|
||||
const {slackProvider}=await import("./external-slack-provider.js");
|
||||
const b=binding(),requests:Array<{method:string;body:Record<string,unknown>}>=[];
|
||||
const ctx={input:{privateSecrets:[],privateInputs:{read:()=>"synthetic-private-token"}}} as unknown as import("../chat-catalog-contract.js").ChatCatalogContext;
|
||||
const client=slackProvider(ctx,b,(async(url,options)=>{
|
||||
const method=String(url).split("/").at(-1)!,body=JSON.parse(String(options?.body));requests.push({method,body});
|
||||
return new Response(JSON.stringify(method==="chat.postMessage"?{ok:true,channel:"CCHANNEL",ts:"1793050002.123456"}:method==="conversations.replies"?{ok:true,messages:[{ts:"1793050003.123456",thread_ts:"1793050002.123456",user:"UBOT"},{ts:"1793050004.123456",thread_ts:"1793050002.123456",user:"UOTHER"}]}:{ok:true}),{status:200});
|
||||
}) as typeof fetch);
|
||||
await expect(client.call("sender","chat.delete",{channel:"CCHANNEL",ts:"1793059999.123456"})).rejects.toThrow(/finite/);
|
||||
await client.call("sender","chat.postMessage",{channel:"CCHANNEL",text:"Actual disposable creation"});
|
||||
await client.cleanupOwned();await client.cleanupOwned();
|
||||
expect(requests).toEqual([{method:"chat.postMessage",body:{channel:"CCHANNEL",text:"Actual disposable creation"}},{method:"conversations.replies",body:{channel:"CCHANNEL",ts:"1793050002.123456",limit:100}},{method:"chat.delete",body:{channel:"CCHANNEL",ts:"1793050003.123456"}},{method:"chat.delete",body:{channel:"CCHANNEL",ts:"1793050002.123456"}}]);
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
import type { ChatConversation,ChatEndpoint,ExternalChannelBindingSummary } from "../../../packages/shared/src/types/chat-channels.js";
|
||||
import { selectedExternalCredential,type ProviderBinding } from "./external-fixtures.js";
|
||||
|
||||
export const slackCompanionFields=["workspaceId","channelId","senderUserId","botUserId","appId","registeredWebhookOrigin","botTokenEnv","senderTokenEnv","signingSecretEnv","configurationTokenEnv"] as const;
|
||||
const methods={bot:new Set(["auth.test","conversations.info","conversations.replies","conversations.history","chat.postMessage","files.info"]),sender:new Set(["auth.test","chat.postMessage","files.getUploadURLExternal","files.completeUploadExternal"]),configuration:new Set(["apps.manifest.export","apps.manifest.update"])};
|
||||
export const slackCompanionFields=["workspaceId","channelId","senderUserId","botUserId","appId","registeredWebhookOrigin","webhookRelayPort","botTokenEnv","senderTokenEnv","signingSecretEnv","configurationTokenEnv"] as const;
|
||||
const methods={bot:new Set(["auth.test","users.info","bots.info","conversations.info","conversations.replies","conversations.history","chat.postMessage","files.info"]),sender:new Set(["auth.test","chat.postMessage","files.getUploadURLExternal","files.completeUploadExternal"]),configuration:new Set(["apps.manifest.export","apps.manifest.update"])};
|
||||
export function assertSlackFixture(binding:ProviderBinding){
|
||||
const p=binding.prerequisites;
|
||||
for(const field of slackCompanionFields)if(!p[field])throw new Error(`Setup blocker: Slack ${field} reference is required`);
|
||||
@@ -23,16 +23,43 @@ export function assertSlackCall(binding:ProviderBinding,kind:keyof typeof method
|
||||
/** Official Slack HTTP only. No fixture call occurs during import/calibration. */
|
||||
export function slackProvider(ctx:ChatCatalogContext,binding:ProviderBinding,fetcher:typeof fetch=fetch){
|
||||
assertSlackFixture(binding);let calls=0;const receipts:Array<{kind:string;method:string;ok:boolean}>=[];
|
||||
return {receipts,async call<T extends Record<string,unknown>>(kind:keyof typeof methods,method:string,parameters:Record<string,unknown>,signal?:AbortSignal):Promise<T>{
|
||||
assertSlackCall(binding,kind,method,parameters);if(++calls>40)throw new Error("Slack fixture exceeded its declared bounded provider calls");
|
||||
const ownedMessages:Array<{kind:"sender"|"bot";ts:string}>=[],ownedFiles:string[]=[],ownedThreads=new Set<string>();
|
||||
async function invoke<T extends Record<string,unknown>>(kind:keyof typeof methods,method:string,parameters:Record<string,unknown>,signal?:AbortSignal):Promise<T>{
|
||||
if(++calls>120)throw new Error("Slack fixture exceeded its declared bounded provider calls");
|
||||
const token=selectedExternalCredential(ctx,binding,kind==="bot"?"botTokenEnv":kind==="sender"?"senderTokenEnv":"configurationTokenEnv");
|
||||
const response=await fetcher(`https://slack.com/api/${method}`,{method:"POST",redirect:"error",signal:signal?AbortSignal.any([signal,AbortSignal.timeout(20000)]):AbortSignal.timeout(20000),headers:{authorization:`Bearer ${token}`,"content-type":"application/json; charset=utf-8"},body:JSON.stringify(parameters)});
|
||||
if(!response.ok){receipts.push({kind,method,ok:false});await response.body?.cancel();throw new Error(`Slack fixture ${method} returned HTTP ${response.status}; private response withheld`);}
|
||||
const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length>2*1024*1024)throw new Error("Slack fixture response exceeds bounded metadata size");
|
||||
let result:T;try{result=JSON.parse(bytes.toString("utf8")) as T;}catch{throw new Error("Slack fixture returned invalid metadata; response withheld");}
|
||||
const chunks:Uint8Array[]=[];let size=0;const reader=response.body?.getReader();if(!reader)throw new Error("Slack fixture response is missing");
|
||||
try{for(;;){const part=await reader.read();if(part.done)break;size+=part.value.byteLength;if(size>2*1024*1024)throw new Error("Slack fixture response exceeds bounded metadata size");chunks.push(part.value);}}finally{await reader.cancel();}
|
||||
let result:T;try{result=JSON.parse(Buffer.concat(chunks).toString("utf8")) as T;}catch{throw new Error("Slack fixture returned invalid metadata; response withheld");}
|
||||
receipts.push({kind,method,ok:result.ok===true});if(result.ok!==true)throw new Error(`Slack fixture ${method} was not accepted; provider error details withheld`);return result;
|
||||
}
|
||||
return {receipts,async call<T extends Record<string,unknown>>(kind:keyof typeof methods,method:string,parameters:Record<string,unknown>,signal?:AbortSignal):Promise<T>{
|
||||
assertSlackCall(binding,kind,method,parameters);const result=await invoke<T>(kind,method,parameters,signal);
|
||||
if(method==="chat.postMessage"&&kind!=="configuration"&&result.channel===binding.prerequisites.channelId&&typeof result.ts==="string"){ownedMessages.push({kind,ts:result.ts});ownedThreads.add(typeof parameters.thread_ts==="string"?parameters.thread_ts:result.ts);}
|
||||
if(method==="files.completeUploadExternal"&&kind==="sender"&&Array.isArray(result.files))for(const file of result.files){if(file&&typeof file.id==="string")ownedFiles.push(file.id);}
|
||||
return result;
|
||||
},
|
||||
/** No caller-supplied deletion IDs: only receipts from this client's actual
|
||||
* successful creation calls may be removed. Existing channel content is never
|
||||
* enumerated for cleanup or adopted as owned. */
|
||||
async cleanupOwned(){
|
||||
const failures:string[]=[];
|
||||
// Product-published replies are owned only through this client's genuinely
|
||||
// created roots. Read each exact thread, then delete only this app's bot
|
||||
// messages; never adopt another author's content as disposable.
|
||||
const threads=[...ownedThreads];ownedThreads.clear();
|
||||
for(const ts of threads)try{
|
||||
const thread=await invoke<{ok:true;messages:Array<{ts:string;thread_ts?:string;user?:string}>;has_more?:boolean}>("bot","conversations.replies",{channel:binding.prerequisites.channelId,ts,limit:100});
|
||||
if(thread.has_more)throw new Error("Owned Slack cleanup exceeds finite thread bound");
|
||||
for(const message of thread.messages??[])if(message.user===binding.prerequisites.botUserId&&message.thread_ts===ts)await invoke("bot","chat.delete",{channel:binding.prerequisites.channelId,ts:message.ts});
|
||||
}catch{failures.push("owned-thread-replies");}
|
||||
for(const file of ownedFiles.splice(0).reverse())try{await invoke("sender","files.delete",{file});}catch{failures.push("owned-file");}
|
||||
for(const message of ownedMessages.splice(0).reverse())try{await invoke(message.kind,"chat.delete",{channel:binding.prerequisites.channelId,ts:message.ts});}catch{failures.push("owned-message");}
|
||||
if(failures.length)throw new Error(`Slack disposable cleanup incomplete: ${failures.length} owned resources could not be removed`);
|
||||
}};
|
||||
}
|
||||
|
||||
export interface SlackIngressReceipt {providerCalls:number;query:string;endpointId:string;conversationId:string;issueId:string;humanCommentId:string;linkedPaperclipUserId:string;provider:{workspaceId:string;channelId:string;threadId:string;canonicalMessageId:string;senderUserId:string}}
|
||||
/** Source-qualified public DTO checks; externalThreadId is the SDK encoded key. */
|
||||
export function slackIngressMatches(input:{receipt:SlackIngressReceipt;endpoint:ChatEndpoint;conversation:ChatConversation;binding:ExternalChannelBindingSummary;comment:{id:string;body:string;authorUserId:string|null;metadata?:{sourceChannel?:string}};canonicalQuery:string;companyId:string;agentId:string}){
|
||||
|
||||
Reference in new issue
Block a user