@inkeep/agents-work-apps 0.47.5 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/env.d.ts +24 -2
- package/dist/env.js +13 -2
- package/dist/github/index.d.ts +3 -3
- package/dist/github/mcp/auth.d.ts +2 -2
- package/dist/github/mcp/index.d.ts +2 -2
- package/dist/github/mcp/index.js +23 -34
- package/dist/github/mcp/schemas.d.ts +1 -1
- package/dist/github/routes/setup.d.ts +2 -2
- package/dist/github/routes/tokenExchange.d.ts +2 -2
- package/dist/github/routes/webhooks.d.ts +2 -2
- package/dist/slack/i18n/index.d.ts +2 -0
- package/dist/slack/i18n/index.js +3 -0
- package/dist/slack/i18n/strings.d.ts +73 -0
- package/dist/slack/i18n/strings.js +67 -0
- package/dist/slack/index.d.ts +18 -0
- package/dist/slack/index.js +28 -0
- package/dist/slack/middleware/permissions.d.ts +31 -0
- package/dist/slack/middleware/permissions.js +167 -0
- package/dist/slack/routes/events.d.ts +10 -0
- package/dist/slack/routes/events.js +551 -0
- package/dist/slack/routes/index.d.ts +10 -0
- package/dist/slack/routes/index.js +47 -0
- package/dist/slack/routes/oauth.d.ts +20 -0
- package/dist/slack/routes/oauth.js +344 -0
- package/dist/slack/routes/users.d.ts +10 -0
- package/dist/slack/routes/users.js +365 -0
- package/dist/slack/routes/workspaces.d.ts +10 -0
- package/dist/slack/routes/workspaces.js +909 -0
- package/dist/slack/services/agent-resolution.d.ts +41 -0
- package/dist/slack/services/agent-resolution.js +99 -0
- package/dist/slack/services/blocks/index.d.ts +73 -0
- package/dist/slack/services/blocks/index.js +103 -0
- package/dist/slack/services/client.d.ts +108 -0
- package/dist/slack/services/client.js +232 -0
- package/dist/slack/services/commands/index.d.ts +19 -0
- package/dist/slack/services/commands/index.js +553 -0
- package/dist/slack/services/events/app-mention.d.ts +40 -0
- package/dist/slack/services/events/app-mention.js +297 -0
- package/dist/slack/services/events/block-actions.d.ts +40 -0
- package/dist/slack/services/events/block-actions.js +265 -0
- package/dist/slack/services/events/index.d.ts +6 -0
- package/dist/slack/services/events/index.js +7 -0
- package/dist/slack/services/events/modal-submission.d.ts +30 -0
- package/dist/slack/services/events/modal-submission.js +400 -0
- package/dist/slack/services/events/streaming.d.ts +26 -0
- package/dist/slack/services/events/streaming.js +255 -0
- package/dist/slack/services/events/utils.d.ts +146 -0
- package/dist/slack/services/events/utils.js +370 -0
- package/dist/slack/services/index.d.ts +16 -0
- package/dist/slack/services/index.js +16 -0
- package/dist/slack/services/modals.d.ts +86 -0
- package/dist/slack/services/modals.js +355 -0
- package/dist/slack/services/nango.d.ts +85 -0
- package/dist/slack/services/nango.js +476 -0
- package/dist/slack/services/security.d.ts +35 -0
- package/dist/slack/services/security.js +65 -0
- package/dist/slack/services/types.d.ts +26 -0
- package/dist/slack/services/types.js +1 -0
- package/dist/slack/services/workspace-tokens.d.ts +25 -0
- package/dist/slack/services/workspace-tokens.js +27 -0
- package/dist/slack/tracer.d.ts +40 -0
- package/dist/slack/tracer.js +39 -0
- package/dist/slack/types.d.ts +10 -0
- package/dist/slack/types.js +1 -0
- package/package.json +11 -2
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { env } from "../../env.js";
|
|
2
|
+
import { getLogger } from "../../logger.js";
|
|
3
|
+
import runDbClient_default from "../../db/runDbClient.js";
|
|
4
|
+
import { findWorkAppSlackWorkspaceBySlackTeamId } from "@inkeep/agents-core";
|
|
5
|
+
import { Nango } from "@nangohq/node";
|
|
6
|
+
|
|
7
|
+
//#region src/slack/services/nango.ts
|
|
8
|
+
/**
|
|
9
|
+
* Nango Service for Slack OAuth Token Management
|
|
10
|
+
*
|
|
11
|
+
* ARCHITECTURE NOTE: PostgreSQL is the authoritative source of truth for:
|
|
12
|
+
* - User linking data (work_app_slack_user_mappings table)
|
|
13
|
+
* - Workspace metadata (work_app_slack_workspaces table)
|
|
14
|
+
* - Channel agent configs (work_app_slack_channel_agent_configs table)
|
|
15
|
+
*
|
|
16
|
+
* Nango is used ONLY for:
|
|
17
|
+
* - OAuth token storage and refresh (bot tokens for workspaces)
|
|
18
|
+
* - OAuth flow management (createConnectSession)
|
|
19
|
+
* - Workspace default agent config (stored in connection metadata)
|
|
20
|
+
*
|
|
21
|
+
* PERFORMANCE: Workspace lookups use PostgreSQL first (O(1)), with Nango
|
|
22
|
+
* fallback only when needed for bot token retrieval.
|
|
23
|
+
*
|
|
24
|
+
* For user data, use the PostgreSQL data access layer:
|
|
25
|
+
* @see packages/agents-core/src/data-access/runtime/workAppSlack.ts
|
|
26
|
+
*/
|
|
27
|
+
const MAX_WORKSPACE_CACHE_SIZE = 1e3;
|
|
28
|
+
const workspaceConnectionCache = /* @__PURE__ */ new Map();
|
|
29
|
+
const CACHE_TTL_MS = 6e4;
|
|
30
|
+
const logger = getLogger("slack-nango");
|
|
31
|
+
/**
|
|
32
|
+
* Retry a function with exponential backoff for transient failures.
|
|
33
|
+
* Retries on AbortError (timeout) and 5xx HTTP errors.
|
|
34
|
+
*/
|
|
35
|
+
async function retryWithBackoff(fn, maxAttempts = 3) {
|
|
36
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
|
|
37
|
+
return await fn();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const isTimeout = error.name === "AbortError";
|
|
40
|
+
const isServerError = typeof error.status === "number" && error.status >= 500;
|
|
41
|
+
if (!(isTimeout || isServerError) || attempt === maxAttempts) throw error;
|
|
42
|
+
const delay = Math.min(500 * 2 ** (attempt - 1), 2e3) + Math.random() * 100;
|
|
43
|
+
logger.warn({
|
|
44
|
+
attempt,
|
|
45
|
+
maxAttempts,
|
|
46
|
+
isTimeout,
|
|
47
|
+
delay: Math.round(delay)
|
|
48
|
+
}, "Retrying Nango API call after transient failure");
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
50
|
+
}
|
|
51
|
+
throw new Error("Unreachable");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Evict expired entries from workspace cache to bound memory.
|
|
55
|
+
*/
|
|
56
|
+
function evictWorkspaceCache() {
|
|
57
|
+
if (workspaceConnectionCache.size <= MAX_WORKSPACE_CACHE_SIZE) return;
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
for (const [key, entry] of workspaceConnectionCache) if (entry.expiresAt <= now) workspaceConnectionCache.delete(key);
|
|
60
|
+
if (workspaceConnectionCache.size > MAX_WORKSPACE_CACHE_SIZE) {
|
|
61
|
+
const excess = workspaceConnectionCache.size - MAX_WORKSPACE_CACHE_SIZE;
|
|
62
|
+
const keys = workspaceConnectionCache.keys();
|
|
63
|
+
for (let i = 0; i < excess; i++) {
|
|
64
|
+
const { value } = keys.next();
|
|
65
|
+
if (value) workspaceConnectionCache.delete(value);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function getSlackNango() {
|
|
70
|
+
const secretKey = env.NANGO_SLACK_SECRET_KEY || env.NANGO_SECRET_KEY;
|
|
71
|
+
if (!secretKey) throw new Error("NANGO_SLACK_SECRET_KEY or NANGO_SECRET_KEY is required for Slack integration");
|
|
72
|
+
return new Nango({
|
|
73
|
+
secretKey,
|
|
74
|
+
host: env.NANGO_SERVER_URL
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function getSlackIntegrationId() {
|
|
78
|
+
return env.NANGO_SLACK_INTEGRATION_ID || "slack-agent";
|
|
79
|
+
}
|
|
80
|
+
async function createConnectSession(params) {
|
|
81
|
+
try {
|
|
82
|
+
const nango = getSlackNango();
|
|
83
|
+
const integrationId = getSlackIntegrationId();
|
|
84
|
+
const session = await nango.createConnectSession({
|
|
85
|
+
end_user: {
|
|
86
|
+
id: params.userId,
|
|
87
|
+
email: params.userEmail,
|
|
88
|
+
display_name: params.userName
|
|
89
|
+
},
|
|
90
|
+
organization: {
|
|
91
|
+
id: params.tenantId,
|
|
92
|
+
display_name: params.tenantId
|
|
93
|
+
},
|
|
94
|
+
allowed_integrations: [integrationId]
|
|
95
|
+
});
|
|
96
|
+
logger.info({
|
|
97
|
+
userId: params.userId,
|
|
98
|
+
userEmail: params.userEmail,
|
|
99
|
+
integrationId
|
|
100
|
+
}, "Created Nango connect session");
|
|
101
|
+
return { sessionToken: session.data.token };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
logger.error({ error }, "Failed to create Nango connect session");
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function getConnectionAccessToken(connectionId) {
|
|
108
|
+
try {
|
|
109
|
+
const nango = getSlackNango();
|
|
110
|
+
const integrationId = getSlackIntegrationId();
|
|
111
|
+
return (await nango.getConnection(integrationId, connectionId)).credentials?.access_token || null;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logger.error({
|
|
114
|
+
error,
|
|
115
|
+
connectionId
|
|
116
|
+
}, "Failed to get connection access token");
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Find a workspace connection by Slack team ID.
|
|
122
|
+
* Uses PostgreSQL first (O(1)) with in-memory caching, then falls back to Nango.
|
|
123
|
+
*
|
|
124
|
+
* Performance: This function is called on every @mention and command.
|
|
125
|
+
* The PostgreSQL-first approach with caching provides O(1) lookups.
|
|
126
|
+
*/
|
|
127
|
+
async function findWorkspaceConnectionByTeamId(teamId) {
|
|
128
|
+
const cached = workspaceConnectionCache.get(teamId);
|
|
129
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
130
|
+
logger.debug({ teamId }, "Workspace connection cache hit");
|
|
131
|
+
return cached.connection;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const dbWorkspace = await findWorkAppSlackWorkspaceBySlackTeamId(runDbClient_default)(teamId);
|
|
135
|
+
if (dbWorkspace?.nangoConnectionId) {
|
|
136
|
+
const botToken = await getConnectionAccessToken(dbWorkspace.nangoConnectionId);
|
|
137
|
+
if (botToken) {
|
|
138
|
+
const connection = {
|
|
139
|
+
connectionId: dbWorkspace.nangoConnectionId,
|
|
140
|
+
teamId,
|
|
141
|
+
teamName: dbWorkspace.slackTeamName || void 0,
|
|
142
|
+
botToken,
|
|
143
|
+
tenantId: dbWorkspace.tenantId,
|
|
144
|
+
defaultAgent: void 0
|
|
145
|
+
};
|
|
146
|
+
const defaultAgentConfig = await getWorkspaceDefaultAgentFromNangoByConnectionId(dbWorkspace.nangoConnectionId);
|
|
147
|
+
if (defaultAgentConfig) connection.defaultAgent = defaultAgentConfig;
|
|
148
|
+
evictWorkspaceCache();
|
|
149
|
+
workspaceConnectionCache.set(teamId, {
|
|
150
|
+
connection,
|
|
151
|
+
expiresAt: Date.now() + CACHE_TTL_MS
|
|
152
|
+
});
|
|
153
|
+
logger.debug({ teamId }, "Workspace connection found via PostgreSQL");
|
|
154
|
+
return connection;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
logger.debug({ teamId }, "PostgreSQL lookup failed, falling back to Nango iteration");
|
|
158
|
+
return findWorkspaceConnectionByTeamIdFromNango(teamId);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
logger.error({
|
|
161
|
+
error,
|
|
162
|
+
teamId
|
|
163
|
+
}, "Failed to find workspace connection by team ID");
|
|
164
|
+
return findWorkspaceConnectionByTeamIdFromNango(teamId);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function getWorkspaceDefaultAgentFromNangoByConnectionId(connectionId) {
|
|
168
|
+
try {
|
|
169
|
+
const nango = getSlackNango();
|
|
170
|
+
const integrationId = getSlackIntegrationId();
|
|
171
|
+
const metadata = (await nango.getConnection(integrationId, connectionId)).metadata;
|
|
172
|
+
if (metadata?.default_agent) try {
|
|
173
|
+
return JSON.parse(metadata.default_agent);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
logger.warn({ error }, "Failed to get workspace default agent from Nango");
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Legacy fallback: Find workspace by iterating all Nango connections.
|
|
185
|
+
* Only used when PostgreSQL lookup fails.
|
|
186
|
+
*/
|
|
187
|
+
async function findWorkspaceConnectionByTeamIdFromNango(teamId) {
|
|
188
|
+
try {
|
|
189
|
+
const nango = getSlackNango();
|
|
190
|
+
const integrationId = getSlackIntegrationId();
|
|
191
|
+
const connections = await nango.listConnections();
|
|
192
|
+
for (const conn of connections.connections) if (conn.provider_config_key === integrationId) try {
|
|
193
|
+
const fullConn = await nango.getConnection(integrationId, conn.connection_id);
|
|
194
|
+
const connectionConfig = fullConn.connection_config;
|
|
195
|
+
const metadata = fullConn.metadata;
|
|
196
|
+
const credentials = fullConn;
|
|
197
|
+
if ((connectionConfig?.["team.id"] || metadata?.slack_team_id) === teamId && credentials.credentials?.access_token) {
|
|
198
|
+
let defaultAgent;
|
|
199
|
+
if (metadata?.default_agent) try {
|
|
200
|
+
defaultAgent = JSON.parse(metadata.default_agent);
|
|
201
|
+
} catch {}
|
|
202
|
+
const connection = {
|
|
203
|
+
connectionId: conn.connection_id,
|
|
204
|
+
teamId,
|
|
205
|
+
teamName: metadata?.slack_team_name,
|
|
206
|
+
botToken: credentials.credentials.access_token,
|
|
207
|
+
tenantId: metadata?.tenant_id || metadata?.inkeep_tenant_id || "",
|
|
208
|
+
defaultAgent
|
|
209
|
+
};
|
|
210
|
+
evictWorkspaceCache();
|
|
211
|
+
workspaceConnectionCache.set(teamId, {
|
|
212
|
+
connection,
|
|
213
|
+
expiresAt: Date.now() + CACHE_TTL_MS
|
|
214
|
+
});
|
|
215
|
+
return connection;
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
logger.warn({
|
|
219
|
+
error,
|
|
220
|
+
connectionId: conn.connection_id
|
|
221
|
+
}, "Failed to get Nango connection details");
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
logger.error({
|
|
226
|
+
error,
|
|
227
|
+
teamId
|
|
228
|
+
}, "Failed to find workspace connection from Nango");
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function clearWorkspaceConnectionCache(teamId) {
|
|
233
|
+
if (teamId) workspaceConnectionCache.delete(teamId);
|
|
234
|
+
else workspaceConnectionCache.clear();
|
|
235
|
+
}
|
|
236
|
+
async function updateConnectionMetadata(connectionId, metadata) {
|
|
237
|
+
try {
|
|
238
|
+
const nango = getSlackNango();
|
|
239
|
+
const integrationId = getSlackIntegrationId();
|
|
240
|
+
await nango.updateMetadata(integrationId, connectionId, metadata);
|
|
241
|
+
return true;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
logger.error({
|
|
244
|
+
error,
|
|
245
|
+
connectionId
|
|
246
|
+
}, "Failed to update connection metadata");
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async function setWorkspaceDefaultAgent(teamId, defaultAgent) {
|
|
251
|
+
try {
|
|
252
|
+
const workspace = await findWorkspaceConnectionByTeamId(teamId);
|
|
253
|
+
if (!workspace) {
|
|
254
|
+
logger.warn({ teamId }, "No workspace connection found to set default agent");
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const success = await updateConnectionMetadata(workspace.connectionId, { default_agent: defaultAgent ? JSON.stringify(defaultAgent) : "" });
|
|
258
|
+
if (success) clearWorkspaceConnectionCache(teamId);
|
|
259
|
+
return success;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
logger.error({
|
|
262
|
+
error,
|
|
263
|
+
teamId
|
|
264
|
+
}, "Failed to set workspace default agent");
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function getWorkspaceDefaultAgentFromNango(teamId) {
|
|
269
|
+
try {
|
|
270
|
+
return (await findWorkspaceConnectionByTeamId(teamId))?.defaultAgent || null;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
logger.error({
|
|
273
|
+
error,
|
|
274
|
+
teamId
|
|
275
|
+
}, "Failed to get workspace default agent");
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Compute a stable, deterministic connection ID for a Slack workspace.
|
|
281
|
+
* Format: "T:<team_id>" or "E:<enterprise_id>:T:<team_id>" for Enterprise Grid
|
|
282
|
+
*/
|
|
283
|
+
function computeWorkspaceConnectionId(params) {
|
|
284
|
+
const { teamId, enterpriseId } = params;
|
|
285
|
+
if (enterpriseId) return `E:${enterpriseId}:T:${teamId}`;
|
|
286
|
+
return `T:${teamId}`;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Store a workspace installation in Nango.
|
|
290
|
+
* Uses upsert semantics - will update if the connection already exists.
|
|
291
|
+
*/
|
|
292
|
+
async function storeWorkspaceInstallation(data) {
|
|
293
|
+
const connectionId = computeWorkspaceConnectionId({
|
|
294
|
+
teamId: data.teamId,
|
|
295
|
+
enterpriseId: data.enterpriseId
|
|
296
|
+
});
|
|
297
|
+
try {
|
|
298
|
+
const integrationId = getSlackIntegrationId();
|
|
299
|
+
const secretKey = env.NANGO_SLACK_SECRET_KEY || env.NANGO_SECRET_KEY;
|
|
300
|
+
if (!secretKey) {
|
|
301
|
+
logger.error({}, "No Nango secret key available");
|
|
302
|
+
return {
|
|
303
|
+
connectionId,
|
|
304
|
+
success: false
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const nangoApiUrl = env.NANGO_SERVER_URL || "https://api.nango.dev";
|
|
308
|
+
logger.info({
|
|
309
|
+
integrationId,
|
|
310
|
+
connectionId,
|
|
311
|
+
teamId: data.teamId,
|
|
312
|
+
teamName: data.teamName
|
|
313
|
+
}, "Importing connection to Nango");
|
|
314
|
+
const displayName = data.enterpriseName ? `${data.teamName || data.teamId} (${data.enterpriseName})` : data.teamName || data.teamId;
|
|
315
|
+
const workspaceUrl = data.workspaceUrl || (data.teamDomain ? `https://${data.teamDomain}.slack.com` : "");
|
|
316
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
317
|
+
const requestBody = {
|
|
318
|
+
provider_config_key: integrationId,
|
|
319
|
+
connection_id: connectionId,
|
|
320
|
+
credentials: {
|
|
321
|
+
type: "OAUTH2",
|
|
322
|
+
access_token: data.botToken
|
|
323
|
+
},
|
|
324
|
+
metadata: {
|
|
325
|
+
display_name: displayName,
|
|
326
|
+
connection_type: "workspace",
|
|
327
|
+
slack_team_id: data.teamId,
|
|
328
|
+
slack_team_name: data.teamName || "",
|
|
329
|
+
slack_team_domain: data.teamDomain || "",
|
|
330
|
+
slack_workspace_url: workspaceUrl,
|
|
331
|
+
slack_workspace_icon_url: data.workspaceIconUrl || "",
|
|
332
|
+
slack_enterprise_id: data.enterpriseId || "",
|
|
333
|
+
slack_enterprise_name: data.enterpriseName || "",
|
|
334
|
+
is_enterprise_install: String(data.isEnterpriseInstall || false),
|
|
335
|
+
slack_bot_user_id: data.botUserId || "",
|
|
336
|
+
slack_bot_scopes: data.botScopes || "",
|
|
337
|
+
slack_app_id: data.appId || "",
|
|
338
|
+
installed_by_slack_user_id: data.installerUserId || "",
|
|
339
|
+
installed_by_slack_user_name: data.installerUserName || "",
|
|
340
|
+
installed_at: now,
|
|
341
|
+
last_updated_at: now,
|
|
342
|
+
installation_source: data.installationSource || "dashboard",
|
|
343
|
+
inkeep_tenant_id: data.tenantId || "",
|
|
344
|
+
status: "active"
|
|
345
|
+
},
|
|
346
|
+
connection_config: { "team.id": data.teamId }
|
|
347
|
+
};
|
|
348
|
+
const response = await retryWithBackoff(async () => {
|
|
349
|
+
const controller = new AbortController();
|
|
350
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
351
|
+
try {
|
|
352
|
+
const res = await fetch(`${nangoApiUrl}/connections`, {
|
|
353
|
+
method: "POST",
|
|
354
|
+
headers: {
|
|
355
|
+
Authorization: `Bearer ${secretKey}`,
|
|
356
|
+
"Content-Type": "application/json"
|
|
357
|
+
},
|
|
358
|
+
body: JSON.stringify(requestBody),
|
|
359
|
+
signal: controller.signal
|
|
360
|
+
});
|
|
361
|
+
if (!res.ok && res.status >= 500) {
|
|
362
|
+
const errorBody = await res.text().catch(() => "Unknown error");
|
|
363
|
+
const err = /* @__PURE__ */ new Error(`Nango API error ${res.status}: ${errorBody}`);
|
|
364
|
+
err.status = res.status;
|
|
365
|
+
throw err;
|
|
366
|
+
}
|
|
367
|
+
return res;
|
|
368
|
+
} finally {
|
|
369
|
+
clearTimeout(timeout);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
const responseText = await response.text();
|
|
373
|
+
if (!response.ok) {
|
|
374
|
+
logger.error({
|
|
375
|
+
status: response.status,
|
|
376
|
+
errorBody: responseText,
|
|
377
|
+
connectionId
|
|
378
|
+
}, "Failed to import connection to Nango");
|
|
379
|
+
return {
|
|
380
|
+
connectionId,
|
|
381
|
+
success: false
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
logger.info({
|
|
385
|
+
connectionId,
|
|
386
|
+
teamId: data.teamId,
|
|
387
|
+
teamName: data.teamName
|
|
388
|
+
}, "Stored workspace installation in Nango");
|
|
389
|
+
return {
|
|
390
|
+
connectionId,
|
|
391
|
+
success: true
|
|
392
|
+
};
|
|
393
|
+
} catch (error) {
|
|
394
|
+
logger.error({
|
|
395
|
+
error,
|
|
396
|
+
connectionId,
|
|
397
|
+
teamId: data.teamId
|
|
398
|
+
}, "Failed to store workspace installation");
|
|
399
|
+
return {
|
|
400
|
+
connectionId,
|
|
401
|
+
success: false
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* List all workspace installations from Nango.
|
|
407
|
+
*/
|
|
408
|
+
async function listWorkspaceInstallations() {
|
|
409
|
+
try {
|
|
410
|
+
const nango = getSlackNango();
|
|
411
|
+
const integrationId = getSlackIntegrationId();
|
|
412
|
+
const connections = await nango.listConnections();
|
|
413
|
+
const workspaces = [];
|
|
414
|
+
for (const conn of connections.connections) if (conn.provider_config_key === integrationId) try {
|
|
415
|
+
const fullConn = await nango.getConnection(integrationId, conn.connection_id);
|
|
416
|
+
const metadata = fullConn.metadata;
|
|
417
|
+
const credentials = fullConn;
|
|
418
|
+
if (metadata?.connection_type === "workspace" && credentials.credentials?.access_token) {
|
|
419
|
+
let defaultAgent;
|
|
420
|
+
if (metadata?.default_agent) try {
|
|
421
|
+
defaultAgent = JSON.parse(metadata.default_agent);
|
|
422
|
+
} catch {}
|
|
423
|
+
workspaces.push({
|
|
424
|
+
connectionId: conn.connection_id,
|
|
425
|
+
teamId: metadata.slack_team_id || "",
|
|
426
|
+
teamName: metadata.slack_team_name,
|
|
427
|
+
botToken: credentials.credentials.access_token,
|
|
428
|
+
tenantId: metadata.tenant_id || metadata.inkeep_tenant_id || "",
|
|
429
|
+
defaultAgent
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
} catch (error) {
|
|
433
|
+
logger.warn({
|
|
434
|
+
error,
|
|
435
|
+
connectionId: conn.connection_id
|
|
436
|
+
}, "Failed to get Nango connection during list");
|
|
437
|
+
}
|
|
438
|
+
return workspaces;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
logger.error({ error }, "Failed to list workspace installations");
|
|
441
|
+
return [];
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Delete a workspace installation from Nango.
|
|
446
|
+
*/
|
|
447
|
+
async function deleteWorkspaceInstallation(connectionId) {
|
|
448
|
+
try {
|
|
449
|
+
const nango = getSlackNango();
|
|
450
|
+
const integrationId = getSlackIntegrationId();
|
|
451
|
+
logger.info({
|
|
452
|
+
connectionId,
|
|
453
|
+
integrationId
|
|
454
|
+
}, "Attempting to delete workspace installation");
|
|
455
|
+
await nango.deleteConnection(integrationId, connectionId);
|
|
456
|
+
logger.info({ connectionId }, "Deleted workspace installation from Nango");
|
|
457
|
+
return true;
|
|
458
|
+
} catch (error) {
|
|
459
|
+
const errorObj = error;
|
|
460
|
+
const errorMessage = errorObj?.message || String(error);
|
|
461
|
+
const statusCode = errorObj?.status;
|
|
462
|
+
if (statusCode === 404 || errorMessage.includes("404") || errorMessage.includes("not found")) {
|
|
463
|
+
logger.warn({ connectionId }, "Connection not found in Nango, treating as already deleted");
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
logger.error({
|
|
467
|
+
error: errorMessage,
|
|
468
|
+
statusCode,
|
|
469
|
+
connectionId
|
|
470
|
+
}, "Failed to delete workspace installation");
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
//#endregion
|
|
476
|
+
export { clearWorkspaceConnectionCache, computeWorkspaceConnectionId, createConnectSession, deleteWorkspaceInstallation, findWorkspaceConnectionByTeamId, getConnectionAccessToken, getSlackIntegrationId, getSlackNango, getWorkspaceDefaultAgentFromNango, listWorkspaceInstallations, setWorkspaceDefaultAgent, storeWorkspaceInstallation, updateConnectionMetadata };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/slack/services/security.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Slack Security Utilities
|
|
4
|
+
*
|
|
5
|
+
* Provides security functions for verifying Slack requests and parsing payloads.
|
|
6
|
+
* All incoming Slack requests are verified using HMAC-SHA256 signatures.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Verify that a request originated from Slack using HMAC-SHA256 signature.
|
|
10
|
+
*
|
|
11
|
+
* @param signingSecret - The Slack signing secret from app settings
|
|
12
|
+
* @param requestBody - The raw request body string
|
|
13
|
+
* @param timestamp - The X-Slack-Request-Timestamp header value
|
|
14
|
+
* @param signature - The X-Slack-Signature header value
|
|
15
|
+
* @returns true if the signature is valid, false otherwise
|
|
16
|
+
*/
|
|
17
|
+
declare function verifySlackRequest(signingSecret: string, requestBody: string, timestamp: string, signature: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Parse a URL-encoded Slack command body into key-value pairs.
|
|
20
|
+
*
|
|
21
|
+
* @param body - The URL-encoded request body from a slash command
|
|
22
|
+
* @returns Parsed parameters as a string record
|
|
23
|
+
*/
|
|
24
|
+
declare function parseSlackCommandBody(body: string): Record<string, string>;
|
|
25
|
+
/**
|
|
26
|
+
* Parse a Slack event body based on content type.
|
|
27
|
+
* Handles both JSON and URL-encoded payloads (for interactive components).
|
|
28
|
+
*
|
|
29
|
+
* @param body - The raw request body
|
|
30
|
+
* @param contentType - The Content-Type header value
|
|
31
|
+
* @returns Parsed event payload
|
|
32
|
+
*/
|
|
33
|
+
declare function parseSlackEventBody(body: string, contentType: string): Record<string, unknown>;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { parseSlackCommandBody, parseSlackEventBody, verifySlackRequest };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getLogger } from "../../logger.js";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
|
|
4
|
+
//#region src/slack/services/security.ts
|
|
5
|
+
/**
|
|
6
|
+
* Slack Security Utilities
|
|
7
|
+
*
|
|
8
|
+
* Provides security functions for verifying Slack requests and parsing payloads.
|
|
9
|
+
* All incoming Slack requests are verified using HMAC-SHA256 signatures.
|
|
10
|
+
*/
|
|
11
|
+
const logger = getLogger("slack-security");
|
|
12
|
+
/**
|
|
13
|
+
* Verify that a request originated from Slack using HMAC-SHA256 signature.
|
|
14
|
+
*
|
|
15
|
+
* @param signingSecret - The Slack signing secret from app settings
|
|
16
|
+
* @param requestBody - The raw request body string
|
|
17
|
+
* @param timestamp - The X-Slack-Request-Timestamp header value
|
|
18
|
+
* @param signature - The X-Slack-Signature header value
|
|
19
|
+
* @returns true if the signature is valid, false otherwise
|
|
20
|
+
*/
|
|
21
|
+
function verifySlackRequest(signingSecret, requestBody, timestamp, signature) {
|
|
22
|
+
try {
|
|
23
|
+
const fiveMinutesAgo = Math.floor(Date.now() / 1e3) - 300;
|
|
24
|
+
if (Number.parseInt(timestamp, 10) < fiveMinutesAgo) {
|
|
25
|
+
logger.warn({}, "Slack request timestamp too old");
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const sigBaseString = `v0:${timestamp}:${requestBody}`;
|
|
29
|
+
const mySignature = `v0=${crypto.createHmac("sha256", signingSecret).update(sigBaseString).digest("hex")}`;
|
|
30
|
+
return crypto.timingSafeEqual(Buffer.from(mySignature), Buffer.from(signature));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
logger.error({ error }, "Error verifying Slack request");
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse a URL-encoded Slack command body into key-value pairs.
|
|
38
|
+
*
|
|
39
|
+
* @param body - The URL-encoded request body from a slash command
|
|
40
|
+
* @returns Parsed parameters as a string record
|
|
41
|
+
*/
|
|
42
|
+
function parseSlackCommandBody(body) {
|
|
43
|
+
const params = new URLSearchParams(body);
|
|
44
|
+
return Object.fromEntries(params.entries());
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse a Slack event body based on content type.
|
|
48
|
+
* Handles both JSON and URL-encoded payloads (for interactive components).
|
|
49
|
+
*
|
|
50
|
+
* @param body - The raw request body
|
|
51
|
+
* @param contentType - The Content-Type header value
|
|
52
|
+
* @returns Parsed event payload
|
|
53
|
+
*/
|
|
54
|
+
function parseSlackEventBody(body, contentType) {
|
|
55
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
56
|
+
const params = new URLSearchParams(body);
|
|
57
|
+
const payload = params.get("payload");
|
|
58
|
+
if (payload) return JSON.parse(payload);
|
|
59
|
+
return Object.fromEntries(params.entries());
|
|
60
|
+
}
|
|
61
|
+
return JSON.parse(body);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
export { parseSlackCommandBody, parseSlackEventBody, verifySlackRequest };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MessageAttachment } from "@slack/types";
|
|
2
|
+
|
|
3
|
+
//#region src/slack/services/types.d.ts
|
|
4
|
+
interface SlackCommandPayload {
|
|
5
|
+
command: string;
|
|
6
|
+
text: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
userName: string;
|
|
9
|
+
teamId: string;
|
|
10
|
+
teamDomain: string;
|
|
11
|
+
enterpriseId?: string;
|
|
12
|
+
channelId: string;
|
|
13
|
+
channelName: string;
|
|
14
|
+
responseUrl: string;
|
|
15
|
+
triggerId: string;
|
|
16
|
+
}
|
|
17
|
+
interface SlackCommandResponse {
|
|
18
|
+
response_type?: 'ephemeral' | 'in_channel';
|
|
19
|
+
text?: string;
|
|
20
|
+
blocks?: unknown[];
|
|
21
|
+
attachments?: MessageAttachment[];
|
|
22
|
+
replace_original?: boolean;
|
|
23
|
+
delete_original?: boolean;
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
export { SlackCommandPayload, SlackCommandResponse };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/slack/services/workspace-tokens.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Workspace Token Service
|
|
4
|
+
*
|
|
5
|
+
* In-memory cache for Slack bot tokens during OAuth installation flow.
|
|
6
|
+
* Primary token storage is in Nango; this is a temporary fallback.
|
|
7
|
+
*
|
|
8
|
+
* Note: Tokens stored here do not persist across server restarts.
|
|
9
|
+
* Always prefer fetching tokens from Nango for production use.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Get the cached bot token for a Slack team.
|
|
13
|
+
* Falls back to null if not cached (caller should fetch from Nango).
|
|
14
|
+
*/
|
|
15
|
+
declare function getBotTokenForTeam(teamId: string): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Cache a bot token for a Slack team (used during OAuth installation).
|
|
18
|
+
*/
|
|
19
|
+
declare function setBotTokenForTeam(teamId: string, data: {
|
|
20
|
+
botToken: string;
|
|
21
|
+
teamName: string;
|
|
22
|
+
installedAt: string;
|
|
23
|
+
}): void;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { getBotTokenForTeam, setBotTokenForTeam };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/slack/services/workspace-tokens.ts
|
|
2
|
+
/**
|
|
3
|
+
* Workspace Token Service
|
|
4
|
+
*
|
|
5
|
+
* In-memory cache for Slack bot tokens during OAuth installation flow.
|
|
6
|
+
* Primary token storage is in Nango; this is a temporary fallback.
|
|
7
|
+
*
|
|
8
|
+
* Note: Tokens stored here do not persist across server restarts.
|
|
9
|
+
* Always prefer fetching tokens from Nango for production use.
|
|
10
|
+
*/
|
|
11
|
+
const workspaceBotTokens = /* @__PURE__ */ new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Get the cached bot token for a Slack team.
|
|
14
|
+
* Falls back to null if not cached (caller should fetch from Nango).
|
|
15
|
+
*/
|
|
16
|
+
function getBotTokenForTeam(teamId) {
|
|
17
|
+
return workspaceBotTokens.get(teamId)?.botToken || null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Cache a bot token for a Slack team (used during OAuth installation).
|
|
21
|
+
*/
|
|
22
|
+
function setBotTokenForTeam(teamId, data) {
|
|
23
|
+
workspaceBotTokens.set(teamId, data);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
export { getBotTokenForTeam, setBotTokenForTeam };
|