@shipfox/api-integration-slack 4.0.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/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/dist/api/client.d.ts +28 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +163 -0
- package/dist/api/client.js.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +21 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent-tools-provider.d.ts +75 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +111 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +65 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +177 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/disconnect.d.ts +18 -0
- package/dist/core/disconnect.d.ts.map +1 -0
- package/dist/core/disconnect.js +23 -0
- package/dist/core/disconnect.js.map +1 -0
- package/dist/core/errors.d.ts +44 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +71 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/install.d.ts +48 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +88 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/scopes.d.ts +4 -0
- package/dist/core/scopes.d.ts.map +1 -0
- package/dist/core/scopes.js +24 -0
- package/dist/core/scopes.js.map +1 -0
- package/dist/core/signature.d.ts +10 -0
- package/dist/core/signature.d.ts.map +1 -0
- package/dist/core/signature.js +18 -0
- package/dist/core/signature.js.map +1 -0
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/state.js +54 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/tokens.d.ts +35 -0
- package/dist/core/tokens.d.ts.map +1 -0
- package/dist/core/tokens.js +51 -0
- package/dist/core/tokens.js.map +1 -0
- package/dist/core/webhook.d.ts +25 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +160 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +403 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/installations.d.ts +40 -0
- package/dist/db/installations.d.ts.map +1 -0
- package/dist/db/installations.js +73 -0
- package/dist/db/installations.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/installations.d.ts +203 -0
- package/dist/db/schema/installations.d.ts.map +1 -0
- package/dist/db/schema/installations.js +42 -0
- package/dist/db/schema/installations.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/integrations.d.ts +2 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +3 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts +16 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.js +65 -0
- package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +5 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +11 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +75 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +19 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +98 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/presentation/routes/webhooks.d.ts +16 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +166 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_calm_fantastic_four.sql +16 -0
- package/drizzle/meta/0000_snapshot.json +132 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +71 -0
- package/src/api/client.test.ts +239 -0
- package/src/api/client.ts +243 -0
- package/src/config.test.ts +32 -0
- package/src/config.ts +20 -0
- package/src/connection-external-url.test.ts +25 -0
- package/src/core/agent-tools-provider.test.ts +256 -0
- package/src/core/agent-tools-provider.ts +147 -0
- package/src/core/agent-tools.test.ts +90 -0
- package/src/core/agent-tools.ts +189 -0
- package/src/core/disconnect.ts +26 -0
- package/src/core/errors.ts +92 -0
- package/src/core/install.test.ts +155 -0
- package/src/core/install.ts +137 -0
- package/src/core/scopes.test.ts +22 -0
- package/src/core/scopes.ts +25 -0
- package/src/core/signature.test.ts +45 -0
- package/src/core/signature.ts +41 -0
- package/src/core/state.test.ts +48 -0
- package/src/core/state.ts +82 -0
- package/src/core/tokens.test.ts +163 -0
- package/src/core/tokens.ts +98 -0
- package/src/core/webhook.test.ts +269 -0
- package/src/core/webhook.ts +223 -0
- package/src/db/db.ts +16 -0
- package/src/db/installations.test.ts +93 -0
- package/src/db/installations.ts +141 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/installations.ts +44 -0
- package/src/index.test.ts +25 -0
- package/src/index.ts +196 -0
- package/src/presentation/dto/integrations.ts +1 -0
- package/src/presentation/e2eRoutes/create-connection.ts +82 -0
- package/src/presentation/e2eRoutes/index.test.ts +96 -0
- package/src/presentation/e2eRoutes/index.ts +11 -0
- package/src/presentation/routes/errors.ts +68 -0
- package/src/presentation/routes/install.test.ts +229 -0
- package/src/presentation/routes/install.ts +117 -0
- package/src/presentation/routes/webhooks.test.ts +266 -0
- package/src/presentation/routes/webhooks.ts +170 -0
- package/test/api-secrets.d.ts +26 -0
- package/test/env.ts +13 -0
- package/test/factories/index.ts +1 -0
- package/test/globalSetup.ts +23 -0
- package/test/index.ts +1 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import ky, {HTTPError, TimeoutError} from 'ky';
|
|
3
|
+
import {config} from '#config.js';
|
|
4
|
+
import {
|
|
5
|
+
SlackEnterpriseInstallUnsupportedError,
|
|
6
|
+
SlackIntegrationProviderError,
|
|
7
|
+
SlackTokenRotationUnsupportedError,
|
|
8
|
+
} from '#core/errors.js';
|
|
9
|
+
|
|
10
|
+
const SLACK_API_TIMEOUT_MS = 10_000;
|
|
11
|
+
|
|
12
|
+
export interface SlackAuthorization {
|
|
13
|
+
accessToken: string;
|
|
14
|
+
botUserId: string;
|
|
15
|
+
appId: string;
|
|
16
|
+
teamId: string;
|
|
17
|
+
teamName: string;
|
|
18
|
+
scopes: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SlackWebApiResponse {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
error?: string | undefined;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SlackApiClient {
|
|
28
|
+
exchangeAuthorizationCode(input: {code: string}): Promise<SlackAuthorization>;
|
|
29
|
+
revokeToken(input: {token: string}): Promise<void>;
|
|
30
|
+
callMethod(input: {
|
|
31
|
+
method: string;
|
|
32
|
+
token: string;
|
|
33
|
+
arguments: Record<string, unknown>;
|
|
34
|
+
}): Promise<SlackWebApiResponse>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SlackOAuthAccessResponse {
|
|
38
|
+
ok?: unknown;
|
|
39
|
+
error?: unknown;
|
|
40
|
+
access_token?: unknown;
|
|
41
|
+
bot_user_id?: unknown;
|
|
42
|
+
app_id?: unknown;
|
|
43
|
+
team?: {id?: unknown; name?: unknown} | null | undefined;
|
|
44
|
+
scope?: unknown;
|
|
45
|
+
is_enterprise_install?: unknown;
|
|
46
|
+
expires_in?: unknown;
|
|
47
|
+
refresh_token?: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createSlackApiClient(): SlackApiClient {
|
|
51
|
+
return {
|
|
52
|
+
async exchangeAuthorizationCode(input) {
|
|
53
|
+
const body = await mapSlackError('exchange-authorization-code', () =>
|
|
54
|
+
ky
|
|
55
|
+
.post(slackApiUrl('oauth.v2.access'), {
|
|
56
|
+
body: new URLSearchParams({
|
|
57
|
+
client_id: config.SLACK_OAUTH_CLIENT_ID,
|
|
58
|
+
client_secret: config.SLACK_OAUTH_CLIENT_SECRET,
|
|
59
|
+
code: input.code,
|
|
60
|
+
redirect_uri: config.SLACK_OAUTH_REDIRECT_URL,
|
|
61
|
+
}),
|
|
62
|
+
timeout: SLACK_API_TIMEOUT_MS,
|
|
63
|
+
})
|
|
64
|
+
.json<SlackOAuthAccessResponse>(),
|
|
65
|
+
);
|
|
66
|
+
return parseOAuthAccess(body);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async revokeToken(input) {
|
|
70
|
+
await mapSlackError('revoke-token', async () => {
|
|
71
|
+
await ky.post(slackApiUrl('auth.revoke'), {
|
|
72
|
+
headers: {authorization: `Bearer ${input.token}`},
|
|
73
|
+
timeout: SLACK_API_TIMEOUT_MS,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async callMethod(input) {
|
|
79
|
+
return await mapSlackWebApiError(input.method, () =>
|
|
80
|
+
ky
|
|
81
|
+
.post(slackApiUrl(input.method), {
|
|
82
|
+
headers: {authorization: `Bearer ${input.token}`},
|
|
83
|
+
body: slackMethodArguments(input.arguments),
|
|
84
|
+
timeout: SLACK_API_TIMEOUT_MS,
|
|
85
|
+
})
|
|
86
|
+
.json<SlackWebApiResponse>(),
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseOAuthAccess(body: SlackOAuthAccessResponse): SlackAuthorization {
|
|
93
|
+
if (body.ok !== true) {
|
|
94
|
+
throw new SlackIntegrationProviderError(
|
|
95
|
+
slackOAuthErrorReason(body.error),
|
|
96
|
+
'Slack authorization request failed',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (body.is_enterprise_install === true || body.team == null) {
|
|
100
|
+
throw new SlackEnterpriseInstallUnsupportedError();
|
|
101
|
+
}
|
|
102
|
+
// The bot-token store has no refresh path, so a rotating (expiring) token must never be persisted.
|
|
103
|
+
// Slack omits both fields when rotation is off, so presence of either signals rotation.
|
|
104
|
+
// The rejected token self-expires, matching the Enterprise Grid rejection path.
|
|
105
|
+
if (body.expires_in !== undefined || body.refresh_token !== undefined) {
|
|
106
|
+
throw new SlackTokenRotationUnsupportedError();
|
|
107
|
+
}
|
|
108
|
+
const {access_token: accessToken, bot_user_id: botUserId, app_id: appId, team, scope} = body;
|
|
109
|
+
if (
|
|
110
|
+
typeof accessToken !== 'string' ||
|
|
111
|
+
typeof botUserId !== 'string' ||
|
|
112
|
+
typeof appId !== 'string' ||
|
|
113
|
+
typeof team.id !== 'string' ||
|
|
114
|
+
typeof team.name !== 'string' ||
|
|
115
|
+
typeof scope !== 'string'
|
|
116
|
+
) {
|
|
117
|
+
throw new SlackIntegrationProviderError(
|
|
118
|
+
'malformed-provider-response',
|
|
119
|
+
'Slack authorization response did not include the required installation details',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
accessToken,
|
|
124
|
+
botUserId,
|
|
125
|
+
appId,
|
|
126
|
+
teamId: team.id,
|
|
127
|
+
teamName: team.name,
|
|
128
|
+
scopes: scope
|
|
129
|
+
.split(',')
|
|
130
|
+
.map((value) => value.trim())
|
|
131
|
+
.filter(Boolean),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function slackOAuthErrorReason(error: unknown) {
|
|
136
|
+
if (error === 'service_unavailable' || error === 'internal_error') return 'provider-unavailable';
|
|
137
|
+
if (error === 'ratelimited') return 'rate-limited';
|
|
138
|
+
return 'access-denied';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function mapSlackError<T>(operation: string, request: () => Promise<T>): Promise<T> {
|
|
142
|
+
try {
|
|
143
|
+
return await request();
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof SlackIntegrationProviderError) throw error;
|
|
146
|
+
if (error instanceof HTTPError) {
|
|
147
|
+
const {status, statusText, headers} = error.response;
|
|
148
|
+
logger().warn({operation, status, statusText}, 'Slack API request rejected');
|
|
149
|
+
if (status === 429) {
|
|
150
|
+
throw new SlackIntegrationProviderError(
|
|
151
|
+
'rate-limited',
|
|
152
|
+
'Slack request was rate limited',
|
|
153
|
+
retryAfterSeconds(headers),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (status >= 500) {
|
|
157
|
+
throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');
|
|
158
|
+
}
|
|
159
|
+
throw new SlackIntegrationProviderError('access-denied', 'Slack request was rejected');
|
|
160
|
+
}
|
|
161
|
+
if (error instanceof TimeoutError) {
|
|
162
|
+
logger().warn({operation}, 'Slack API request timed out');
|
|
163
|
+
throw new SlackIntegrationProviderError('timeout', 'Slack request timed out');
|
|
164
|
+
}
|
|
165
|
+
logger().warn(
|
|
166
|
+
{operation, errName: error instanceof Error ? error.name : typeof error},
|
|
167
|
+
'Slack API request failed',
|
|
168
|
+
);
|
|
169
|
+
throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function mapSlackWebApiError<T>(method: string, request: () => Promise<T>): Promise<T> {
|
|
174
|
+
try {
|
|
175
|
+
return await request();
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error instanceof SlackIntegrationProviderError) throw error;
|
|
178
|
+
if (error instanceof HTTPError) {
|
|
179
|
+
const {status, statusText, headers} = error.response;
|
|
180
|
+
logger().warn(
|
|
181
|
+
{operation: 'call-method', method, status, statusText},
|
|
182
|
+
'Slack API request rejected',
|
|
183
|
+
);
|
|
184
|
+
if (status === 429) {
|
|
185
|
+
throw new SlackIntegrationProviderError(
|
|
186
|
+
'rate-limited',
|
|
187
|
+
'Slack request was rate limited',
|
|
188
|
+
retryAfterSeconds(headers),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (status === 413) {
|
|
192
|
+
throw new SlackIntegrationProviderError(
|
|
193
|
+
'content-too-large',
|
|
194
|
+
'Slack request content was too large',
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (status >= 500) {
|
|
198
|
+
throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');
|
|
199
|
+
}
|
|
200
|
+
throw new SlackIntegrationProviderError(
|
|
201
|
+
'malformed-provider-response',
|
|
202
|
+
'Slack request was rejected',
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (error instanceof TimeoutError) {
|
|
206
|
+
logger().warn({operation: 'call-method', method}, 'Slack API request timed out');
|
|
207
|
+
throw new SlackIntegrationProviderError('timeout', 'Slack request timed out');
|
|
208
|
+
}
|
|
209
|
+
logger().warn(
|
|
210
|
+
{
|
|
211
|
+
operation: 'call-method',
|
|
212
|
+
method,
|
|
213
|
+
errName: error instanceof Error ? error.name : typeof error,
|
|
214
|
+
},
|
|
215
|
+
'Slack API request failed',
|
|
216
|
+
);
|
|
217
|
+
throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function slackMethodArguments(input: Record<string, unknown>): URLSearchParams {
|
|
222
|
+
const body = new URLSearchParams();
|
|
223
|
+
for (const [key, value] of Object.entries(input)) {
|
|
224
|
+
if (value === null || value === undefined) continue;
|
|
225
|
+
const serialized =
|
|
226
|
+
typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
|
227
|
+
? String(value)
|
|
228
|
+
: JSON.stringify(value);
|
|
229
|
+
if (serialized !== undefined) body.set(key, serialized);
|
|
230
|
+
}
|
|
231
|
+
return body;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function slackApiUrl(path: string): string {
|
|
235
|
+
return new URL(path, `${config.SLACK_API_BASE_URL}/`).toString();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function retryAfterSeconds(headers: Headers): number | undefined {
|
|
239
|
+
const retryAfter = headers.get('retry-after');
|
|
240
|
+
if (!retryAfter) return undefined;
|
|
241
|
+
const parsed = Number.parseInt(retryAfter, 10);
|
|
242
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
243
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const slackEnvNames = [
|
|
2
|
+
'SLACK_OAUTH_CLIENT_ID',
|
|
3
|
+
'SLACK_OAUTH_CLIENT_SECRET',
|
|
4
|
+
'SLACK_SIGNING_SECRET',
|
|
5
|
+
'SLACK_OAUTH_REDIRECT_URL',
|
|
6
|
+
] as const;
|
|
7
|
+
|
|
8
|
+
describe('slack config', () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.unstubAllEnvs();
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('exports validated Slack config from the package root', async () => {
|
|
15
|
+
vi.stubEnv('SLACK_API_BASE_URL', undefined);
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
|
|
18
|
+
const {config} = await import('#index.js');
|
|
19
|
+
|
|
20
|
+
for (const name of slackEnvNames) expect(config[name]).toBe(process.env[name]);
|
|
21
|
+
expect(config.SLACK_API_BASE_URL).toBe('https://slack.com/api');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('accepts a compatible Slack API base URL override', async () => {
|
|
25
|
+
vi.stubEnv('SLACK_API_BASE_URL', 'https://slack-api.example.test');
|
|
26
|
+
vi.resetModules();
|
|
27
|
+
|
|
28
|
+
const {config} = await import('#index.js');
|
|
29
|
+
|
|
30
|
+
expect(config.SLACK_API_BASE_URL).toBe('https://slack-api.example.test');
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {createConfig, str, url} from '@shipfox/config';
|
|
2
|
+
|
|
3
|
+
export const config = createConfig({
|
|
4
|
+
SLACK_OAUTH_CLIENT_ID: str({
|
|
5
|
+
desc: 'OAuth client ID of the Slack app, used to start and complete the Slack connect flow. Required.',
|
|
6
|
+
}),
|
|
7
|
+
SLACK_OAUTH_CLIENT_SECRET: str({
|
|
8
|
+
desc: 'OAuth client secret of the Slack app, used to exchange authorization codes for workspace bot tokens. Required.',
|
|
9
|
+
}),
|
|
10
|
+
SLACK_SIGNING_SECRET: str({
|
|
11
|
+
desc: 'Secret used to verify incoming Slack events and commands. It must match the signing secret configured on the Slack app. Required.',
|
|
12
|
+
}),
|
|
13
|
+
SLACK_OAUTH_REDIRECT_URL: url({
|
|
14
|
+
desc: 'Public client callback URL Slack redirects to after OAuth authorization, such as https://shipfox.example.com/integrations/slack/callback. Required.',
|
|
15
|
+
}),
|
|
16
|
+
SLACK_API_BASE_URL: url({
|
|
17
|
+
desc: 'Slack Web API base URL. Override this only when routing Slack API requests through a proxy or test server.',
|
|
18
|
+
default: 'https://slack.com/api',
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type {SlackInstallation} from '#db/installations.js';
|
|
2
|
+
import {createSlackIntegrationProvider} from '#index.js';
|
|
3
|
+
|
|
4
|
+
describe('Slack connectionExternalUrl', () => {
|
|
5
|
+
it('resolves the Slack team URL from the installation row', async () => {
|
|
6
|
+
const provider = createSlackIntegrationProvider({
|
|
7
|
+
getSlackInstallationByConnectionId: () =>
|
|
8
|
+
Promise.resolve({teamId: 'T 123'} as SlackInstallation),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const result = await provider.connectionExternalUrl({id: crypto.randomUUID()});
|
|
12
|
+
|
|
13
|
+
expect(result).toBe('https://app.slack.com/client/T%20123');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns undefined when the installation is missing', async () => {
|
|
17
|
+
const provider = createSlackIntegrationProvider({
|
|
18
|
+
getSlackInstallationByConnectionId: () => Promise.resolve(undefined),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const result = await provider.connectionExternalUrl({id: crypto.randomUUID()});
|
|
22
|
+
|
|
23
|
+
expect(result).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import type {SlackWebApiResponse} from '#api/client.js';
|
|
3
|
+
import {slackAgentToolCatalog, slackAgentToolSelectionCatalog} from '#core/agent-tools.js';
|
|
4
|
+
import {SlackAgentToolsProvider} from '#core/agent-tools-provider.js';
|
|
5
|
+
import {SlackBotTokenMissingError, SlackIntegrationProviderError} from '#core/errors.js';
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({warn: vi.fn()}));
|
|
8
|
+
|
|
9
|
+
vi.mock('@shipfox/node-opentelemetry', () => ({logger: () => ({warn: mocks.warn})}));
|
|
10
|
+
|
|
11
|
+
function slackConnection(
|
|
12
|
+
overrides: Partial<IntegrationConnection<'slack'>> = {},
|
|
13
|
+
): IntegrationConnection<'slack'> {
|
|
14
|
+
const now = new Date();
|
|
15
|
+
return {
|
|
16
|
+
id: 'slack-connection-1',
|
|
17
|
+
workspaceId: 'workspace-1',
|
|
18
|
+
provider: 'slack',
|
|
19
|
+
externalAccountId: 'T123',
|
|
20
|
+
slug: 'slack-acme',
|
|
21
|
+
displayName: 'Slack Acme',
|
|
22
|
+
lifecycleStatus: 'active',
|
|
23
|
+
createdAt: now,
|
|
24
|
+
updatedAt: now,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function catalogTool(id: string) {
|
|
30
|
+
const tool = slackAgentToolCatalog.find((candidate) => candidate.id === id);
|
|
31
|
+
if (!tool) throw new Error(`Unknown test tool: ${id}`);
|
|
32
|
+
return tool;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function providerOptions(
|
|
36
|
+
callMethod: (input: {
|
|
37
|
+
method: string;
|
|
38
|
+
token: string;
|
|
39
|
+
arguments: Record<string, unknown>;
|
|
40
|
+
}) => Promise<SlackWebApiResponse>,
|
|
41
|
+
) {
|
|
42
|
+
return {
|
|
43
|
+
slack: {callMethod: vi.fn(callMethod)},
|
|
44
|
+
tokenStore: {getAccessToken: vi.fn().mockResolvedValue('xoxb-token')},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function openSession(
|
|
49
|
+
options: ReturnType<typeof providerOptions>,
|
|
50
|
+
toolIds: string[],
|
|
51
|
+
connection = slackConnection(),
|
|
52
|
+
) {
|
|
53
|
+
const provider = new SlackAgentToolsProvider(options);
|
|
54
|
+
return await provider.openSession({
|
|
55
|
+
connection,
|
|
56
|
+
tools: toolIds.map(catalogTool),
|
|
57
|
+
scope: {provider: 'slack'},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('SlackAgentToolsProvider', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
mocks.warn.mockReset();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns the Slack agent tools catalogs', () => {
|
|
67
|
+
const options = providerOptions(async () => ({ok: true}));
|
|
68
|
+
const provider = new SlackAgentToolsProvider(options);
|
|
69
|
+
|
|
70
|
+
const catalog = provider.catalog();
|
|
71
|
+
const selectionCatalog = provider.selectionCatalog();
|
|
72
|
+
|
|
73
|
+
expect(catalog).toBe(slackAgentToolCatalog);
|
|
74
|
+
expect(selectionCatalog).toBe(slackAgentToolSelectionCatalog);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('reads the stored bot token for the connection before opening a session', async () => {
|
|
78
|
+
const options = providerOptions(async () => ({ok: true}));
|
|
79
|
+
const provider = new SlackAgentToolsProvider(options);
|
|
80
|
+
|
|
81
|
+
await provider.openSession({
|
|
82
|
+
connection: slackConnection({id: 'slack-connection-7'}),
|
|
83
|
+
tools: [],
|
|
84
|
+
scope: {provider: 'slack'},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(options.tokenStore.getAccessToken).toHaveBeenCalledWith({
|
|
88
|
+
connectionId: 'slack-connection-7',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('dispatches a read method and returns the Slack response as structured content', async () => {
|
|
93
|
+
const body = {ok: true, messages: [{text: 'Hello'}]};
|
|
94
|
+
const options = providerOptions(async () => body);
|
|
95
|
+
const session = await openSession(options, ['conversations_history']);
|
|
96
|
+
|
|
97
|
+
const result = await session.call({
|
|
98
|
+
toolId: 'conversations_history',
|
|
99
|
+
arguments: {channel: 'C123', limit: 10},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(options.slack.callMethod).toHaveBeenCalledWith({
|
|
103
|
+
method: 'conversations.history',
|
|
104
|
+
token: 'xoxb-token',
|
|
105
|
+
arguments: {channel: 'C123', limit: 10},
|
|
106
|
+
});
|
|
107
|
+
expect(result).toEqual({
|
|
108
|
+
content: [{type: 'text', text: JSON.stringify(body)}],
|
|
109
|
+
structuredContent: body,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('passes a thread timestamp through to the message-posting method', async () => {
|
|
114
|
+
const options = providerOptions(async () => ({ok: true, ts: '456.000'}));
|
|
115
|
+
const session = await openSession(options, ['chat_postMessage']);
|
|
116
|
+
|
|
117
|
+
await session.call({
|
|
118
|
+
toolId: 'chat_postMessage',
|
|
119
|
+
arguments: {channel: 'C123', text: 'Reply', thread_ts: '123.000'},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(options.slack.callMethod).toHaveBeenCalledWith({
|
|
123
|
+
method: 'chat.postMessage',
|
|
124
|
+
token: 'xoxb-token',
|
|
125
|
+
arguments: {channel: 'C123', text: 'Reply', thread_ts: '123.000'},
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('rejects a tool that was not selected for the session', async () => {
|
|
130
|
+
const options = providerOptions(async () => ({ok: true}));
|
|
131
|
+
const session = await openSession(options, ['conversations_history']);
|
|
132
|
+
|
|
133
|
+
const result = await session.call({toolId: 'users_info', arguments: {user: 'U123'}});
|
|
134
|
+
|
|
135
|
+
expect(result).toEqual({
|
|
136
|
+
isError: true,
|
|
137
|
+
content: [{type: 'text', text: 'Unknown Slack tool: users_info'}],
|
|
138
|
+
});
|
|
139
|
+
expect(options.slack.callMethod).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects a call missing a required parameter', async () => {
|
|
143
|
+
const options = providerOptions(async () => ({ok: true}));
|
|
144
|
+
const session = await openSession(options, ['conversations_replies']);
|
|
145
|
+
|
|
146
|
+
const result = await session.call({
|
|
147
|
+
toolId: 'conversations_replies',
|
|
148
|
+
arguments: {channel: 'C123'},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result).toEqual({
|
|
152
|
+
isError: true,
|
|
153
|
+
content: [{type: 'text', text: 'Missing required parameter: ts'}],
|
|
154
|
+
});
|
|
155
|
+
expect(options.slack.callMethod).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns a Slack application error to the agent', async () => {
|
|
159
|
+
const options = providerOptions(async () => ({ok: false, error: 'channel_not_found'}));
|
|
160
|
+
const session = await openSession(options, ['conversations_history']);
|
|
161
|
+
|
|
162
|
+
const result = await session.call({
|
|
163
|
+
toolId: 'conversations_history',
|
|
164
|
+
arguments: {channel: 'C123'},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(result).toEqual({
|
|
168
|
+
isError: true,
|
|
169
|
+
content: [{type: 'text', text: 'channel_not_found'}],
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns auth failures with a stable access-denied code and logs only metadata', async () => {
|
|
174
|
+
const options = providerOptions(async () => ({ok: false, error: 'invalid_auth'}));
|
|
175
|
+
const session = await openSession(options, ['conversations_history']);
|
|
176
|
+
|
|
177
|
+
const result = await session.call({
|
|
178
|
+
toolId: 'conversations_history',
|
|
179
|
+
arguments: {channel: 'C123'},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result).toMatchObject({
|
|
183
|
+
isError: true,
|
|
184
|
+
content: [{type: 'text', text: 'invalid_auth'}],
|
|
185
|
+
structuredContent: {code: 'access-denied'},
|
|
186
|
+
});
|
|
187
|
+
expect(mocks.warn).toHaveBeenCalledWith(
|
|
188
|
+
{connectionId: 'slack-connection-1', slackError: 'invalid_auth'},
|
|
189
|
+
'Slack API rejected integration credentials',
|
|
190
|
+
);
|
|
191
|
+
expect(JSON.stringify(mocks.warn.mock.calls)).not.toContain('xoxb-token');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('returns an HTTP-200 rate-limit response with a stable code', async () => {
|
|
195
|
+
const options = providerOptions(async () => ({ok: false, error: 'ratelimited'}));
|
|
196
|
+
const session = await openSession(options, ['conversations_list']);
|
|
197
|
+
|
|
198
|
+
const result = await session.call({toolId: 'conversations_list', arguments: {}});
|
|
199
|
+
|
|
200
|
+
expect(result).toMatchObject({
|
|
201
|
+
isError: true,
|
|
202
|
+
structuredContent: {code: 'rate-limited'},
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('preserves retry details from a transport-level rate limit', async () => {
|
|
207
|
+
const options = providerOptions(() =>
|
|
208
|
+
Promise.reject(new SlackIntegrationProviderError('rate-limited', 'Try again later', 19)),
|
|
209
|
+
);
|
|
210
|
+
const session = await openSession(options, ['conversations_list']);
|
|
211
|
+
|
|
212
|
+
const result = await session.call({toolId: 'conversations_list', arguments: {}});
|
|
213
|
+
|
|
214
|
+
expect(result).toMatchObject({
|
|
215
|
+
isError: true,
|
|
216
|
+
content: [{type: 'text', text: 'Try again later'}],
|
|
217
|
+
structuredContent: {code: 'rate-limited', retryAfterSeconds: 19},
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('preserves content-too-large from a rejected Slack request', async () => {
|
|
222
|
+
const options = providerOptions(() =>
|
|
223
|
+
Promise.reject(
|
|
224
|
+
new SlackIntegrationProviderError('content-too-large', 'Slack content was too large'),
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
const session = await openSession(options, ['chat_postMessage']);
|
|
228
|
+
|
|
229
|
+
const result = await session.call({
|
|
230
|
+
toolId: 'chat_postMessage',
|
|
231
|
+
arguments: {channel: 'C123', blocks: []},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result).toMatchObject({
|
|
235
|
+
isError: true,
|
|
236
|
+
structuredContent: {code: 'content-too-large'},
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('does not call Slack when the bot token is missing', async () => {
|
|
241
|
+
const options = providerOptions(async () => ({ok: true}));
|
|
242
|
+
options.tokenStore.getAccessToken.mockRejectedValue(
|
|
243
|
+
new SlackBotTokenMissingError('slack-connection-1'),
|
|
244
|
+
);
|
|
245
|
+
const provider = new SlackAgentToolsProvider(options);
|
|
246
|
+
|
|
247
|
+
const result = provider.openSession({
|
|
248
|
+
connection: slackConnection(),
|
|
249
|
+
tools: [catalogTool('conversations_list')],
|
|
250
|
+
scope: {provider: 'slack'},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await expect(result).rejects.toBeInstanceOf(SlackBotTokenMissingError);
|
|
254
|
+
expect(options.slack.callMethod).not.toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
});
|