42 lines
59 KiB
JSON
42 lines
59 KiB
JSON
[
|
|
{
|
|
"path": "src/index.ts",
|
|
"composio": false,
|
|
"contents": "import { Composio } from '@composio/core';\n\nimport { createAuthCallbackHandler } from './api/auth-callback';\nimport { createComposioWebhookHandler } from './api/composio-webhook';\nimport { createApiHandler } from './api/router';\nimport { createSlackComposioAgent } from './agent/slack-composio-agent';\nimport { createSlackbotSetupService } from './composio/slackbot-setup';\nimport { loadConfig } from './config/env';\nimport { createSlackTransport } from './slack/transport';\nimport { createFileStore } from './store/file-store';\n\nconst config = loadConfig();\nconst store = await createFileStore(config.dataDir);\nconst composio = new Composio({ apiKey: config.composioApiKey });\nconst slack = createSlackTransport({ config, store });\nconst agent = createSlackComposioAgent({ config, slack, store });\nconst slackbotSetup = createSlackbotSetupService({ composio, config, store });\nconst composioWebhook = createComposioWebhookHandler({ agent, config, store });\nconst authCallback = createAuthCallbackHandler({ agent, slack, store });\n\nconst server = Bun.serve({\n hostname: config.host,\n port: config.port,\n fetch: createApiHandler({ authCallback, composioWebhook, slackbotSetup }),\n});\n\nconsole.log(`Composio Slack Pi bot listening on http://${server.hostname}:${server.port}`);\nconsole.log(`Setup URL: ${config.appUrl}/setup/slackbot/start`);\n\n"
|
|
},
|
|
{
|
|
"path": "src/config/env.ts",
|
|
"composio": false,
|
|
"contents": "import 'dotenv/config';\nimport { join } from 'node:path';\n\nconst DEFAULT_SLACKBOT_BOT_SCOPES = [\n 'app_mentions:read',\n 'assistant:write',\n 'channels:history',\n 'channels:read',\n 'chat:write',\n 'files:read',\n 'files:write',\n 'groups:history',\n 'groups:read',\n 'im:history',\n 'im:read',\n 'im:write',\n 'links:read',\n 'links:write',\n 'mpim:history',\n 'mpim:read',\n 'mpim:write',\n 'reactions:read',\n 'reactions:write',\n 'team:read',\n 'users:read',\n];\n\nconst DEFAULT_SLACKBOT_USER_SCOPES = [\n 'channels:history',\n 'channels:read',\n 'files:read',\n 'groups:history',\n 'groups:read',\n 'im:history',\n 'im:read',\n 'mpim:history',\n 'mpim:read',\n 'reactions:read',\n 'search:read',\n 'search:read.public',\n 'users.profile:read',\n 'users:read',\n 'users:read.email',\n];\n\nexport interface AppConfig {\n appUrl: string;\n composioApiKey: string;\n composioToolkits?: string[];\n composioAuthConfigs?: Record<string, string>;\n autoAnalyzeMainUserMessages: boolean;\n autoPromoteFirstMainUser: boolean;\n composioWorkbenchEnabled: boolean;\n dataDir: string;\n host: string;\n piCwd: string;\n ignoreReactionName: string;\n mainSlackUserIds: string[];\n piWorkbenchEnabled: boolean;\n port: number;\n postPublicAuthDmNotice: boolean;\n slackBotUserId?: string;\n slackbotAuthConfigId?: string;\n slackbotAuthConfigName: string;\n slackbotBotScopes: string[];\n slackbotSharedConnectedAccountId?: string;\n slackbotTransportUserId?: string;\n slackbotUserScopes: string[];\n}\n\nfunction requiredEnv(name: string): string {\n const value = process.env[name];\n if (!value || value.trim().length === 0) {\n throw new Error(`Missing required environment variable ${name}`);\n }\n return value.trim();\n}\n\nfunction optionalList(name: string): string[] | undefined {\n return optionalRawList(name)?.map((value) => value.toLowerCase());\n}\n\nfunction optionalRawList(name: string): string[] | undefined {\n const raw = process.env[name];\n if (!raw) return undefined;\n\n const values = raw\n .split(',')\n .map((value) => value.trim())\n .filter(Boolean);\n\n return values.length > 0 ? values : undefined;\n}\n\nfunction booleanEnv(name: string, defaultValue: boolean): boolean {\n const raw = process.env[name];\n if (!raw) return defaultValue;\n return ['1', 'true', 'yes', 'y', 'on'].includes(raw.trim().toLowerCase());\n}\n\nfunction numberEnv(name: string, defaultValue: number): number {\n const raw = process.env[name];\n if (!raw) return defaultValue;\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n throw new Error(`Invalid numeric environment variable ${name}: ${raw}`);\n }\n return value;\n}\n\nfunction optionalEnv(name: string): string | undefined {\n const value = process.env[name];\n return value && value.trim() ? value.trim() : undefined;\n}\n\nfunction parseAuthConfigs(): Record<string, string> | undefined {\n const configs: Record<string, string> = {};\n\n const json = process.env.COMPOSIO_AUTH_CONFIGS_JSON;\n if (json && json.trim() && json.trim() !== '{}') {\n const parsed = JSON.parse(json) as Record<string, unknown>;\n for (const [toolkit, authConfigId] of Object.entries(parsed)) {\n if (typeof authConfigId === 'string' && authConfigId.trim()) {\n configs[toolkit.toLowerCase()] = authConfigId.trim();\n }\n }\n }\n\n for (const [key, value] of Object.entries(process.env)) {\n if (!key.startsWith('COMPOSIO_AUTH_CONFIG_') || key === 'COMPOSIO_AUTH_CONFIGS_JSON') {\n continue;\n }\n if (!value || !value.trim()) continue;\n\n const toolkit = key\n .slice('COMPOSIO_AUTH_CONFIG_'.length)\n .toLowerCase()\n .replaceAll('__', '-')\n .replaceAll('_', '');\n\n configs[toolkit] = value.trim();\n }\n\n return Object.keys(configs).length > 0 ? configs : undefined;\n}\n\nexport function loadConfig(): AppConfig {\n const port = numberEnv('PORT', 3000);\n const rawAppUrl = optionalEnv('APP_URL');\n const appUrl = (!rawAppUrl || rawAppUrl.includes('your-public-url.example.com')\n ? `http://localhost:${port}`\n : rawAppUrl\n ).replace(/\\/$/, '');\n const dataDir = process.env.DATA_DIR?.trim() || '.data';\n const piWorkbenchEnabled = booleanEnv('PI_LOCAL_WORKBENCH_ENABLED', false);\n\n return {\n appUrl,\n autoAnalyzeMainUserMessages: booleanEnv('AUTO_ANALYZE_MAIN_USER_MESSAGES', true),\n autoPromoteFirstMainUser: booleanEnv('AUTO_PROMOTE_FIRST_MAIN_USER', true),\n composioApiKey: requiredEnv('COMPOSIO_API_KEY'),\n composioToolkits: optionalList('COMPOSIO_TOOLKITS'),\n composioAuthConfigs: parseAuthConfigs(),\n composioWorkbenchEnabled: booleanEnv('COMPOSIO_WORKBENCH_ENABLED', true),\n dataDir,\n host: process.env.HOST?.trim() || '0.0.0.0',\n ignoreReactionName: process.env.IGNORE_REACTION_NAME?.trim() || 'ignore',\n mainSlackUserIds: optionalRawList('MAIN_SLACK_USER_IDS') ?? [],\n piCwd: process.env.PI_CWD?.trim() || (piWorkbenchEnabled ? join(dataDir, 'pi-local-workbench') : process.cwd()),\n piWorkbenchEnabled,\n port,\n postPublicAuthDmNotice: booleanEnv('POST_PUBLIC_AUTH_DM_NOTICE', true),\n slackBotUserId: optionalEnv('SLACK_BOT_USER_ID'),\n slackbotAuthConfigId: optionalEnv('SLACKBOT_AUTH_CONFIG_ID'),\n slackbotAuthConfigName: optionalEnv('SLACKBOT_AUTH_CONFIG_NAME') ?? 'slackbot-expanded-scopes',\n slackbotBotScopes: optionalRawList('SLACKBOT_BOT_SCOPES') ?? DEFAULT_SLACKBOT_BOT_SCOPES,\n slackbotSharedConnectedAccountId: optionalEnv('SLACKBOT_SHARED_CONNECTED_ACCOUNT_ID'),\n slackbotTransportUserId: optionalEnv('SLACKBOT_TRANSPORT_USER_ID'),\n slackbotUserScopes: optionalRawList('SLACKBOT_USER_SCOPES') ?? DEFAULT_SLACKBOT_USER_SCOPES,\n };\n}\n"
|
|
},
|
|
{
|
|
"path": "src/composio/slackbot-setup.ts",
|
|
"composio": true,
|
|
"contents": "import { createHash, randomUUID } from 'node:crypto';\n\nimport { Composio } from '@composio/core';\n\nimport { jsonResponse } from '../api/http';\nimport type { AppConfig } from '../config/env';\nimport { htmlPages } from '../html/pages';\nimport { htmlFileResponse, htmlTemplateResponse } from '../html/render';\nimport { proxySlack } from '../slack/transport';\nimport type { Store } from '../store/file-store';\nimport { firstString, jsonObject } from '../utils/decode';\nimport type { JsonObject, JsonValue } from '../utils/json';\nimport { createComposioApiClient } from './api';\n\ntype ComposioSession = Awaited<ReturnType<Composio['create']>>;\n\ninterface SharedSlackbotAuthorizeOptions {\n callbackUrl: string;\n experimental: {\n accountType: 'SHARED';\n };\n}\n\ninterface SlackbotSetupDeps {\n composio: Composio;\n config: AppConfig;\n store: Store;\n}\n\nexport interface SlackbotSetupService {\n completeSetup(url: URL): Promise<Response>;\n ensureSlackbotAuthConfig(): Promise<string | undefined>;\n ensureSlackbotTriggers(connectedAccountId: string): Promise<void>;\n ensureWebhookSubscription(): Promise<void>;\n startSetup(): Promise<Response>;\n}\n\n/**\n * Owns the install-time Slackbot plumbing:\n * - create/reuse a managed Slackbot auth config with the requested scopes\n * - request a shared Slackbot connection\n * - persist the team-scoped connected account\n * - ensure Composio webhook subscription + Slackbot triggers exist\n */\nexport function createSlackbotSetupService({ composio, config, store }: SlackbotSetupDeps): SlackbotSetupService {\n const composioApi = createComposioApiClient(config);\n\n const ensureSlackbotAuthConfig = async (): Promise<string | undefined> => {\n if (config.slackbotAuthConfigId) {\n return config.slackbotAuthConfigId;\n }\n\n const botScopes = uniqueSorted(config.slackbotBotScopes);\n const userScopes = uniqueSorted(config.slackbotUserScopes);\n if (botScopes.length === 0 && userScopes.length === 0) {\n return undefined;\n }\n\n const name = slackbotAuthConfigName(config, botScopes, userScopes);\n const existing = await composio.authConfigs.list({ toolkit: 'slackbot', isComposioManaged: true, limit: 100 });\n const match = authConfigItems(existing).find((item) => item.name === name);\n if (match?.id) {\n return match.id;\n }\n\n const created = await composio.authConfigs.create('slackbot', {\n type: 'use_composio_managed_auth',\n name,\n credentials: {\n scopes: botScopes,\n user_scopes: userScopes,\n },\n });\n console.log(`Created Slackbot Composio auth config ${created.id} (${name})`);\n return created.id;\n };\n\n const ensureWebhookSubscription = async (): Promise<void> => {\n const webhookUrl = `${config.appUrl}/webhooks/composio`;\n const body = {\n webhook_url: webhookUrl,\n enabled_events: ['composio.trigger.message'],\n version: 'V3',\n };\n\n const existing = await composioApi.request('/api/v3.1/webhook_subscriptions', { method: 'GET' });\n const subscription = firstObjectItem(existing?.items);\n const subscriptionId = firstString(subscription?.id);\n\n if (subscriptionId) {\n await composioApi.request(`/api/v3.1/webhook_subscriptions/${subscriptionId}`, {\n method: 'PATCH',\n body,\n });\n return;\n }\n\n await composioApi.request('/api/v3.1/webhook_subscriptions', {\n method: 'POST',\n body,\n });\n };\n\n const ensureSlackbotTriggers = async (connectedAccountId: string): Promise<void> => {\n const connectedAccount = await composioApi.request(`/api/v3/connected_accounts/${connectedAccountId}`, { method: 'GET' });\n const ownerUserId = firstString(connectedAccount?.user_id);\n if (!ownerUserId) {\n throw new Error(`Could not determine owner user_id for connected account ${connectedAccountId}`);\n }\n\n await upsertTrigger(composioApi.request, 'SLACKBOT_CHANNEL_MESSAGE_RECEIVED', connectedAccountId, ownerUserId, {\n is_bot_message: false,\n });\n\n await upsertTrigger(composioApi.request, 'SLACKBOT_DIRECT_MESSAGE_RECEIVED', connectedAccountId, ownerUserId, {});\n };\n\n const startSetup = async (): Promise<Response> => {\n const setupId = randomUUID();\n const setupUserId = `setup:slackbot:${setupId}`;\n const authConfigId = await ensureSlackbotAuthConfig();\n const session = await composio.create(setupUserId, {\n toolkits: ['slackbot'],\n authConfigs: authConfigId ? { slackbot: authConfigId } : {},\n manageConnections: true,\n workbench: { enable: false },\n });\n await store.setSlackbotSetup(setupId, session.sessionId, setupUserId);\n\n const connectionRequest = await session.authorize('slackbot', sharedSlackbotAuthorizeOptions(config, setupId));\n if (!connectionRequest.redirectUrl) {\n return jsonResponse({ ok: false, error: 'missing_redirect_url' }, 500);\n }\n\n return Response.redirect(connectionRequest.redirectUrl, 302);\n };\n\n const completeSetup = async (url: URL): Promise<Response> => {\n const setupId = url.searchParams.get('setup');\n const connectedAccountId = url.searchParams.get('connected_account_id');\n const status = url.searchParams.get('status');\n const setup = setupId ? store.getSlackbotSetup(setupId) : undefined;\n\n if (!setupId || !setup) {\n return htmlFileResponse(htmlPages.slackbotSetupExpired, 400);\n }\n\n if (!connectedAccountId || status !== 'success') {\n return htmlFileResponse(htmlPages.slackbotSetupFailed, 400);\n }\n\n const setupSession = await composio.use(setup.sessionId);\n await setupSession.update({ connectedAccounts: { slackbot: [connectedAccountId] } });\n const slackAuth = await slackAuthTest(setupSession);\n const teamId = slackAuth.teamId;\n await composio.experimental.updateAcl(connectedAccountId, { allowAllUsers: true });\n\n await store.setSlackbotConnection(teamId, connectedAccountId, {\n botUserId: slackAuth.botUserId,\n teamName: slackAuth.teamName,\n });\n await store.deleteSlackbotSetup(setupId);\n\n await ensureWebhookSubscription().catch((error) => {\n console.error('Failed to configure Composio webhook subscription after setup', error);\n });\n await ensureSlackbotTriggers(connectedAccountId).catch((error) => {\n console.error('Failed to create Slackbot triggers after setup', error);\n });\n\n return htmlTemplateResponse(htmlPages.slackbotConnected, { teamName: slackAuth.teamName ?? teamId });\n };\n\n return {\n completeSetup,\n ensureSlackbotAuthConfig,\n ensureSlackbotTriggers,\n ensureWebhookSubscription,\n startSetup,\n };\n}\n\nfunction sharedSlackbotAuthorizeOptions(config: AppConfig, setupId: string): SharedSlackbotAuthorizeOptions {\n const callbackUrl = new URL('/setup/slackbot/callback', config.appUrl);\n callbackUrl.searchParams.set('setup', setupId);\n\n return {\n callbackUrl: callbackUrl.toString(),\n experimental: {\n accountType: 'SHARED',\n },\n };\n}\n\nfunction slackbotAuthConfigName(config: AppConfig, botScopes: string[], userScopes: string[]): string {\n const scopeHash = createHash('sha256')\n .update(JSON.stringify({ botScopes, userScopes }))\n .digest('hex')\n .slice(0, 8);\n return `${config.slackbotAuthConfigName}-${scopeHash}`;\n}\n\nasync function slackAuthTest(session: ComposioSession): Promise<{ botUserId?: string; teamId: string; teamName?: string }> {\n return decodeSlackAuthTest(await proxySlack(session, 'auth.test', {}));\n}\n\nfunction decodeSlackAuthTest(response: unknown): { botUserId?: string; teamId: string; teamName?: string } {\n const responseObject = jsonObject(response) ?? {};\n const data = jsonObject(responseObject.data) ?? responseObject;\n const nestedData = jsonObject(data.data);\n\n if (data.ok === false) {\n throw new Error(`Slack auth.test failed: ${firstString(data.error) ?? JSON.stringify(data)}`);\n }\n\n const teamId = firstString(data.team_id, data.teamId, nestedData?.team_id, nestedData?.teamId);\n if (!teamId) {\n throw new Error(`Slack auth.test did not return team_id: ${JSON.stringify(data)}`);\n }\n\n return {\n botUserId: firstString(data.user_id, data.userId, data.bot_user_id, data.botUserId, nestedData?.user_id),\n teamId,\n teamName: firstString(data.team, data.team_name, data.teamName, nestedData?.team),\n };\n}\n\nfunction authConfigItems(value: unknown): Array<{ id?: string; name?: string }> {\n const record = jsonObject(value) ?? {};\n const items = Array.isArray(record.items) ? record.items : [];\n return items.flatMap((item): Array<{ id?: string; name?: string }> => {\n const object = jsonObject(item);\n if (!object) return [];\n return [\n {\n id: firstString(object.id),\n name: firstString(object.name),\n },\n ];\n });\n}\n\nfunction uniqueSorted(values: string[]): string[] {\n return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();\n}\n\nfunction firstObjectItem(value: JsonValue | undefined): JsonObject | undefined {\n return Array.isArray(value) ? jsonObject(value[0]) : undefined;\n}\n\nasync function upsertTrigger(\n request: (path: string, init: { method: string; body?: JsonValue }) => Promise<JsonObject | undefined>,\n slug: string,\n connectedAccountId: string,\n userId: string,\n triggerConfig: JsonObject,\n): Promise<void> {\n await request(`/api/v3/trigger_instances/${slug}/upsert`, {\n method: 'POST',\n body: {\n connected_account_id: connectedAccountId,\n user_id: userId,\n trigger_config: triggerConfig,\n },\n });\n}\n"
|
|
},
|
|
{
|
|
"path": "src/composio/routing-with-pi-provider.ts",
|
|
"composio": true,
|
|
"contents": "import { Composio } from '@composio/core';\nimport { PiProvider, type PiSessionToolCapabilities } from '@composio/experimental';\n\nimport {\n authCallbackUrl,\n isSlackbotToolkit,\n isSlackbotToolSlug,\n isUserSlackToolSlug,\n normalizeRequestedToolkits,\n normalizeSearchToolkits,\n normalizeToolkitKey,\n normalizeToolkitStates,\n toolkitIsConnected,\n} from './helpers';\nimport type { AppConfig } from '../config/env';\nimport { assertSlackbotManualAcl } from '../slack/acl';\nimport type { NormalizedSlackTriggerEvent } from '../slack/events';\nimport type { Store } from '../store/file-store';\nimport type { JsonObject } from '../utils/json';\nimport type { ComposioRouting } from './routing';\n\ntype ComposioSession = Awaited<ReturnType<Composio['create']>>;\ntype HandleAuthLinks = <T>(value: T, sourceTool: string, requestedToolkits?: string[]) => Promise<T>;\n\ninterface ComposioRoutingWithPiProviderDeps {\n composio: Composio;\n config: AppConfig;\n store: Store;\n}\n\ninterface CapabilityDeps {\n config: AppConfig;\n event: NormalizedSlackTriggerEvent;\n handleAuthLinks: HandleAuthLinks;\n userSession: ComposioSession;\n}\n\nconst piProvider = new PiProvider();\n\n/**\n * Side-by-side experiment for using @composio/experimental's Pi provider.\n *\n * This uses the newer capability API rather than a fake session adapter:\n * app code owns search normalization, execution policy, connection state,\n * authorization, auth-link routing, and Slackbot shared-connection ACLs.\n */\nexport function createComposioRoutingWithPiProvider({\n composio,\n config,\n store,\n}: ComposioRoutingWithPiProviderDeps): ComposioRouting {\n const getOrCreateUserSession = async (\n event: NormalizedSlackTriggerEvent,\n userId: string,\n threadKey: string,\n ): Promise<ComposioSession> => {\n const slackbotConnectedAccountId = slackbotConnectedAccountIdForEvent(config, store, event);\n const existing = store.getThreadSession(threadKey);\n if (existing) {\n const session = await composio.use(existing.sessionId);\n await pinSlackbotConnectionIfAllowed(config, event, session, existing.userId, slackbotConnectedAccountId);\n return session;\n }\n\n if (slackbotConnectedAccountId) {\n assertSlackbotManualAcl(event, userId);\n }\n\n const session = await composio.sessions.create(userId, {\n manageConnections: {\n enable: true,\n callbackUrl: authCallbackUrl(config, event),\n waitForConnections: true,\n },\n workbench: {\n enable: config.composioWorkbenchEnabled,\n },\n ...(config.composioToolkits ? { toolkits: withSlackbotToolkit(config.composioToolkits, slackbotConnectedAccountId) } : {}),\n ...(config.composioAuthConfigs ? { authConfigs: config.composioAuthConfigs } : {}),\n ...(slackbotConnectedAccountId\n ? {\n connectedAccounts: {\n slackbot: [slackbotConnectedAccountId],\n },\n }\n : {}),\n });\n\n await store.setThreadSession(threadKey, session.sessionId, userId);\n return session;\n };\n\n const createPiComposioTools: ComposioRouting['createPiComposioTools'] = (event, userSession, handleAuthLinks) =>\n piProvider.createSessionTools(createPiCapabilities({ config, event, handleAuthLinks, userSession }));\n\n return {\n createPiComposioTools,\n getOrCreateUserSession,\n };\n}\n\nfunction createPiCapabilities({ config, event, handleAuthLinks, userSession }: CapabilityDeps): PiSessionToolCapabilities {\n const callbackUrl = authCallbackUrl(config, event);\n const includeWorkbenchTools = config.composioWorkbenchEnabled;\n\n return {\n callbackUrl,\n includeWorkbenchTools,\n sessionId: userSession.sessionId,\n search: (params) =>\n userSession.search({\n query: params.query,\n ...(params.toolkits ? { toolkits: params.toolkits } : {}),\n }),\n execute: async (toolSlug, args, options) => {\n if (isSlackbotToolSlug(toolSlug)) {\n assertSlackbotManualAcl(event, slackUserComposioId(event));\n }\n\n return userSession.execute(toolSlug, args, {\n ...(options?.account ? { account: options.account } : {}),\n });\n },\n connections: {\n getToolkitStates: async (toolkits) => {\n const normalizedToolkits = normalizeRequestedToolkits(toolkits) ?? [];\n const userToolkits = normalizedToolkits.filter(isUserAuthToolkit);\n const userStates = userToolkits.length > 0 ? normalizeToolkitStates(await userSession.toolkits({ toolkits: userToolkits })) : [];\n return {\n items: [\n ...normalizedToolkits.filter((toolkit) => !isUserAuthToolkit(toolkit)).map(syntheticConnectedToolkitState),\n ...userStates,\n ],\n };\n },\n authorizeToolkit: async (toolkit) => {\n if (!isUserAuthToolkit(toolkit)) {\n return slackbotAlreadyConnectedResult([toolkit]);\n }\n\n const request = await userSession.authorize(toolkit, { callbackUrl });\n return {\n status: 'needs_connection',\n redirectUrl: request.redirectUrl ?? null,\n };\n },\n isConnected: (state) => toolkitIsConnectedFromUnknown(state),\n },\n hooks: {\n search: (context, next) => {\n context.request.toolkits = normalizeSearchToolkits(context.request.toolkits) ?? undefined;\n return next();\n },\n execute: (context, next) => {\n const trimmedSlug = context.request.toolSlug.trim();\n\n if (isUserSlackToolSlug(trimmedSlug)) {\n return context.deny(USE_SLACKBOT_INSTEAD_ERROR);\n }\n\n if (trimmedSlug === 'COMPOSIO_MANAGE_CONNECTIONS') {\n return context.deny(USE_DEDICATED_MANAGE_CONNECTIONS_ERROR);\n }\n\n if (isSlackbotToolSlug(trimmedSlug)) {\n assertSlackbotManualAcl(event, slackUserComposioId(event));\n }\n\n context.request.toolSlug = trimmedSlug;\n return next();\n },\n onAuthLink: (context) => handleAuthLinks(context.result, context.sourceTool, context.toolkit ? [context.toolkit] : undefined),\n },\n transformResult: async ({ value, tool, requestedToolkits, context }) => {\n const sourceTool = context?.sourceTool ?? providerToolName(tool);\n return handleAuthLinks(value, sourceTool, requestedToolkits);\n },\n };\n}\n\nasync function pinSlackbotConnectionIfAllowed(\n config: AppConfig,\n event: NormalizedSlackTriggerEvent,\n session: ComposioSession,\n userId: string,\n slackbotConnectedAccountId: string | undefined,\n): Promise<void> {\n if (!slackbotConnectedAccountId) return;\n\n assertSlackbotManualAcl(event, userId);\n await session.update({\n ...(config.composioToolkits ? { toolkits: withSlackbotToolkit(config.composioToolkits, slackbotConnectedAccountId) } : {}),\n connectedAccounts: {\n slackbot: [slackbotConnectedAccountId],\n },\n });\n}\n\nfunction slackbotConnectedAccountIdForEvent(\n config: AppConfig,\n store: Store,\n event: NormalizedSlackTriggerEvent,\n): string | undefined {\n return config.slackbotSharedConnectedAccountId ?? store.getSlackbotConnection(event.teamId)?.connectedAccountId ?? event.connectedAccountId;\n}\n\nfunction withSlackbotToolkit(toolkits: string[], slackbotConnectedAccountId: string | undefined): string[] {\n if (!slackbotConnectedAccountId || toolkits.includes('slackbot')) return toolkits;\n return [...toolkits, 'slackbot'];\n}\n\nfunction isUserAuthToolkit(toolkit: string): boolean {\n const normalized = normalizeToolkitKey(toolkit);\n return Boolean(normalized && normalized !== 'slack' && normalized !== 'slackbot');\n}\n\nfunction syntheticConnectedToolkitState(toolkit: string): JsonObject {\n return {\n slug: normalizeToolkitKey(toolkit) === 'slack' ? 'slackbot' : toolkit,\n isNoAuth: true,\n connection: {\n isActive: true,\n connectedAccount: {\n status: 'ACTIVE',\n },\n },\n };\n}\n\nfunction toolkitIsConnectedFromUnknown(value: unknown): boolean {\n const states = normalizeToolkitStates({ items: [value] });\n return toolkitIsConnected(states[0]);\n}\n\nfunction providerToolName(tool: string): string {\n const names: Record<string, string> = {\n execute: 'composio_execute_tool',\n manageConnections: 'COMPOSIO_MANAGE_CONNECTIONS',\n remoteBash: 'COMPOSIO_REMOTE_BASH_TOOL',\n remoteWorkbench: 'COMPOSIO_REMOTE_WORKBENCH',\n search: 'composio_search_tools',\n };\n return names[tool] ?? tool;\n}\n\nfunction slackUserComposioId(event: NormalizedSlackTriggerEvent): string {\n return `slack:${event.teamId}:${event.userId}`;\n}\n\nfunction slackbotAlreadyConnectedResult(toolkits: string[]): JsonObject {\n return {\n successful: true,\n data: {\n message:\n 'Slack workspace actions use the already-connected shared Slackbot connection. Search the slackbot toolkit and execute SLACKBOT_* tools. Do not ask the user to connect Slack.',\n toolkits,\n },\n };\n}\n\nconst USE_DEDICATED_MANAGE_CONNECTIONS_ERROR =\n 'COMPOSIO_MANAGE_CONNECTIONS is not executable through composio_execute_tool. Use the dedicated composio_manage_connections helper, which checks session.toolkits and calls session.authorize for missing accounts.';\n\nconst USE_SLACKBOT_INSTEAD_ERROR =\n 'Use the slackbot toolkit for Slack workspace actions in this bot. Search with toolkits [\"slackbot\"] and execute a SLACKBOT_* tool instead of using the user-scoped Slack toolkit.';\n"
|
|
},
|
|
{
|
|
"path": "src/agent/slack-composio-agent.ts",
|
|
"composio": true,
|
|
"contents": "import { Composio } from '@composio/core';\n\nimport { redactString } from '../auth/links';\nimport { createAuthLinkCollector, authLabelsForLinks, formatAuthLabelList, formatConnectionDm } from '../auth/ui';\nimport { createComposioRouting } from '../composio/routing';\nimport type { AppConfig } from '../config/env';\nimport { runPiAgent, shouldRespondToMainUserMessage } from '../pi/runner';\nimport {\n cleanSlackPrompt,\n composioUserIdForSlack,\n slackThreadKey,\n type NormalizedSlackTriggerEvent,\n} from '../slack/events';\nimport { claudeStyleStatus } from '../slack/status';\nimport type { SlackThreadMessage, SlackTransport, SlackUserContext } from '../slack/transport';\nimport type { Store } from '../store/file-store';\n\nexport interface HandleSlackMessageOptions {\n autoAnalyze?: boolean;\n}\n\nexport interface SlackComposioAgent {\n handleSlackMessage(event: NormalizedSlackTriggerEvent, options?: HandleSlackMessageOptions): Promise<void>;\n}\n\ninterface AgentDeps {\n config: AppConfig;\n slack: SlackTransport;\n store: Store;\n}\n\nexport function createSlackComposioAgent({ config, slack, store }: AgentDeps): SlackComposioAgent {\n const composio = new Composio({ apiKey: config.composioApiKey });\n const composioRouting = createComposioRouting({ composio, config, store });\n\n const postSlackResponse = async (\n channel: string,\n threadTs: string | undefined,\n text: string,\n event: NormalizedSlackTriggerEvent,\n ): Promise<void> => {\n for (const chunk of splitForSlack(text)) {\n await slack.postMessage({ channel, threadTs, text: chunk }, event);\n }\n };\n\n const handleSlackMessage = async (\n event: NormalizedSlackTriggerEvent,\n options: HandleSlackMessageOptions = {},\n ): Promise<void> => {\n const threadKey = slackThreadKey(event);\n const userId = composioUserIdForSlack(event);\n const botUserId = store.getSlackbotConnection(event.teamId)?.botUserId ?? config.slackBotUserId;\n const prompt = cleanSlackPrompt(event.text, botUserId);\n const replyThreadTs = replyThreadTimestamp(event);\n\n if (!prompt) {\n if (options.autoAnalyze) {\n await slack.addReaction(event.channel, event.ts, config.ignoreReactionName, event);\n return;\n }\n\n await slack.postMessage(\n {\n channel: event.channel,\n threadTs: replyThreadTs,\n text: \"Hi! Ask me to do something across your connected apps, and I'll help.\",\n },\n event,\n );\n return;\n }\n\n const history = store.getHistory(threadKey);\n const [slackThreadMessages, slackUserContext] = await Promise.all([\n fetchSlackThreadContext(slack, event),\n fetchSlackUserContext(slack, event),\n ]);\n if (options.autoAnalyze) {\n const shouldRespond = await shouldRespondToMainUserMessage({ config, event, history, prompt, slackThreadMessages });\n if (!shouldRespond) {\n await slack.addReaction(event.channel, event.ts, config.ignoreReactionName, event);\n return;\n }\n }\n\n void slack.setThreadStatus(\n {\n channel: event.channel,\n threadTs: event.threadTs,\n status: claudeStyleStatus(`${threadKey}:${prompt}`),\n },\n event,\n );\n\n if (!event.isDirectMessage) {\n void slack.addReaction(event.channel, event.ts, 'eyes', event);\n }\n\n const composioSession = await composioRouting.getOrCreateUserSession(event, userId, threadKey);\n const threadOwnerUserId = store.getThreadSession(threadKey)?.userId ?? userId;\n const authCollector = createAuthLinkCollector({ store, threadKey });\n const tools = composioRouting.createPiComposioTools(event, composioSession, authCollector.handleAuthLinks);\n\n const assistantText = await runPiAgent({\n config,\n event,\n history,\n prompt,\n slackThreadMessages,\n systemPrompt: buildSystemPrompt(config, event, composioSession.sessionId, slackUserContext, threadOwnerUserId),\n tools,\n });\n\n const sentAuthDm = authCollector.pendingLinks.length > 0;\n if (sentAuthDm) {\n await slack.postDm(event.userId, formatConnectionDm(authCollector.pendingLinks, prompt), event);\n }\n\n const authLabels = sentAuthDm ? authLabelsForLinks(authCollector.pendingLinks) : [];\n const authLabelText = sentAuthDm ? formatAuthLabelList(authLabels) : '';\n const text = redactString(\n sentAuthDm\n ? `I sent you a DM to connect ${authLabelText}. After approving access, I\u2019ll continue automatically.`\n : assistantText || 'Done.',\n );\n\n if (sentAuthDm) {\n await store.setPendingAuthContinuation({\n authLabels,\n channel: event.channel,\n event: resumableEvent(event),\n prompt,\n teamId: event.teamId,\n threadKey,\n threadTs: event.threadTs,\n userId: event.userId,\n });\n const publicMessageTs = await slack.postMessage({ channel: event.channel, threadTs: replyThreadTs, text }, event);\n if (publicMessageTs) {\n await store.setPendingAuthPublicMessageTs(threadKey, publicMessageTs);\n }\n } else {\n await postSlackResponse(event.channel, replyThreadTs, text, event);\n }\n\n void slack.clearThreadStatus({ channel: event.channel, threadTs: event.threadTs }, event);\n await store.appendHistory(threadKey, [\n { role: 'user', content: prompt },\n { role: 'assistant', content: text },\n ]);\n };\n\n return { handleSlackMessage };\n}\n\nfunction resumableEvent(event: NormalizedSlackTriggerEvent) {\n return {\n botId: event.botId,\n channel: event.channel,\n channelType: event.channelType,\n connectedAccountId: event.connectedAccountId,\n eventKey: `auth-resume:${event.eventKey}`,\n isDirectMessage: event.isDirectMessage,\n subtype: event.subtype,\n teamId: event.teamId,\n text: event.text,\n threadTs: event.threadTs,\n triggerSlug: event.triggerSlug,\n ts: event.ts,\n userId: event.userId,\n };\n}\n\nfunction buildSystemPrompt(\n config: AppConfig,\n event: NormalizedSlackTriggerEvent,\n composioSessionId: string,\n slackUserContext: SlackUserContext,\n threadOwnerUserId: string,\n): string {\n return [\n 'You are Pi running as the brain of a Slack assistant. You are concise, practical, and action-oriented.',\n config.composioWorkbenchEnabled\n ? 'You have access to Composio remote workbench tools through the Composio session. Use composio_search_tools to find workbench tools, then composio_execute_tool to run them for scratch files, analysis, transformations, and code execution in Composio\u2019s remote sandbox.'\n : 'Composio remote workbench tools are disabled in this deployment.',\n config.piWorkbenchEnabled\n ? `Local Pi workbench tools are also enabled at ${config.piCwd}. Prefer Composio remote workbench for untrusted code or larger execution tasks.`\n : 'Local Pi filesystem/bash tools are disabled; use Composio remote workbench for code/scratch execution.',\n 'You have three Composio tools: search tools, manage connections, and execute tools. Use them to work across the Slack user\u2019s connected apps.',\n 'You are given the full Slack thread context fetched from Slack before the incoming message. Treat that thread context as the source of truth for conversation state, user instructions, prior decisions, and what has already been tried.',\n formatSlackUserContext(slackUserContext),\n 'Use the Slack requester timezone/local time when interpreting relative dates and times like today, tomorrow, next Friday, morning, EOD, or scheduling defaults.',\n 'composio_search_tools is global by default. Only pass a toolkits filter when intentionally narrowing the search.',\n 'For Slack workspace actions outside the current conversation, search with toolkits [\"slackbot\"] and execute SLACKBOT_* tools. Never use the user-scoped slack toolkit and never ask the user to connect Slack for Slack workspace actions.',\n 'For requests to say/reply/post something in the current Slack conversation, simply make that text your final response. Do not use Slack/Slackbot Composio tools or ask the user to connect Slack for current-conversation replies.',\n 'Always use the current user-scoped Composio session for non-Slackbot app actions. Never ask for API keys or OAuth secrets in Slack.',\n 'When a required non-Slack app is not connected, call composio_manage_connections. The system will DM auth links to the user, wait for approval when supported, and redact links from tool output.',\n 'Never reveal Composio connection links, OAuth URLs, or raw credentials in your final Slack response.',\n 'If a tool says a connection link was sent via Slack DM, tell the user to approve it and that you will continue automatically.',\n 'If the user says \u201cdone\u201d, \u201cconnected\u201d, or similar after an auth step, continue the previous task from the conversation context.',\n 'Ask before destructive or irreversible actions unless the user explicitly requested the action.',\n 'Use Slack mrkdwn in final responses.',\n `Composio session id: ${composioSessionId}`,\n `Thread owner Composio user id: ${threadOwnerUserId}. Incoming Slack requester: slack:${event.teamId}:${event.userId}. For user-scoped app actions, this thread uses the thread owner's Composio session unless a new thread is started.`,\n `Slack context: team=${event.teamId}, channel=${event.channel}, thread_ts=${event.threadTs}, user=${event.userId}.`,\n ].join('\\n');\n}\n\nfunction formatSlackUserContext(context: SlackUserContext): string {\n const fields = [\n `user=${context.userId}`,\n context.timeZone ? `timezone=${context.timeZone}` : undefined,\n context.timeZoneLabel ? `timezone_label=${context.timeZoneLabel}` : undefined,\n typeof context.timeZoneOffsetSeconds === 'number' ? `timezone_offset_seconds=${context.timeZoneOffsetSeconds}` : undefined,\n context.localTime ? `current_local_time=${context.localTime}` : undefined,\n context.locale ? `locale=${context.locale}` : undefined,\n ].filter(Boolean);\n return `Slack requester context: ${fields.join(', ')}.`;\n}\n\nasync function fetchSlackThreadContext(\n slack: SlackTransport,\n event: NormalizedSlackTriggerEvent,\n): Promise<SlackThreadMessage[]> {\n try {\n return ensureIncomingEventInThread(\n await slack.fetchThreadMessages({ channel: event.channel, threadTs: event.threadTs }, event),\n event,\n );\n } catch (error) {\n console.warn('Failed to fetch Slack thread context', error instanceof Error ? error.message : String(error));\n return [threadMessageFromEvent(event)];\n }\n}\n\nasync function fetchSlackUserContext(slack: SlackTransport, event: NormalizedSlackTriggerEvent): Promise<SlackUserContext> {\n try {\n return await slack.fetchUserContext(event.userId, event);\n } catch (error) {\n console.warn('Failed to fetch Slack user timezone context', error instanceof Error ? error.message : String(error));\n return { userId: event.userId };\n }\n}\n\nfunction ensureIncomingEventInThread(\n messages: SlackThreadMessage[],\n event: NormalizedSlackTriggerEvent,\n): SlackThreadMessage[] {\n if (messages.some((message) => message.ts === event.ts)) return messages;\n return [...messages, threadMessageFromEvent(event)].sort((left, right) => Number(left.ts) - Number(right.ts));\n}\n\nfunction threadMessageFromEvent(event: NormalizedSlackTriggerEvent): SlackThreadMessage {\n return {\n botId: event.botId,\n isBot: Boolean(event.botId),\n subtype: event.subtype,\n text: event.text,\n ts: event.ts,\n userId: event.userId,\n };\n}\n\nfunction replyThreadTimestamp(event: NormalizedSlackTriggerEvent): string | undefined {\n if (event.isDirectMessage && event.threadTs === event.ts) return undefined;\n return event.threadTs;\n}\n\nfunction splitForSlack(text: string, maxLength = 3600): string[] {\n if (text.length <= maxLength) return [text];\n\n const chunks: string[] = [];\n let remaining = text;\n while (remaining.length > maxLength) {\n const breakpoint = Math.max(remaining.lastIndexOf('\\n', maxLength), remaining.lastIndexOf(' ', maxLength));\n const index = breakpoint > maxLength * 0.5 ? breakpoint : maxLength;\n chunks.push(remaining.slice(0, index).trim());\n remaining = remaining.slice(index).trim();\n }\n if (remaining) chunks.push(remaining);\n return chunks;\n}\n"
|
|
},
|
|
{
|
|
"path": "src/api/composio-webhook.ts",
|
|
"composio": true,
|
|
"contents": "import type { SlackComposioAgent } from '../agent/slack-composio-agent';\nimport type { AppConfig } from '../config/env';\nimport {\n composioUserIdForSlack,\n isProcessableSlackEvent,\n messageMentionsBot,\n normalizeComposioSlackEvent,\n slackMessageDedupeKey,\n slackThreadKey,\n} from '../slack/events';\nimport type { Store } from '../store/file-store';\n\ninterface ComposioWebhookHandlerDeps {\n agent: SlackComposioAgent;\n config: AppConfig;\n store: Store;\n}\n\nexport interface ComposioWebhookHandler {\n processPayload(body: unknown): Promise<Record<string, unknown>>;\n}\n\nexport function createComposioWebhookHandler({ agent, config, store }: ComposioWebhookHandlerDeps): ComposioWebhookHandler {\n const inFlightDedupeKeys = new Set<string>();\n\n const processPayload = async (body: unknown): Promise<Record<string, unknown>> => {\n const event = normalizeComposioSlackEvent(body);\n if (!event) {\n return { ok: true, ignored: 'not_a_slack_trigger_payload' };\n }\n\n const dedupeKeys = [event.eventKey, slackMessageDedupeKey(event)];\n if (dedupeKeys.some((key) => inFlightDedupeKeys.has(key) || store.hasProcessedEvent(key))) {\n return { ok: true, duplicate: true };\n }\n\n for (const key of dedupeKeys) inFlightDedupeKeys.add(key);\n try {\n await Promise.all(dedupeKeys.map((key) => store.markProcessedEvent(key)));\n } finally {\n for (const key of dedupeKeys) inFlightDedupeKeys.delete(key);\n }\n\n const botUserId = store.getSlackbotConnection(event.teamId)?.botUserId ?? config.slackBotUserId;\n if (!isProcessableSlackEvent(event, botUserId)) {\n return { ok: true, ignored: 'not_processable' };\n }\n\n const mentionedBot = messageMentionsBot(event, botUserId);\n const configuredMainUser = config.mainSlackUserIds.includes(event.userId);\n if (configuredMainUser) {\n await store.addMainSlackUser(event.teamId, event.userId);\n }\n\n if (\n config.autoPromoteFirstMainUser &&\n store.getMainSlackUsers(event.teamId).length === 0 &&\n (mentionedBot || event.isDirectMessage)\n ) {\n await store.addMainSlackUser(event.teamId, event.userId);\n }\n\n const isMainUser = store.isMainSlackUser(event.teamId, event.userId) || configuredMainUser;\n const threadSession = store.getThreadSession(slackThreadKey(event));\n const isThreadOwner = threadSession?.userId === composioUserIdForSlack(event);\n const shouldHandleNormally = event.isDirectMessage || mentionedBot;\n const shouldAutoAnalyze =\n !shouldHandleNormally &&\n ((config.autoAnalyzeMainUserMessages && isMainUser) || Boolean(threadSession && isThreadOwner));\n\n if (!shouldHandleNormally && !shouldAutoAnalyze) {\n return { ok: true, ignored: 'requires_mention' };\n }\n\n queueMicrotask(() => {\n agent.handleSlackMessage(event, { autoAnalyze: shouldAutoAnalyze }).catch((error) => {\n console.error('Failed to handle Slack event', error);\n });\n });\n\n return { ok: true, accepted: true, autoAnalyze: shouldAutoAnalyze };\n };\n\n return { processPayload };\n}\n\n"
|
|
},
|
|
{
|
|
"path": "src/slack/transport.ts",
|
|
"composio": true,
|
|
"contents": "import { Composio } from '@composio/core';\n\nimport type { AppConfig } from '../config/env';\nimport type { Store } from '../store/file-store';\nimport { firstString, jsonObject } from '../utils/decode';\nimport type { JsonObject, JsonValue } from '../utils/json';\nimport type { NormalizedSlackTriggerEvent } from './events';\n\ntype ComposioSession = Awaited<ReturnType<Composio['create']>>;\n\ntype CachedSlackUserContext = {\n context: SlackUserContext;\n expiresAt: number;\n refresh?: Promise<SlackUserContext>;\n};\n\nconst USER_CONTEXT_CACHE_TTL_MS = 6 * 60 * 60 * 1000;\n\nexport interface SlackMessageOptions {\n channel: string;\n text: string;\n threadTs?: string;\n}\n\nexport interface SlackUpdateMessageOptions {\n channel: string;\n text: string;\n ts: string;\n}\n\nexport interface SlackThreadStatusOptions {\n channel: string;\n loadingMessages?: string[];\n status: string;\n threadTs: string;\n}\n\nexport interface SlackThreadMessage {\n botId?: string | null;\n isBot: boolean;\n subtype?: string | null;\n text: string;\n ts: string;\n userId?: string;\n username?: string;\n}\n\nexport interface SlackFetchThreadOptions {\n channel: string;\n maxMessages?: number;\n threadTs: string;\n}\n\nexport interface SlackUserContext {\n locale?: string;\n localTime?: string;\n timeZone?: string;\n timeZoneLabel?: string;\n timeZoneOffsetSeconds?: number;\n userId: string;\n}\n\nexport interface SlackTransport {\n addReaction(channel: string, timestamp: string, name: string, event?: NormalizedSlackTriggerEvent): Promise<void>;\n clearThreadStatus(options: Omit<SlackThreadStatusOptions, 'loadingMessages' | 'status'>, event?: NormalizedSlackTriggerEvent): Promise<void>;\n fetchThreadMessages(options: SlackFetchThreadOptions, event?: NormalizedSlackTriggerEvent): Promise<SlackThreadMessage[]>;\n fetchUserContext(userId: string, event?: NormalizedSlackTriggerEvent): Promise<SlackUserContext>;\n postDm(userId: string, text: string, event?: NormalizedSlackTriggerEvent): Promise<string | undefined>;\n postMessage(options: SlackMessageOptions, event?: NormalizedSlackTriggerEvent): Promise<string | undefined>;\n updateMessage(options: SlackUpdateMessageOptions, event?: NormalizedSlackTriggerEvent): Promise<void>;\n setThreadStatus(options: SlackThreadStatusOptions, event?: NormalizedSlackTriggerEvent): Promise<void>;\n}\n\ninterface SlackTransportDeps {\n config: AppConfig;\n store: Store;\n}\n\n/**\n * Slack transport backed by a Composio Slackbot connected account.\n * The Slack bot token stays in Composio; this app stores only ca_* ids.\n */\nexport function createSlackTransport({ config, store }: SlackTransportDeps): SlackTransport {\n const composio = new Composio({ apiKey: config.composioApiKey });\n const dmChannels = new Map<string, string>();\n const sessions = new Map<string, ComposioSession>();\n const userContexts = new Map<string, CachedSlackUserContext>();\n\n const sessionForEvent = async (event?: NormalizedSlackTriggerEvent): Promise<ComposioSession> => {\n const connectedAccountId = connectedAccountIdForEvent(config, store, event);\n const cached = sessions.get(connectedAccountId);\n if (cached) return cached;\n\n const session = await composio.create(transportUserIdForEvent(config, event), {\n toolkits: ['slackbot'],\n connectedAccounts: {\n slackbot: [connectedAccountId],\n },\n manageConnections: false,\n workbench: { enable: false },\n });\n\n sessions.set(connectedAccountId, session);\n return session;\n };\n\n const openDm = async (userId: string, event?: NormalizedSlackTriggerEvent): Promise<string> => {\n const cacheKey = `${connectedAccountIdForEvent(config, store, event)}:${userId}`;\n const cached = dmChannels.get(cacheKey);\n if (cached) return cached;\n\n const session = await sessionForEvent(event);\n const response = await proxySlack(session, 'conversations.open', { users: userId });\n const channelId = decodeOpenDmChannelId(response);\n if (!channelId) {\n throw new Error(`Slack conversations.open did not return a channel id for user ${userId}: ${JSON.stringify(response)}`);\n }\n\n dmChannels.set(cacheKey, channelId);\n return channelId;\n };\n\n const postMessage = async (\n { channel, text, threadTs }: SlackMessageOptions,\n event?: NormalizedSlackTriggerEvent,\n ): Promise<string | undefined> => {\n const session = await sessionForEvent(event);\n const response = await proxySlack(session, 'chat.postMessage', {\n channel,\n text,\n mrkdwn: true,\n thread_ts: threadTs,\n unfurl_links: false,\n unfurl_media: false,\n });\n\n return decodePostMessageTimestamp(response);\n };\n\n const updateMessage = async (\n { channel, text, ts }: SlackUpdateMessageOptions,\n event?: NormalizedSlackTriggerEvent,\n ): Promise<void> => {\n const session = await sessionForEvent(event);\n await proxySlack(session, 'chat.update', {\n channel,\n ts,\n text,\n mrkdwn: true,\n unfurl_links: false,\n unfurl_media: false,\n });\n };\n\n const setThreadStatus = async (\n { channel, threadTs, status, loadingMessages }: SlackThreadStatusOptions,\n event?: NormalizedSlackTriggerEvent,\n ): Promise<void> => {\n try {\n const session = await sessionForEvent(event);\n await proxySlack(session, 'assistant.threads.setStatus', {\n channel_id: channel,\n thread_ts: threadTs,\n status,\n loading_messages: loadingMessages,\n });\n } catch (error) {\n // Status indicators are optional polish. Missing Slack AI app capability should not break replies.\n console.warn('Failed to set Slack assistant thread status', summarizeError(error));\n }\n };\n\n const fetchThreadMessages = async (\n { channel, threadTs, maxMessages = Number.MAX_SAFE_INTEGER }: SlackFetchThreadOptions,\n event?: NormalizedSlackTriggerEvent,\n ): Promise<SlackThreadMessage[]> => {\n const session = await sessionForEvent(event);\n const messages: SlackThreadMessage[] = [];\n let cursor: string | undefined;\n\n do {\n const response = await proxySlackGet(session, 'conversations.replies', {\n channel,\n ts: threadTs,\n limit: Math.min(200, Math.max(1, maxMessages - messages.length)),\n cursor,\n });\n messages.push(...decodeThreadMessages(response));\n cursor = decodeNextCursor(response);\n } while (cursor && messages.length < maxMessages);\n\n return messages.slice(0, maxMessages);\n };\n\n const requestUserContext = async (userId: string, event?: NormalizedSlackTriggerEvent): Promise<SlackUserContext> => {\n const session = await sessionForEvent(event);\n const response = await proxySlackGet(session, 'users.info', {\n user: userId,\n include_locale: 'true',\n });\n return decodeUserContext(response, userId);\n };\n\n const refreshUserContext = (cacheKey: string, userId: string, event?: NormalizedSlackTriggerEvent): Promise<SlackUserContext> => {\n const refresh = requestUserContext(userId, event)\n .then((context) => {\n const cached = {\n context,\n expiresAt: Date.now() + USER_CONTEXT_CACHE_TTL_MS,\n };\n userContexts.set(cacheKey, cached);\n return withFreshLocalTime(context);\n })\n .catch((error) => {\n const existing = userContexts.get(cacheKey);\n if (existing) {\n userContexts.set(cacheKey, { context: existing.context, expiresAt: existing.expiresAt });\n }\n throw error;\n });\n\n const existing = userContexts.get(cacheKey);\n if (existing) {\n userContexts.set(cacheKey, { ...existing, refresh });\n }\n return refresh;\n };\n\n const fetchUserContext = async (userId: string, event?: NormalizedSlackTriggerEvent): Promise<SlackUserContext> => {\n const cacheKey = `${connectedAccountIdForEvent(config, store, event)}:${userId}`;\n const now = Date.now();\n const cached = userContexts.get(cacheKey);\n if (cached && cached.expiresAt > now) {\n return withFreshLocalTime(cached.context);\n }\n\n if (cached) {\n if (!cached.refresh) {\n const refresh = refreshUserContext(cacheKey, userId, event).catch((error) => {\n console.warn('Failed to refresh cached Slack user timezone context', summarizeError(error));\n return withFreshLocalTime(cached.context);\n });\n userContexts.set(cacheKey, { ...cached, refresh });\n }\n return withFreshLocalTime(cached.context);\n }\n\n const fallback = { userId };\n const refresh = refreshUserContext(cacheKey, userId, event).catch((error) => {\n console.warn('Failed to fetch Slack user timezone context', summarizeError(error));\n return fallback;\n });\n userContexts.set(cacheKey, {\n context: fallback,\n expiresAt: 0,\n refresh,\n });\n return fallback;\n };\n\n return {\n addReaction: async (channel, timestamp, name, event) => {\n try {\n const session = await sessionForEvent(event);\n await proxySlack(session, 'reactions.add', { channel, timestamp, name });\n } catch (error) {\n // Reactions are optional polish. Missing scopes or duplicate reactions should not break handling.\n console.warn('Failed to add Slack reaction', summarizeError(error));\n }\n },\n\n clearThreadStatus: async ({ channel, threadTs }, event) => {\n await setThreadStatus({ channel, threadTs, status: '' }, event);\n },\n\n fetchThreadMessages,\n fetchUserContext,\n\n postDm: async (userId, text, event) => {\n const channel = await openDm(userId, event);\n return postMessage({ channel, text }, event);\n },\n\n postMessage,\n setThreadStatus,\n updateMessage,\n };\n}\n\nexport async function proxySlack(\n session: ComposioSession,\n method: string,\n body: Record<string, JsonValue | undefined>,\n): Promise<JsonValue> {\n const response = await session.proxyExecute({\n toolkit: 'slackbot',\n endpoint: `https://slack.com/api/${method}`,\n method: 'POST',\n body: stripUndefined(body),\n });\n\n return decodeSlackApiResponse(method, response);\n}\n\nasync function proxySlackGet(\n session: ComposioSession,\n method: string,\n query: Record<string, string | number | undefined>,\n): Promise<JsonValue> {\n const response = await session.proxyExecute({\n toolkit: 'slackbot',\n endpoint: `https://slack.com/api/${method}`,\n method: 'GET',\n parameters: queryParameters(query),\n });\n\n return decodeSlackApiResponse(method, response);\n}\n\nfunction decodeSlackApiResponse(method: string, response: unknown): JsonValue {\n const responseObject = jsonObject(response) ?? {};\n const slackPayload = jsonObject(responseObject.data) ?? responseObject;\n\n if (slackPayload.ok === false) {\n throw new Error(`Slack API ${method} failed: ${firstString(slackPayload.error) ?? JSON.stringify(slackPayload)}`);\n }\n\n return slackPayload;\n}\n\nfunction decodeOpenDmChannelId(response: JsonValue): string | undefined {\n const payload = jsonObject(response) ?? {};\n const channel = jsonObject(payload.channel) ?? jsonObject(jsonObject(payload.data)?.channel);\n return firstString(channel?.id);\n}\n\nfunction decodePostMessageTimestamp(response: JsonValue): string | undefined {\n const payload = jsonObject(response) ?? {};\n const data = jsonObject(payload.data);\n const message = jsonObject(payload.message);\n return firstString(payload.ts, data?.ts, message?.ts);\n}\n\nfunction decodeThreadMessages(response: JsonValue): SlackThreadMessage[] {\n const payload = jsonObject(response) ?? {};\n const rawMessages = Array.isArray(payload.messages) ? payload.messages : [];\n\n return rawMessages.flatMap((rawMessage): SlackThreadMessage[] => {\n const message = jsonObject(rawMessage);\n const ts = firstString(message?.ts);\n if (!message || !ts) return [];\n\n return [\n {\n botId: firstString(message.bot_id, message.botId) ?? null,\n isBot: Boolean(firstString(message.bot_id, message.botId)) || typeof message.bot_profile === 'object',\n subtype: firstString(message.subtype) ?? null,\n text: messageTextWithFiles(message),\n ts,\n userId: firstString(message.user, message.user_id, message.userId),\n username: firstString(message.username, message.user_name, message.userName),\n },\n ];\n });\n}\n\nfunction decodeNextCursor(response: JsonValue): string | undefined {\n const payload = jsonObject(response) ?? {};\n const metadata = jsonObject(payload.response_metadata) ?? jsonObject(payload.responseMetadata);\n return firstString(metadata?.next_cursor, metadata?.nextCursor);\n}\n\nfunction decodeUserContext(response: JsonValue, fallbackUserId: string): SlackUserContext {\n const payload = jsonObject(response) ?? {};\n const data = jsonObject(payload.data);\n const user = jsonObject(payload.user) ?? jsonObject(data?.user) ?? {};\n const profile = jsonObject(user.profile) ?? {};\n const timeZone = firstString(user.tz, user.timezone, profile.tz, profile.timezone);\n\n return {\n locale: firstString(user.locale, profile.locale),\n localTime: timeZone ? formatLocalTime(timeZone) : undefined,\n timeZone,\n timeZoneLabel: firstString(user.tz_label, user.tzLabel),\n timeZoneOffsetSeconds: firstNumber(user.tz_offset, user.tzOffset),\n userId: firstString(user.id) ?? fallbackUserId,\n };\n}\n\nfunction messageTextWithFiles(message: JsonObject): string {\n const text = firstString(message.text) ?? '';\n const files = Array.isArray(message.files) ? message.files : [];\n const fileSummaries = files.flatMap((rawFile): string[] => {\n const file = jsonObject(rawFile);\n if (!file) return [];\n const title = firstString(file.title, file.name) ?? 'file';\n const url = firstString(file.permalink, file.url_private, file.urlPrivate);\n return [url ? `[file: ${title} ${url}]` : `[file: ${title}]`];\n });\n\n return [text, ...fileSummaries].filter(Boolean).join('\\n');\n}\n\nfunction firstNumber(...values: unknown[]): number | undefined {\n for (const value of values) {\n if (typeof value === 'number' && Number.isFinite(value)) return value;\n if (typeof value !== 'string' || !value.trim()) continue;\n const parsed = Number(value);\n if (Number.isFinite(parsed)) return parsed;\n }\n return undefined;\n}\n\nfunction withFreshLocalTime(context: SlackUserContext): SlackUserContext {\n return {\n ...context,\n localTime: context.timeZone ? formatLocalTime(context.timeZone) : undefined,\n };\n}\n\nfunction formatLocalTime(timeZone: string): string | undefined {\n try {\n return new Intl.DateTimeFormat('en-US', {\n dateStyle: 'full',\n timeStyle: 'long',\n timeZone,\n }).format(new Date());\n } catch {\n return undefined;\n }\n}\n\nfunction connectedAccountIdForEvent(config: AppConfig, store: Store, event?: NormalizedSlackTriggerEvent): string {\n const id =\n config.slackbotSharedConnectedAccountId ??\n event?.connectedAccountId ??\n (event?.teamId ? store.getSlackbotConnection(event.teamId)?.connectedAccountId : undefined);\n if (!id) {\n throw new Error(\n 'Missing Slackbot transport connected account. Open /setup/slackbot/start to install Slackbot or set SLACKBOT_SHARED_CONNECTED_ACCOUNT_ID.',\n );\n }\n return id;\n}\n\nfunction transportUserIdForEvent(config: AppConfig, event?: NormalizedSlackTriggerEvent): string {\n if (config.slackbotTransportUserId) return config.slackbotTransportUserId;\n if (!event?.teamId || event.teamId === 'unknown-team') {\n throw new Error('Missing Slack team id; cannot derive Slackbot transport user id service:${teamId}.');\n }\n return `service:${event.teamId}`;\n}\n\nfunction stripUndefined(value: Record<string, JsonValue | undefined>): JsonObject {\n return Object.fromEntries(Object.entries(value).filter(([, nested]) => nested !== undefined)) as JsonObject;\n}\n\nfunction queryParameters(value: Record<string, string | number | undefined>): Array<{ in: 'query'; name: string; value: string | number }> {\n return Object.entries(value).flatMap(([name, nested]) => (nested === undefined ? [] : [{ in: 'query' as const, name, value: nested }]));\n}\n\nfunction summarizeError(error: Error | unknown): string {\n if (error instanceof Error) return error.message;\n return String(error);\n}\n"
|
|
},
|
|
{
|
|
"path": "src/slack/acl.ts",
|
|
"composio": true,
|
|
"contents": "import type { NormalizedSlackTriggerEvent } from './events';\n\nexport function assertSlackbotManualAcl(event: NormalizedSlackTriggerEvent, composioUserId: string): void {\n if (isAllowedSlackbotUserId(event.teamId, composioUserId)) return;\n\n throw new Error(\n `Refusing to pin Slackbot shared connection for ${composioUserId}. ` +\n `Allowed patterns: ${slackbotAllowedUserIdPatterns(event.teamId).join(', ')}`,\n );\n}\n\nexport function isAllowedSlackbotUserId(teamId: string, composioUserId: string): boolean {\n return composioUserId === `service:${teamId}` || composioUserId.startsWith(`slack:${teamId}:`);\n}\n\nexport function slackbotAllowedUserIdPatterns(teamId: string): string[] {\n return [`service:${teamId}`, `slack:${teamId}:*`];\n}\n"
|
|
}
|
|
] |