399 lines
16 KiB
TypeScript
399 lines
16 KiB
TypeScript
/**
|
|
* Cumulative stages of the example's files, built up one piece at a time. Each
|
|
* stage's `code` is the full file at that point; <FileBuildup> diffs consecutive
|
|
* stages so the reader watches the file grow from a basic agent to the full bot.
|
|
*/
|
|
|
|
export interface BuildStage {
|
|
title: string;
|
|
description: string;
|
|
/** Full file contents at this stage. */
|
|
code: string;
|
|
}
|
|
|
|
// ── shared bot.ts parts ─────────────────────────────────────────────────────
|
|
|
|
const head1 = `import { Composio } from '@composio/core';
|
|
import { PiProvider } from '@composio/experimental';
|
|
import { createAgentSession, SessionManager } from '@earendil-works/pi-coding-agent';
|
|
|
|
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });
|
|
const piProvider = new PiProvider();
|
|
`;
|
|
|
|
const head2 = `import { Composio } from '@composio/core';
|
|
import type { IncomingTriggerPayload } from '@composio/core';
|
|
import { PiProvider } from '@composio/experimental';
|
|
import { createAgentSession, SessionManager } from '@earendil-works/pi-coding-agent';
|
|
|
|
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });
|
|
const piProvider = new PiProvider();
|
|
const callbackUrl = \`\${process.env.APP_URL}/connections/callback\`;
|
|
`;
|
|
|
|
const head2Shared = `import { Composio } from '@composio/core';
|
|
import type { IncomingTriggerPayload } from '@composio/core';
|
|
import { PiProvider } from '@composio/experimental';
|
|
import { createAgentSession, SessionManager } from '@earendil-works/pi-coding-agent';
|
|
|
|
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });
|
|
const piProvider = new PiProvider();
|
|
const callbackUrl = \`\${process.env.APP_URL}/connections/callback\`;
|
|
|
|
// One Slack connection, shared by the whole workspace. The bot posts as this
|
|
// identity while acting in every other app as the individual user.
|
|
const SHARED_SLACK_CONNECTION_ID = process.env.SLACK_CONNECTION_ID;
|
|
`;
|
|
|
|
const runPi = `
|
|
// Run the Pi agent over a session's tools and return its final text.
|
|
async function runPi(tools: unknown, prompt: string) {
|
|
const { session: pi } = await createAgentSession({
|
|
sessionManager: SessionManager.inMemory(process.cwd()),
|
|
customTools: tools,
|
|
tools: ['composio_search_tools', 'composio_manage_connections', 'composio_execute_tool'],
|
|
});
|
|
let reply = '';
|
|
pi.subscribe((e) => {
|
|
if (e.type === 'message_update' && e.assistantMessageEvent.type === 'text_delta') {
|
|
reply += e.assistantMessageEvent.delta;
|
|
}
|
|
});
|
|
await pi.prompt(prompt);
|
|
pi.dispose();
|
|
return reply;
|
|
}
|
|
`;
|
|
|
|
const runAgent = `
|
|
// The smallest agent: one session, its tools, one prompt.
|
|
export async function runAgent(userId: string, prompt: string) {
|
|
const session = await composio.create(userId);
|
|
const tools = piProvider.createSessionTools({
|
|
sessionId: session.sessionId,
|
|
search: (params) => session.search(params),
|
|
execute: (slug, args, options) => session.execute(slug, args, options),
|
|
});
|
|
return runPi(tools, prompt);
|
|
}
|
|
`;
|
|
|
|
const memNoPin = `
|
|
// One session per Slack thread, reused so the agent keeps context, with a short
|
|
// transcript for memory across turns.
|
|
const threads = new Map<string, { sessionId: string; history: { role: string; content: string }[] }>();
|
|
const threadKey = (event: IncomingTriggerPayload) =>
|
|
\`\${event.payload?.channel}:\${event.payload?.thread_ts ?? event.payload?.ts}\`;
|
|
|
|
async function sessionForThread(event: IncomingTriggerPayload) {
|
|
const key = threadKey(event);
|
|
const existing = threads.get(key);
|
|
if (existing) return { session: await composio.use(existing.sessionId), memory: existing };
|
|
|
|
const session = await composio.create(event.userId, {
|
|
manageConnections: { enable: true, callbackUrl, waitForConnections: true },
|
|
});
|
|
const memory = { sessionId: session.sessionId, history: [] as { role: string; content: string }[] };
|
|
threads.set(key, memory);
|
|
return { session, memory };
|
|
}
|
|
`;
|
|
|
|
const memPin = `
|
|
// One session per Slack thread, reused so the agent keeps context, with a short
|
|
// transcript for memory across turns.
|
|
const threads = new Map<string, { sessionId: string; history: { role: string; content: string }[] }>();
|
|
const threadKey = (event: IncomingTriggerPayload) =>
|
|
\`\${event.payload?.channel}:\${event.payload?.thread_ts ?? event.payload?.ts}\`;
|
|
|
|
async function sessionForThread(event: IncomingTriggerPayload) {
|
|
const key = threadKey(event);
|
|
const existing = threads.get(key);
|
|
if (existing) return { session: await composio.use(existing.sessionId), memory: existing };
|
|
|
|
const session = await composio.create(event.userId, {
|
|
// Pin the shared Slack connection; the session still resolves every other
|
|
// toolkit against this user's own connections.
|
|
connectedAccounts: { slackbot: [SHARED_SLACK_CONNECTION_ID] },
|
|
manageConnections: { enable: true, callbackUrl, waitForConnections: true },
|
|
});
|
|
const memory = { sessionId: session.sessionId, history: [] as { role: string; content: string }[] };
|
|
threads.set(key, memory);
|
|
return { session, memory };
|
|
}
|
|
`;
|
|
|
|
const proxy = `
|
|
// Anything the toolkit doesn't wrap as a tool, reach via the proxy: it calls the
|
|
// Slack Web API with the pinned connection's auth, so you never touch a token.
|
|
async function setStatus(session, event: IncomingTriggerPayload, status: string) {
|
|
await session
|
|
.proxyExecute({
|
|
toolkit: 'slackbot',
|
|
endpoint: 'https://slack.com/api/assistant.threads.setStatus',
|
|
method: 'POST',
|
|
body: { channel_id: event.payload?.channel, thread_ts: event.payload?.thread_ts, status },
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
async function openDm(session, userId: string): Promise<string> {
|
|
const res = await session.proxyExecute({
|
|
toolkit: 'slackbot',
|
|
endpoint: 'https://slack.com/api/conversations.open',
|
|
method: 'POST',
|
|
body: { users: userId },
|
|
});
|
|
return res.data?.channel?.id;
|
|
}
|
|
`;
|
|
|
|
const auth = `
|
|
// Redirect auth links. When a tool hits an app the user hasn't connected, the
|
|
// result carries a one-time Composio connect URL. Never let the model or the
|
|
// channel see it: redact it from the tool output and DM the user privately. The
|
|
// session's manageConnections + waitForConnections resumes the run on approval.
|
|
const CONNECT_LINK = /https:\\/\\/(?:connect\\.composio\\.dev|[^\\s"']*composio[^\\s"']*\\/link)\\/[^\\s"')]+/gi;
|
|
|
|
function redactLinks<T>(value: T): T {
|
|
if (typeof value === 'string') return value.replace(CONNECT_LINK, '[connection link sent via DM]') as T;
|
|
if (Array.isArray(value)) return value.map(redactLinks) as T;
|
|
if (value && typeof value === 'object') {
|
|
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactLinks(v)])) as T;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function handleAuthLinks<T>(session, event: IncomingTriggerPayload, value: T): Promise<T> {
|
|
const links = [...new Set([...JSON.stringify(value ?? '').matchAll(CONNECT_LINK)].map((m) => m[0]))];
|
|
if (links.length > 0) {
|
|
const dm = await openDm(session, event.userId);
|
|
for (const url of links) {
|
|
await session.execute('SLACKBOT_SEND_MESSAGE', {
|
|
channel: dm,
|
|
text: \`*Connection needed.* Approve access and I'll continue automatically:\\n<\${url}|Connect>\`,
|
|
});
|
|
}
|
|
}
|
|
return redactLinks(value); // hand the model a result with the raw URL stripped
|
|
}
|
|
`;
|
|
|
|
const toolsNoAuth = `
|
|
function toolsForSession(session) {
|
|
return piProvider.createSessionTools({
|
|
sessionId: session.sessionId,
|
|
callbackUrl,
|
|
search: (params) => session.search(params),
|
|
execute: (slug, args, options) => session.execute(slug, args, options),
|
|
connections: {
|
|
getToolkitStates: (toolkits) => session.toolkits({ toolkits }),
|
|
authorizeToolkit: async (toolkit) => {
|
|
const request = await session.authorize(toolkit, { callbackUrl });
|
|
return { status: 'needs_connection', redirectUrl: request.redirectUrl };
|
|
},
|
|
isConnected: (state) => state.connection?.isActive ?? false,
|
|
},
|
|
});
|
|
}
|
|
`;
|
|
|
|
const toolsAuth = `
|
|
function toolsForSession(session, event: IncomingTriggerPayload) {
|
|
return piProvider.createSessionTools({
|
|
sessionId: session.sessionId,
|
|
callbackUrl,
|
|
search: (params) => session.search(params),
|
|
// Every tool result passes through handleAuthLinks: connect URLs get DM'd to
|
|
// the user and redacted before the model ever sees them.
|
|
execute: async (slug, args, options) => handleAuthLinks(session, event, await session.execute(slug, args, options)),
|
|
connections: {
|
|
getToolkitStates: (toolkits) => session.toolkits({ toolkits }),
|
|
authorizeToolkit: async (toolkit) => {
|
|
const request = await session.authorize(toolkit, { callbackUrl });
|
|
await handleAuthLinks(session, event, request.redirectUrl);
|
|
return { status: 'needs_connection', redirectUrl: request.redirectUrl };
|
|
},
|
|
isConnected: (state) => state.connection?.isActive ?? false,
|
|
},
|
|
});
|
|
}
|
|
`;
|
|
|
|
const handleNoStatus = `
|
|
// Reply to one Slack message as the user who sent it.
|
|
async function handleSlackMessage(event: IncomingTriggerPayload) {
|
|
const { session, memory } = await sessionForThread(event);
|
|
const prompt = [...memory.history.map((m) => \`\${m.role}: \${m.content}\`), \`user: \${event.payload?.text}\`].join('\\n');
|
|
|
|
const reply = await runPi(toolsForSession(session), prompt);
|
|
|
|
await session.execute('SLACKBOT_SEND_MESSAGE', {
|
|
channel: event.payload?.channel,
|
|
thread_ts: event.payload?.thread_ts,
|
|
text: reply,
|
|
});
|
|
memory.history.push({ role: 'user', content: event.payload?.text ?? '' }, { role: 'assistant', content: reply });
|
|
}
|
|
`;
|
|
|
|
const handleStatus = `
|
|
// Reply to one Slack message as the user who sent it.
|
|
async function handleSlackMessage(event: IncomingTriggerPayload) {
|
|
const { session, memory } = await sessionForThread(event);
|
|
await setStatus(session, event, 'Working on it…');
|
|
|
|
const prompt = [...memory.history.map((m) => \`\${m.role}: \${m.content}\`), \`user: \${event.payload?.text}\`].join('\\n');
|
|
const reply = await runPi(toolsForSession(session, event), prompt);
|
|
|
|
await session.execute('SLACKBOT_SEND_MESSAGE', {
|
|
channel: event.payload?.channel,
|
|
thread_ts: event.payload?.thread_ts,
|
|
text: reply,
|
|
});
|
|
memory.history.push({ role: 'user', content: event.payload?.text ?? '' }, { role: 'assistant', content: reply });
|
|
}
|
|
`;
|
|
|
|
// handleStatus calls toolsForSession(session, event); for the proxy stage (before
|
|
// auth) toolsForSession still takes one arg, so use a variant without the event.
|
|
const handleStatusNoAuth = handleStatus.replace('toolsForSession(session, event)', 'toolsForSession(session)');
|
|
|
|
const serve = `
|
|
Bun.serve({
|
|
port: 3000,
|
|
async fetch(req) {
|
|
const url = new URL(req.url);
|
|
if (req.method === 'POST' && url.pathname === '/webhooks/composio') {
|
|
const { payload } = await composio.triggers.verifyWebhook({
|
|
payload: await req.text(),
|
|
secret: process.env.COMPOSIO_WEBHOOK_SECRET,
|
|
id: req.headers.get('webhook-id'),
|
|
timestamp: req.headers.get('webhook-timestamp'),
|
|
signature: req.headers.get('webhook-signature'),
|
|
});
|
|
void handleSlackMessage(payload);
|
|
return Response.json({ ok: true });
|
|
}
|
|
return new Response('Not found', { status: 404 });
|
|
},
|
|
});
|
|
`;
|
|
|
|
const BOT_STAGES: { title: string; description: string; parts: string[] }[] = [
|
|
{
|
|
title: 'A basic agent',
|
|
description:
|
|
'Start from scratch. The smallest possible agent: create a session for a user, hand the Pi provider the session so it can search and execute, run a prompt. This already works against every app that user has connected, with no Slack yet.',
|
|
parts: [head1, runPi, runAgent],
|
|
},
|
|
{
|
|
title: 'Wire it to Slack threads',
|
|
description:
|
|
'Turn the one-shot agent into a handler. Each Slack thread gets its own session (reused for memory), the agent runs over the session tools, and the reply is posted with the SLACKBOT_SEND_MESSAGE tool.',
|
|
parts: [head2, memNoPin, toolsNoAuth, runPi, handleNoStatus],
|
|
},
|
|
{
|
|
title: 'Share one workspace connection',
|
|
description:
|
|
'Pin a single SHARED Slack connection into every session. Now the bot posts as the workspace bot, but still acts in GitHub, Gmail, and everything else as the individual user.',
|
|
parts: [head2Shared, memPin, toolsNoAuth, runPi, handleNoStatus],
|
|
},
|
|
{
|
|
title: 'Reach the gaps with the proxy',
|
|
description:
|
|
'Add a typing indicator and a DM-channel opener. Neither has a SLACKBOT_* tool, so they drop down to the proxy, which calls the Slack Web API with the pinned connection.',
|
|
parts: [head2Shared, memPin, proxy, toolsNoAuth, runPi, handleStatusNoAuth],
|
|
},
|
|
{
|
|
title: 'Redirect auth links',
|
|
description:
|
|
'The payoff. When the agent touches an app the user has not connected, the tool result carries a one-time connect URL. Redact it from the model and the channel, DM it to the user privately, and let the run resume on approval.',
|
|
parts: [head2Shared, memPin, proxy, auth, toolsAuth, runPi, handleStatus],
|
|
},
|
|
{
|
|
title: 'Serve the webhook',
|
|
description:
|
|
'Verify each trigger signature and dispatch the work off the response path. This is the whole server.',
|
|
parts: [head2Shared, memPin, proxy, auth, toolsAuth, runPi, handleStatus, serve],
|
|
},
|
|
];
|
|
|
|
export const BOT_BUILD_STAGES: BuildStage[] = BOT_STAGES.map((s) => ({
|
|
title: s.title,
|
|
description: s.description,
|
|
code: s.parts.join('').replace(/^\n/, ''),
|
|
}));
|
|
|
|
// ── install.ts ──────────────────────────────────────────────────────────────
|
|
|
|
const installAuth = `import { Composio } from '@composio/core';
|
|
|
|
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });
|
|
|
|
// The scopes the bot needs. The slackbot toolkit ships Composio-managed OAuth,
|
|
// so you never register your own Slack app.
|
|
const authConfig = await composio.authConfigs.create('slackbot', {
|
|
type: 'use_composio_managed_auth',
|
|
name: 'workspace-bot',
|
|
credentials: {
|
|
scopes: ['app_mentions:read', 'channels:history', 'chat:write', 'reactions:write', 'users:read'],
|
|
user_scopes: ['search:read'],
|
|
},
|
|
});
|
|
`;
|
|
|
|
const installAuthorize = `
|
|
// One connection for the whole workspace: authorize it as SHARED.
|
|
const setup = await composio.create('setup:workspace-bot', {
|
|
toolkits: ['slackbot'],
|
|
authConfigs: { slackbot: authConfig.id },
|
|
manageConnections: true,
|
|
});
|
|
const request = await setup.authorize('slackbot', {
|
|
callbackUrl: \`\${process.env.APP_URL}/setup/callback\`,
|
|
experimental: { accountType: 'SHARED' },
|
|
});
|
|
console.log('Approve the install:', request.redirectUrl);
|
|
`;
|
|
|
|
const installWire = `
|
|
// On the OAuth callback: open the ACL, subscribe your webhook, create triggers.
|
|
// Persist connectedAccountId as SLACK_CONNECTION_ID for the bot server.
|
|
export async function onSetupCallback(connectedAccountId: string) {
|
|
await composio.connectedAccounts.updateAcl(connectedAccountId, { allowAllUsers: true });
|
|
await composio.triggers.setWebhookSubscription({ webhookUrl: \`\${process.env.APP_URL}/webhooks/composio\` });
|
|
await composio.triggers.create('setup:workspace-bot', 'SLACKBOT_CHANNEL_MESSAGE_RECEIVED', { triggerConfig: { is_bot_message: false } });
|
|
await composio.triggers.create('setup:workspace-bot', 'SLACKBOT_DIRECT_MESSAGE_RECEIVED', { triggerConfig: {} });
|
|
}
|
|
`;
|
|
|
|
const INSTALL_STAGES: { title: string; description: string; parts: string[] }[] = [
|
|
{
|
|
title: 'Declare the scopes',
|
|
description: 'Create a Composio-managed auth config for the slackbot toolkit. No Slack app of your own to register.',
|
|
parts: [installAuth],
|
|
},
|
|
{
|
|
title: 'Authorize one shared connection',
|
|
description: 'Start a setup session and authorize slackbot as a SHARED connection, so a single approval serves every user.',
|
|
parts: [installAuth, installAuthorize],
|
|
},
|
|
{
|
|
title: 'Open it up and wire events',
|
|
description: 'On the callback, open the ACL to the workspace, subscribe your webhook, and create the message triggers.',
|
|
parts: [installAuth, installAuthorize, installWire],
|
|
},
|
|
];
|
|
|
|
export const INSTALL_BUILD_STAGES: BuildStage[] = INSTALL_STAGES.map((s) => ({
|
|
title: s.title,
|
|
description: s.description,
|
|
code: s.parts.join(''),
|
|
}));
|
|
|
|
export const FILE_BUILDS: Record<string, { file: string; stages: BuildStage[] }> = {
|
|
install: { file: 'install.ts', stages: INSTALL_BUILD_STAGES },
|
|
bot: { file: 'bot.ts', stages: BOT_BUILD_STAGES },
|
|
};
|