@shipfox/api-integration-slack 10.2.0 → 12.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 +1 -1
- package/CHANGELOG.md +38 -0
- package/dist/core/agent-tools-provider.d.ts +41 -13
- package/dist/core/agent-tools-provider.d.ts.map +1 -1
- package/dist/core/agent-tools-provider.js +16 -8
- package/dist/core/agent-tools-provider.js.map +1 -1
- package/dist/core/agent-tools.d.ts +154 -21
- package/dist/core/agent-tools.d.ts.map +1 -1
- package/dist/core/agent-tools.js +317 -76
- package/dist/core/agent-tools.js.map +1 -1
- package/dist/core/scopes.d.ts.map +1 -1
- package/dist/core/scopes.js +4 -0
- package/dist/core/scopes.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +13 -9
- package/src/core/agent-tools-provider.test.ts +77 -30
- package/src/core/agent-tools-provider.ts +15 -8
- package/src/core/agent-tools.test.ts +204 -33
- package/src/core/agent-tools.ts +355 -62
- package/src/core/install.test.ts +9 -1
- package/src/core/scopes.ts +4 -0
- package/src/index.ts +1 -5
- package/src/presentation/routes/install.test.ts +7 -3
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-slack",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "12.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -15,22 +15,26 @@
|
|
|
15
15
|
".": {
|
|
16
16
|
"types": "./dist/index.d.ts",
|
|
17
17
|
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./agent-tools": {
|
|
20
|
+
"types": "./dist/core/agent-tools.d.ts",
|
|
21
|
+
"default": "./dist/core/agent-tools.js"
|
|
18
22
|
}
|
|
19
23
|
},
|
|
20
24
|
"dependencies": {
|
|
21
25
|
"drizzle-orm": "^0.45.2",
|
|
22
26
|
"ky": "^2.0.0",
|
|
23
27
|
"zod": "^4.4.3",
|
|
24
|
-
"@shipfox/api-auth-context": "
|
|
25
|
-
"@shipfox/api-integration-spi": "0.
|
|
26
|
-
"@shipfox/api-integration-slack-dto": "
|
|
27
|
-
"@shipfox/api-workspaces-dto": "
|
|
28
|
+
"@shipfox/api-auth-context": "12.0.0",
|
|
29
|
+
"@shipfox/api-integration-spi": "1.0.0",
|
|
30
|
+
"@shipfox/api-integration-slack-dto": "12.0.0",
|
|
31
|
+
"@shipfox/api-workspaces-dto": "12.0.0",
|
|
28
32
|
"@shipfox/config": "1.2.4",
|
|
29
|
-
"@shipfox/inter-module": "0.2.
|
|
30
|
-
"@shipfox/node-drizzle": "0.3.
|
|
31
|
-
"@shipfox/node-fastify": "0.4.
|
|
33
|
+
"@shipfox/inter-module": "0.2.3",
|
|
34
|
+
"@shipfox/node-drizzle": "0.3.5",
|
|
35
|
+
"@shipfox/node-fastify": "0.4.1",
|
|
32
36
|
"@shipfox/node-opentelemetry": "0.6.3",
|
|
33
|
-
"@shipfox/node-postgres": "0.
|
|
37
|
+
"@shipfox/node-postgres": "0.5.0"
|
|
34
38
|
},
|
|
35
39
|
"imports": {
|
|
36
40
|
"#*": "./dist/*"
|
|
@@ -92,17 +92,17 @@ describe('SlackAgentToolsProvider', () => {
|
|
|
92
92
|
it('dispatches a read method and returns the Slack response as structured content', async () => {
|
|
93
93
|
const body = {ok: true, messages: [{text: 'Hello'}]};
|
|
94
94
|
const options = providerOptions(async () => body);
|
|
95
|
-
const session = await openSession(options, ['
|
|
95
|
+
const session = await openSession(options, ['read_channel']);
|
|
96
96
|
|
|
97
97
|
const result = await session.call({
|
|
98
|
-
toolId: '
|
|
99
|
-
arguments: {
|
|
98
|
+
toolId: 'read_channel',
|
|
99
|
+
arguments: {channel_id: 'C123', limit: 10},
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
expect(options.slack.callMethod).toHaveBeenCalledWith({
|
|
103
103
|
method: 'conversations.history',
|
|
104
104
|
token: 'xoxb-token',
|
|
105
|
-
arguments: {channel: 'C123', limit: 10},
|
|
105
|
+
arguments: expect.objectContaining({channel: 'C123', limit: 10}),
|
|
106
106
|
});
|
|
107
107
|
expect(result).toEqual({
|
|
108
108
|
content: [{type: 'text', text: JSON.stringify(body)}],
|
|
@@ -110,58 +110,105 @@ describe('SlackAgentToolsProvider', () => {
|
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
it('
|
|
113
|
+
it('translates message parameters to their Slack Web API names', async () => {
|
|
114
114
|
const options = providerOptions(async () => ({ok: true, ts: '456.000'}));
|
|
115
|
-
const session = await openSession(options, ['
|
|
115
|
+
const session = await openSession(options, ['send_message']);
|
|
116
116
|
|
|
117
117
|
await session.call({
|
|
118
|
-
toolId: '
|
|
119
|
-
arguments: {
|
|
118
|
+
toolId: 'send_message',
|
|
119
|
+
arguments: {channel_id: 'C123', message: 'Reply', thread_ts: '123.000'},
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
expect(options.slack.callMethod).toHaveBeenCalledWith({
|
|
123
123
|
method: 'chat.postMessage',
|
|
124
124
|
token: 'xoxb-token',
|
|
125
|
-
arguments: {
|
|
125
|
+
arguments: {
|
|
126
|
+
channel: 'C123',
|
|
127
|
+
text: 'Reply',
|
|
128
|
+
blocks: [{type: 'markdown', text: 'Reply'}],
|
|
129
|
+
thread_ts: '123.000',
|
|
130
|
+
reply_broadcast: undefined,
|
|
131
|
+
},
|
|
126
132
|
});
|
|
127
133
|
});
|
|
128
134
|
|
|
135
|
+
it('applies the tool result mapping before returning content to the agent', async () => {
|
|
136
|
+
const body = {
|
|
137
|
+
ok: true,
|
|
138
|
+
channels: [
|
|
139
|
+
{id: 'C1', name: 'deploy-prod'},
|
|
140
|
+
{id: 'C2', name: 'design'},
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
const options = providerOptions(async () => body);
|
|
144
|
+
const session = await openSession(options, ['search_channels']);
|
|
145
|
+
|
|
146
|
+
const result = await session.call({
|
|
147
|
+
toolId: 'search_channels',
|
|
148
|
+
arguments: {query: 'deploy'},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result.structuredContent).toEqual({...body, channels: [body.channels[0]]});
|
|
152
|
+
});
|
|
153
|
+
|
|
129
154
|
it('rejects a tool that was not selected for the session', async () => {
|
|
130
155
|
const options = providerOptions(async () => ({ok: true}));
|
|
131
|
-
const session = await openSession(options, ['
|
|
156
|
+
const session = await openSession(options, ['read_channel']);
|
|
132
157
|
|
|
133
|
-
const result = await session.call({
|
|
158
|
+
const result = await session.call({
|
|
159
|
+
toolId: 'read_user_profile',
|
|
160
|
+
arguments: {user_id: 'U123'},
|
|
161
|
+
});
|
|
134
162
|
|
|
135
163
|
expect(result).toEqual({
|
|
136
164
|
isError: true,
|
|
137
|
-
content: [{type: 'text', text: 'Unknown Slack tool:
|
|
165
|
+
content: [{type: 'text', text: 'Unknown Slack tool: read_user_profile'}],
|
|
138
166
|
});
|
|
139
167
|
expect(options.slack.callMethod).not.toHaveBeenCalled();
|
|
140
168
|
});
|
|
141
169
|
|
|
142
170
|
it('rejects a call missing a required parameter', async () => {
|
|
143
171
|
const options = providerOptions(async () => ({ok: true}));
|
|
144
|
-
const session = await openSession(options, ['
|
|
172
|
+
const session = await openSession(options, ['read_thread']);
|
|
145
173
|
|
|
146
174
|
const result = await session.call({
|
|
147
|
-
toolId: '
|
|
148
|
-
arguments: {
|
|
175
|
+
toolId: 'read_thread',
|
|
176
|
+
arguments: {channel_id: 'C123'},
|
|
149
177
|
});
|
|
150
178
|
|
|
151
179
|
expect(result).toEqual({
|
|
152
180
|
isError: true,
|
|
153
|
-
content: [{type: 'text', text: 'Missing required parameter:
|
|
181
|
+
content: [{type: 'text', text: 'Missing required parameter: message_ts'}],
|
|
182
|
+
});
|
|
183
|
+
expect(options.slack.callMethod).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('rejects a message over the Slack Markdown block limit before calling Slack', async () => {
|
|
187
|
+
const options = providerOptions(async () => ({ok: true, ts: '456.000'}));
|
|
188
|
+
const session = await openSession(options, ['send_message']);
|
|
189
|
+
|
|
190
|
+
const result = await session.call({
|
|
191
|
+
toolId: 'send_message',
|
|
192
|
+
arguments: {channel_id: 'C123', message: 'a'.repeat(12_001)},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(result).toMatchObject({
|
|
196
|
+
isError: true,
|
|
197
|
+
content: [
|
|
198
|
+
{type: 'text', text: expect.stringContaining('12,000-character Markdown block limit')},
|
|
199
|
+
],
|
|
200
|
+
structuredContent: {code: 'content-too-large'},
|
|
154
201
|
});
|
|
155
202
|
expect(options.slack.callMethod).not.toHaveBeenCalled();
|
|
156
203
|
});
|
|
157
204
|
|
|
158
205
|
it('returns a Slack application error to the agent', async () => {
|
|
159
206
|
const options = providerOptions(async () => ({ok: false, error: 'channel_not_found'}));
|
|
160
|
-
const session = await openSession(options, ['
|
|
207
|
+
const session = await openSession(options, ['read_channel']);
|
|
161
208
|
|
|
162
209
|
const result = await session.call({
|
|
163
|
-
toolId: '
|
|
164
|
-
arguments: {
|
|
210
|
+
toolId: 'read_channel',
|
|
211
|
+
arguments: {channel_id: 'C123'},
|
|
165
212
|
});
|
|
166
213
|
|
|
167
214
|
expect(result).toEqual({
|
|
@@ -172,11 +219,11 @@ describe('SlackAgentToolsProvider', () => {
|
|
|
172
219
|
|
|
173
220
|
it('returns auth failures with a stable access-denied code and logs only metadata', async () => {
|
|
174
221
|
const options = providerOptions(async () => ({ok: false, error: 'invalid_auth'}));
|
|
175
|
-
const session = await openSession(options, ['
|
|
222
|
+
const session = await openSession(options, ['read_channel']);
|
|
176
223
|
|
|
177
224
|
const result = await session.call({
|
|
178
|
-
toolId: '
|
|
179
|
-
arguments: {
|
|
225
|
+
toolId: 'read_channel',
|
|
226
|
+
arguments: {channel_id: 'C123'},
|
|
180
227
|
});
|
|
181
228
|
|
|
182
229
|
expect(result).toMatchObject({
|
|
@@ -193,9 +240,9 @@ describe('SlackAgentToolsProvider', () => {
|
|
|
193
240
|
|
|
194
241
|
it('returns an HTTP-200 rate-limit response with a stable code', async () => {
|
|
195
242
|
const options = providerOptions(async () => ({ok: false, error: 'ratelimited'}));
|
|
196
|
-
const session = await openSession(options, ['
|
|
243
|
+
const session = await openSession(options, ['search_channels']);
|
|
197
244
|
|
|
198
|
-
const result = await session.call({toolId: '
|
|
245
|
+
const result = await session.call({toolId: 'search_channels', arguments: {query: 'deploy'}});
|
|
199
246
|
|
|
200
247
|
expect(result).toMatchObject({
|
|
201
248
|
isError: true,
|
|
@@ -207,9 +254,9 @@ describe('SlackAgentToolsProvider', () => {
|
|
|
207
254
|
const options = providerOptions(() =>
|
|
208
255
|
Promise.reject(new SlackIntegrationProviderError('rate-limited', 'Try again later', 19)),
|
|
209
256
|
);
|
|
210
|
-
const session = await openSession(options, ['
|
|
257
|
+
const session = await openSession(options, ['search_channels']);
|
|
211
258
|
|
|
212
|
-
const result = await session.call({toolId: '
|
|
259
|
+
const result = await session.call({toolId: 'search_channels', arguments: {query: 'deploy'}});
|
|
213
260
|
|
|
214
261
|
expect(result).toMatchObject({
|
|
215
262
|
isError: true,
|
|
@@ -224,11 +271,11 @@ describe('SlackAgentToolsProvider', () => {
|
|
|
224
271
|
new SlackIntegrationProviderError('content-too-large', 'Slack content was too large'),
|
|
225
272
|
),
|
|
226
273
|
);
|
|
227
|
-
const session = await openSession(options, ['
|
|
274
|
+
const session = await openSession(options, ['send_message']);
|
|
228
275
|
|
|
229
276
|
const result = await session.call({
|
|
230
|
-
toolId: '
|
|
231
|
-
arguments: {
|
|
277
|
+
toolId: 'send_message',
|
|
278
|
+
arguments: {channel_id: 'C123', message: 'Too large'},
|
|
232
279
|
});
|
|
233
280
|
|
|
234
281
|
expect(result).toMatchObject({
|
|
@@ -246,7 +293,7 @@ describe('SlackAgentToolsProvider', () => {
|
|
|
246
293
|
|
|
247
294
|
const result = provider.openSession({
|
|
248
295
|
connection: slackConnection(),
|
|
249
|
-
tools: [catalogTool('
|
|
296
|
+
tools: [catalogTool('search_channels')],
|
|
250
297
|
scope: {provider: 'slack'},
|
|
251
298
|
});
|
|
252
299
|
|
|
@@ -7,10 +7,11 @@ import type {
|
|
|
7
7
|
import {logger} from '@shipfox/node-opentelemetry';
|
|
8
8
|
import type {SlackApiClient, SlackWebApiResponse} from '#api/client.js';
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
SLACK_TOOL_OPERATIONS,
|
|
11
11
|
type SlackAgentToolCatalogEntry,
|
|
12
12
|
type SlackAgentToolId,
|
|
13
13
|
type SlackAgentToolRequiredScope,
|
|
14
|
+
type SlackToolOperation,
|
|
14
15
|
slackAgentToolCatalog,
|
|
15
16
|
slackAgentToolSelectionCatalog,
|
|
16
17
|
} from '#core/agent-tools.js';
|
|
@@ -62,19 +63,23 @@ export class SlackAgentToolsProvider
|
|
|
62
63
|
call: async (call) => {
|
|
63
64
|
const tool = input.tools.find((candidate) => candidate.id === call.toolId);
|
|
64
65
|
if (!tool) return slackToolError(`Unknown Slack tool: ${call.toolId}`);
|
|
65
|
-
const
|
|
66
|
-
if (!
|
|
66
|
+
const operation = slackToolOperation(tool.id);
|
|
67
|
+
if (!operation) return slackToolError(`Unknown Slack tool method: ${tool.id}`);
|
|
67
68
|
const missingParameter = missingRequiredParameter(tool, call.arguments);
|
|
68
69
|
if (missingParameter) {
|
|
69
70
|
return slackToolError(`Missing required parameter: ${missingParameter}`);
|
|
70
71
|
}
|
|
72
|
+
const validationError = operation.validate?.(call.arguments);
|
|
73
|
+
if (validationError) {
|
|
74
|
+
return slackToolError(validationError.message, {code: validationError.code});
|
|
75
|
+
}
|
|
71
76
|
|
|
72
77
|
let body: SlackWebApiResponse;
|
|
73
78
|
try {
|
|
74
79
|
body = await this.options.slack.callMethod({
|
|
75
|
-
method,
|
|
80
|
+
method: operation.method,
|
|
76
81
|
token,
|
|
77
|
-
arguments: call.arguments,
|
|
82
|
+
arguments: operation.mapArguments(call.arguments),
|
|
78
83
|
});
|
|
79
84
|
} catch (error) {
|
|
80
85
|
if (error instanceof SlackIntegrationProviderError) {
|
|
@@ -86,7 +91,9 @@ export class SlackAgentToolsProvider
|
|
|
86
91
|
throw error;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
|
-
if (body.ok)
|
|
94
|
+
if (body.ok) {
|
|
95
|
+
return slackToolResult(operation.mapOutput?.(body, call.arguments) ?? body);
|
|
96
|
+
}
|
|
90
97
|
const slackError = typeof body.error === 'string' ? body.error : 'Slack request failed';
|
|
91
98
|
if (isSlackAccessError(slackError)) {
|
|
92
99
|
logger().warn(
|
|
@@ -105,8 +112,8 @@ export class SlackAgentToolsProvider
|
|
|
105
112
|
}
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
function
|
|
109
|
-
return
|
|
115
|
+
function slackToolOperation(toolId: string): SlackToolOperation | undefined {
|
|
116
|
+
return SLACK_TOOL_OPERATIONS[toolId as SlackAgentToolId];
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
function missingRequiredParameter(
|
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
SLACK_TOOL_OPERATIONS,
|
|
3
3
|
type SlackAgentToolId,
|
|
4
|
+
type SlackToolOperation,
|
|
4
5
|
slackAgentToolCatalog,
|
|
5
6
|
slackAgentToolSelectionCatalog,
|
|
6
7
|
} from './agent-tools.js';
|
|
7
8
|
|
|
8
9
|
const expectedTools = [
|
|
9
|
-
{id: '
|
|
10
|
-
{id: '
|
|
11
|
-
{id: '
|
|
12
|
-
{id: '
|
|
13
|
-
{id: '
|
|
14
|
-
{id: '
|
|
15
|
-
{id: '
|
|
10
|
+
{id: 'read_channel', sensitivity: 'read', requiredScope: 'read'},
|
|
11
|
+
{id: 'read_thread', sensitivity: 'read', requiredScope: 'read'},
|
|
12
|
+
{id: 'read_channel_info', sensitivity: 'read', requiredScope: 'read'},
|
|
13
|
+
{id: 'read_channel_members', sensitivity: 'read', requiredScope: 'read'},
|
|
14
|
+
{id: 'read_user_profile', sensitivity: 'read', requiredScope: 'read'},
|
|
15
|
+
{id: 'search_channels', sensitivity: 'read', requiredScope: 'read'},
|
|
16
|
+
{id: 'send_message', sensitivity: 'write', requiredScope: 'write'},
|
|
17
|
+
{id: 'schedule_message', sensitivity: 'write', requiredScope: 'write'},
|
|
18
|
+
{id: 'update_message', sensitivity: 'write', requiredScope: 'write'},
|
|
19
|
+
{id: 'add_reaction', sensitivity: 'write', requiredScope: 'write'},
|
|
20
|
+
{id: 'create_canvas', sensitivity: 'write', requiredScope: 'write'},
|
|
16
21
|
] as const;
|
|
17
22
|
|
|
23
|
+
function operation(id: SlackAgentToolId): SlackToolOperation {
|
|
24
|
+
return SLACK_TOOL_OPERATIONS[id];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function channelIdDescription(inputSchema: Record<string, unknown>): string | undefined {
|
|
28
|
+
const properties = inputSchema.properties as Record<string, {description?: string}> | undefined;
|
|
29
|
+
return properties?.channel_id?.description;
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
describe('slackAgentToolCatalog', () => {
|
|
19
|
-
it('defines the
|
|
33
|
+
it('defines the Slack tools with their access requirements', () => {
|
|
20
34
|
const tools = slackAgentToolCatalog.map(({id, sensitivity, requiredScope, sensitive}) => ({
|
|
21
35
|
id,
|
|
22
36
|
sensitivity,
|
|
@@ -33,46 +47,203 @@ describe('slackAgentToolCatalog', () => {
|
|
|
33
47
|
type: inputSchema.type,
|
|
34
48
|
}));
|
|
35
49
|
|
|
36
|
-
expect(schemas).toHaveLength(
|
|
50
|
+
expect(schemas).toHaveLength(expectedTools.length);
|
|
37
51
|
expect(
|
|
38
52
|
schemas.every(({description, type}) => description.length > 0 && type === 'object'),
|
|
39
53
|
).toBe(true);
|
|
40
54
|
});
|
|
41
55
|
|
|
42
|
-
it('
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const history = slackAgentToolCatalog.find(({id}) => id === 'conversations_history');
|
|
56
|
+
it('names message parameters the way the Slack MCP server does', () => {
|
|
57
|
+
const sendMessage = slackAgentToolCatalog.find(({id}) => id === 'send_message');
|
|
58
|
+
const readThread = slackAgentToolCatalog.find(({id}) => id === 'read_thread');
|
|
46
59
|
|
|
47
|
-
expect(
|
|
48
|
-
required: ['
|
|
49
|
-
properties: {
|
|
50
|
-
blocks: {type: 'array', items: {type: 'object'}},
|
|
51
|
-
thread_ts: {type: 'string'},
|
|
52
|
-
},
|
|
53
|
-
});
|
|
54
|
-
expect(listConversations?.inputSchema).toMatchObject({
|
|
55
|
-
properties: {types: {type: 'string'}, limit: {type: 'number'}},
|
|
60
|
+
expect(sendMessage?.inputSchema).toMatchObject({
|
|
61
|
+
required: ['channel_id', 'message'],
|
|
62
|
+
properties: {message: {type: 'string', maxLength: 12_000}, thread_ts: {type: 'string'}},
|
|
56
63
|
});
|
|
57
|
-
expect(
|
|
58
|
-
|
|
64
|
+
expect(readThread?.inputSchema).toMatchObject({
|
|
65
|
+
required: ['channel_id', 'message_ts'],
|
|
66
|
+
properties: {limit: {type: 'integer'}, cursor: {type: 'string'}},
|
|
59
67
|
});
|
|
60
68
|
});
|
|
61
69
|
|
|
70
|
+
it('offers user-ID targeting only on the tools whose Slack method resolves one', () => {
|
|
71
|
+
const targetDescriptions = new Map(
|
|
72
|
+
slackAgentToolCatalog
|
|
73
|
+
.filter(({inputSchema}) => channelIdDescription(inputSchema) !== undefined)
|
|
74
|
+
.map(({id, inputSchema}) => [id, channelIdDescription(inputSchema) ?? '']),
|
|
75
|
+
);
|
|
76
|
+
const acceptsUserId = [...targetDescriptions]
|
|
77
|
+
.filter(([, description]) => description.includes('Pass a user ID'))
|
|
78
|
+
.map(([id]) => id);
|
|
79
|
+
|
|
80
|
+
expect(acceptsUserId).toEqual(['send_message', 'schedule_message']);
|
|
81
|
+
expect(targetDescriptions.get('read_channel')).toContain('not accepted');
|
|
82
|
+
});
|
|
83
|
+
|
|
62
84
|
it('maps every tool id to its dotted Slack Web API method', () => {
|
|
63
85
|
const methods = Object.fromEntries(
|
|
64
|
-
slackAgentToolCatalog.map(({id}) => [id,
|
|
86
|
+
slackAgentToolCatalog.map(({id}) => [id, operation(id).method]),
|
|
65
87
|
);
|
|
66
88
|
|
|
67
89
|
expect(methods).toEqual({
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
read_channel: 'conversations.history',
|
|
91
|
+
read_thread: 'conversations.replies',
|
|
92
|
+
read_channel_info: 'conversations.info',
|
|
93
|
+
read_channel_members: 'conversations.members',
|
|
94
|
+
read_user_profile: 'users.info',
|
|
95
|
+
search_channels: 'conversations.list',
|
|
96
|
+
send_message: 'chat.postMessage',
|
|
97
|
+
schedule_message: 'chat.scheduleMessage',
|
|
98
|
+
update_message: 'chat.update',
|
|
99
|
+
add_reaction: 'reactions.add',
|
|
100
|
+
create_canvas: 'canvases.create',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('translates channel and message identifiers to Slack Web API parameters', () => {
|
|
105
|
+
expect(
|
|
106
|
+
operation('read_channel').mapArguments({channel_id: 'C123', limit: 10, cursor: 'next'}),
|
|
107
|
+
).toMatchObject({channel: 'C123', limit: 10, cursor: 'next'});
|
|
108
|
+
expect(
|
|
109
|
+
operation('read_thread').mapArguments({channel_id: 'C123', message_ts: '123.000'}),
|
|
110
|
+
).toMatchObject({channel: 'C123', ts: '123.000'});
|
|
111
|
+
expect(operation('read_user_profile').mapArguments({user_id: 'U123'})).toMatchObject({
|
|
112
|
+
user: 'U123',
|
|
113
|
+
});
|
|
114
|
+
expect(operation('read_channel_info').mapArguments({channel_id: 'C123'})).toMatchObject({
|
|
115
|
+
channel: 'C123',
|
|
116
|
+
});
|
|
117
|
+
expect(
|
|
118
|
+
operation('read_channel_members').mapArguments({channel_id: 'C123', cursor: 'next'}),
|
|
119
|
+
).toMatchObject({channel: 'C123', cursor: 'next'});
|
|
120
|
+
expect(
|
|
121
|
+
operation('add_reaction').mapArguments({
|
|
122
|
+
channel_id: 'C123',
|
|
123
|
+
message_ts: '123.000',
|
|
124
|
+
emoji: 'tada',
|
|
125
|
+
}),
|
|
126
|
+
).toMatchObject({channel: 'C123', timestamp: '123.000', name: 'tada'});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('sends message content as a Markdown block with a stripped plain-text fallback', () => {
|
|
130
|
+
const args = {channel_id: 'C123', message: '**Deployed** to production'};
|
|
131
|
+
|
|
132
|
+
expect(operation('send_message').mapArguments(args)).toMatchObject({
|
|
133
|
+
channel: 'C123',
|
|
134
|
+
text: 'Deployed to production',
|
|
135
|
+
blocks: [{type: 'markdown', text: '**Deployed** to production'}],
|
|
75
136
|
});
|
|
137
|
+
expect(
|
|
138
|
+
operation('update_message').mapArguments({...args, message_ts: '123.000'}),
|
|
139
|
+
).toMatchObject({ts: '123.000', blocks: [{type: 'markdown', text: args.message}]});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('strips Markdown syntax from the notification fallback but not the Markdown block', () => {
|
|
143
|
+
const message = '**bold** _italic_ ~~gone~~ `code` [docs](https://example.com/x)\n> a quote';
|
|
144
|
+
|
|
145
|
+
const {text, blocks} = operation('send_message').mapArguments({channel_id: 'C123', message});
|
|
146
|
+
|
|
147
|
+
expect(text).toBe('bold italic gone code docs (https://example.com/x)\na quote');
|
|
148
|
+
expect(blocks).toEqual([{type: 'markdown', text: message}]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('preserves Markdown-looking characters inside code spans in the fallback', () => {
|
|
152
|
+
const message = 'run `*args, **kwargs` then\n```py\n**not bold**\n```\ndone';
|
|
153
|
+
|
|
154
|
+
const {text} = operation('send_message').mapArguments({channel_id: 'C123', message});
|
|
155
|
+
|
|
156
|
+
expect(text).toBe('run *args, **kwargs then\n**not bold**\ndone');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('does not let a NUL-delimited numeric sequence in the message collide with the internal code-span placeholder', () => {
|
|
160
|
+
const nul = String.fromCharCode(0);
|
|
161
|
+
const message = `call me at ${nul}0${nul} please and \`use code\``;
|
|
162
|
+
|
|
163
|
+
const {text} = operation('send_message').mapArguments({channel_id: 'C123', message});
|
|
164
|
+
|
|
165
|
+
expect(text).toBe('call me at 0 please and use code');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('schedules a message with its send time and thread target', () => {
|
|
169
|
+
expect(
|
|
170
|
+
operation('schedule_message').mapArguments({
|
|
171
|
+
channel_id: 'C123',
|
|
172
|
+
message: 'Standup in 5',
|
|
173
|
+
post_at: 1700000000,
|
|
174
|
+
thread_ts: '123.000',
|
|
175
|
+
reply_broadcast: true,
|
|
176
|
+
}),
|
|
177
|
+
).toMatchObject({
|
|
178
|
+
channel: 'C123',
|
|
179
|
+
text: 'Standup in 5',
|
|
180
|
+
blocks: [{type: 'markdown', text: 'Standup in 5'}],
|
|
181
|
+
post_at: 1700000000,
|
|
182
|
+
thread_ts: '123.000',
|
|
183
|
+
reply_broadcast: true,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('rejects a message over the Slack Markdown block limit', () => {
|
|
188
|
+
const oversized = 'a'.repeat(12_001);
|
|
189
|
+
const fits = 'a'.repeat(12_000);
|
|
190
|
+
|
|
191
|
+
expect(operation('send_message').validate?.({message: oversized})).toMatchObject({
|
|
192
|
+
message: expect.stringContaining('12,000-character Markdown block limit'),
|
|
193
|
+
code: 'content-too-large',
|
|
194
|
+
});
|
|
195
|
+
expect(operation('schedule_message').validate?.({message: oversized})).toBeDefined();
|
|
196
|
+
expect(operation('update_message').validate?.({message: oversized})).toBeDefined();
|
|
197
|
+
expect(operation('send_message').validate?.({message: fits})).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('counts Unicode code points, not UTF-16 code units, against the length limit', () => {
|
|
201
|
+
const emoji = '\u{1F600}';
|
|
202
|
+
// Each 😀 is a surrogate pair (2 UTF-16 units) but 1 code point: 6,001 code points fits the
|
|
203
|
+
// 12,000-character schema limit even though .length would report 12,002.
|
|
204
|
+
const sixThousandOneEmoji = emoji.repeat(6001);
|
|
205
|
+
|
|
206
|
+
expect(operation('send_message').validate?.({message: sixThousandOneEmoji})).toBeUndefined();
|
|
207
|
+
expect(operation('send_message').validate?.({message: emoji.repeat(12_001)})).toBeDefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('wraps canvas content as a Markdown document', () => {
|
|
211
|
+
expect(operation('create_canvas').mapArguments({title: 'Runbook', content: '# Steps'})).toEqual(
|
|
212
|
+
{
|
|
213
|
+
title: 'Runbook',
|
|
214
|
+
document_content: {type: 'markdown', markdown: '# Steps'},
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('excludes archived channels unless the caller asks for them', () => {
|
|
220
|
+
expect(operation('search_channels').mapArguments({query: 'deploy'})).toMatchObject({
|
|
221
|
+
exclude_archived: true,
|
|
222
|
+
});
|
|
223
|
+
expect(
|
|
224
|
+
operation('search_channels').mapArguments({query: 'deploy', include_archived: true}),
|
|
225
|
+
).toMatchObject({exclude_archived: false});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('keeps only the channels matching every search term', () => {
|
|
229
|
+
const body = {
|
|
230
|
+
ok: true,
|
|
231
|
+
channels: [
|
|
232
|
+
{id: 'C1', name: 'deploy-prod', topic: {value: 'Release coordination'}},
|
|
233
|
+
{id: 'C2', name: 'deploy-staging', topic: {value: 'Scratch'}},
|
|
234
|
+
{id: 'C3', name: 'design', purpose: {value: 'Release notes'}},
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const result = operation('search_channels').mapOutput?.(body, {query: 'Deploy release'});
|
|
239
|
+
|
|
240
|
+
expect(result).toEqual({...body, channels: [body.channels[0]]});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('returns the Slack response unchanged when the search query is empty', () => {
|
|
244
|
+
const body = {ok: true, channels: [{id: 'C1', name: 'deploy-prod'}]};
|
|
245
|
+
|
|
246
|
+
expect(operation('search_channels').mapOutput?.(body, {query: ' '})).toBe(body);
|
|
76
247
|
});
|
|
77
248
|
|
|
78
249
|
it('exposes one standalone selector per tool with matching sensitivity', () => {
|