@firedrill-tools/slack 0.1.1
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/LICENSE +201 -0
- package/README.md +369 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/bounds.scenario.json +112 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/history-unavailable.scenario.json +11 -0
- package/firedrill/open-long-handles.scenario.json +263 -0
- package/firedrill/open-member-bound.scenario.json +24 -0
- package/firedrill/post-rate-limited.scenario.json +11 -0
- package/firedrill/response-budget.scenario.json +24 -0
- package/firedrill/slack-bounds.drill.json +428 -0
- package/firedrill/slack-denied.drill.json +74 -0
- package/firedrill/slack-fresh-install.drill.json +132 -0
- package/firedrill/slack-history-unavailable.drill.json +83 -0
- package/firedrill/slack-invalid-auth.drill.json +408 -0
- package/firedrill/slack-mcp-aliases.drill.json +219 -0
- package/firedrill/slack-open-long-handles.drill.json +82 -0
- package/firedrill/slack-open-member-bound.drill.json +135 -0
- package/firedrill/slack-post-rate-limited.drill.json +96 -0
- package/firedrill/slack-response-budget.drill.json +118 -0
- package/firedrill/slack-thread-many-repliers.drill.json +100 -0
- package/firedrill/slack-ts-sequence-full.drill.json +165 -0
- package/firedrill/slack-visibility.drill.json +110 -0
- package/firedrill/slack-web-api-flow.drill.json +896 -0
- package/firedrill/thread-many-repliers.scenario.json +79 -0
- package/firedrill/tools/slack/app/assets/ATTRIBUTION.md +40 -0
- package/firedrill/tools/slack/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/slack/app/assets/fonts/lato-latin-400.woff2 +0 -0
- package/firedrill/tools/slack/app/assets/fonts/lato-latin-700.woff2 +0 -0
- package/firedrill/tools/slack/app/assets/fonts/lato-latin-900.woff2 +0 -0
- package/firedrill/tools/slack/app/assets/slack-wordmark.svg +1 -0
- package/firedrill/tools/slack/app/assets/slack.svg +1 -0
- package/firedrill/tools/slack/app/site/app.js +2202 -0
- package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-400.woff2 +0 -0
- package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-700.woff2 +0 -0
- package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-900.woff2 +0 -0
- package/firedrill/tools/slack/app/site/assets/slack-wordmark.svg +1 -0
- package/firedrill/tools/slack/app/site/assets/slack.svg +1 -0
- package/firedrill/tools/slack/app/site/chrome.css +73 -0
- package/firedrill/tools/slack/app/site/chrome.js +96 -0
- package/firedrill/tools/slack/app/site/icons.js +96 -0
- package/firedrill/tools/slack/app/site/index.html +253 -0
- package/firedrill/tools/slack/app/site/styles.css +2719 -0
- package/firedrill/tools/slack/app/site/ui.js +491 -0
- package/firedrill/tools/slack/behavior.mjs +1171 -0
- package/firedrill/tools/slack/lib/access.mjs +138 -0
- package/firedrill/tools/slack/lib/budget.mjs +67 -0
- package/firedrill/tools/slack/lib/ids.mjs +146 -0
- package/firedrill/tools/slack/lib/search.mjs +172 -0
- package/firedrill/tools/slack/lib/wire.mjs +144 -0
- package/firedrill/tools/slack/slack.tool.json +6114 -0
- package/firedrill/ts-sequence-full.scenario.json +38 -0
- package/firedrill/world.json +2008 -0
- package/firedrill.json +5 -0
- package/package.json +62 -0
- package/starter.json +1562 -0
- package/test/conformance.mjs +1166 -0
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
// Slack Tool conformance target. A scripted Tool test, not a model-driven agent.
|
|
2
|
+
// Node built-ins only: fetch against the Slack-shaped /api/<method> routes (GET query and POST form),
|
|
3
|
+
// the canonical /v1/operations endpoint, and raw MCP JSON-RPC (Streamable HTTP) for the reference tool-name aliases.
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
|
|
6
|
+
let task = "";
|
|
7
|
+
for await (const chunk of process.stdin) task += chunk;
|
|
8
|
+
const invocation = JSON.parse(task);
|
|
9
|
+
const instruction = String(invocation.instruction ?? "");
|
|
10
|
+
|
|
11
|
+
const HTTP = process.env.FIREDRILL_HTTP_URL;
|
|
12
|
+
const HTTP_TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
|
|
13
|
+
const MCP = process.env.FIREDRILL_MCP_URL;
|
|
14
|
+
const MCP_TOKEN = process.env.FIREDRILL_MCP_TOKEN;
|
|
15
|
+
assert.ok(HTTP && HTTP_TOKEN && MCP && MCP_TOKEN, "HTTP and MCP bindings are required");
|
|
16
|
+
|
|
17
|
+
// Fixed ids from the authored world (firedrill/world.json == starter.json).
|
|
18
|
+
const U = { dana: "U01DANA0001", sam: "U01SAM00002", priya: "U01PRIYA003", lee: "U01LEE00004", opsbot: "U01OPSBOT05", mira: "U01MIRA0006" };
|
|
19
|
+
const C = {
|
|
20
|
+
general: "C01GENERAL1",
|
|
21
|
+
engineering: "C01ENGINEER",
|
|
22
|
+
incidents: "C01INCIDENT",
|
|
23
|
+
random: "C01RANDOM01",
|
|
24
|
+
launch: "C01LAUNCHQ3",
|
|
25
|
+
design: "C01DESIGN01",
|
|
26
|
+
leadership: "C01LEADERS1",
|
|
27
|
+
im: "D01DANASAM1",
|
|
28
|
+
mpdm: "C01MPDM0001",
|
|
29
|
+
onboarding: "C01ONBOARD1",
|
|
30
|
+
};
|
|
31
|
+
const TS = {
|
|
32
|
+
g1: "1787216400.000101",
|
|
33
|
+
join: "1787561880.000119",
|
|
34
|
+
e1: "1787562900.000120",
|
|
35
|
+
r1: "1787564400.000121",
|
|
36
|
+
r2: "1787565900.000122",
|
|
37
|
+
r3: "1787667600.000123",
|
|
38
|
+
r4: "1787669100.000124",
|
|
39
|
+
e2: "1787828400.000125",
|
|
40
|
+
e3: "1787934600.000126",
|
|
41
|
+
e5: "1788430200.000128",
|
|
42
|
+
e6: "1788526800.000129",
|
|
43
|
+
e7: "1788773400.000130",
|
|
44
|
+
e8: "1788882300.000131",
|
|
45
|
+
e9: "1788952800.000132",
|
|
46
|
+
e10: "1789116600.000133",
|
|
47
|
+
e11: "1789375200.000134",
|
|
48
|
+
l1: "1787306400.000161",
|
|
49
|
+
unknown: "1700000000.000001",
|
|
50
|
+
};
|
|
51
|
+
const ALL_OPERATIONS = [
|
|
52
|
+
"auth.test",
|
|
53
|
+
"users.list",
|
|
54
|
+
"users.info",
|
|
55
|
+
"users.profile.get",
|
|
56
|
+
"conversations.list",
|
|
57
|
+
"conversations.info",
|
|
58
|
+
"conversations.members",
|
|
59
|
+
"conversations.create",
|
|
60
|
+
"conversations.join",
|
|
61
|
+
"conversations.invite",
|
|
62
|
+
"conversations.open",
|
|
63
|
+
"conversations.archive",
|
|
64
|
+
"conversations.set-topic",
|
|
65
|
+
"conversations.history",
|
|
66
|
+
"conversations.replies",
|
|
67
|
+
"chat.post-message",
|
|
68
|
+
"chat.reply",
|
|
69
|
+
"chat.update",
|
|
70
|
+
"chat.delete",
|
|
71
|
+
"reactions.add",
|
|
72
|
+
"reactions.remove",
|
|
73
|
+
"pins.add",
|
|
74
|
+
"pins.remove",
|
|
75
|
+
"pins.list",
|
|
76
|
+
"search.messages",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------------------------
|
|
80
|
+
// Transport helpers
|
|
81
|
+
// ---------------------------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/** Call a Slack method over HTTP: GET with a query string, or POST with a form body (the SDK convention). */
|
|
84
|
+
async function api(verb, method, args = {}, { status = 200, headers = {}, rawBody, contentType } = {}) {
|
|
85
|
+
const query = new URLSearchParams();
|
|
86
|
+
for (const [name, value] of Object.entries(args)) query.set(name, typeof value === "string" ? value : JSON.stringify(value));
|
|
87
|
+
const url = verb === "GET" ? `${HTTP}/api/${method}${query.size > 0 ? `?${query}` : ""}` : `${HTTP}/api/${method}`;
|
|
88
|
+
const response = await fetch(url, {
|
|
89
|
+
method: verb,
|
|
90
|
+
headers: {
|
|
91
|
+
authorization: `Bearer ${HTTP_TOKEN}`,
|
|
92
|
+
...(verb === "GET" ? {} : { "content-type": contentType ?? "application/x-www-form-urlencoded" }),
|
|
93
|
+
...headers,
|
|
94
|
+
},
|
|
95
|
+
...(verb === "GET" ? {} : { body: rawBody ?? query.toString() }),
|
|
96
|
+
});
|
|
97
|
+
const text = await response.text();
|
|
98
|
+
const json = text.length > 0 ? JSON.parse(text) : undefined;
|
|
99
|
+
assert.equal(response.status, status, `${verb} ${method} ${JSON.stringify(args)} -> ${response.status} ${text.slice(0, 300)}`);
|
|
100
|
+
return { json, headers: response.headers, status: response.status, bytes: Buffer.byteLength(text) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const get = (method, args, options) => api("GET", method, args, options);
|
|
104
|
+
const post = (method, args, options) => api("POST", method, args, options);
|
|
105
|
+
|
|
106
|
+
/** Expect a Slack-shaped error body with the given status and `error` string. */
|
|
107
|
+
async function apiError(verb, method, args, status, error, options = {}) {
|
|
108
|
+
const result = await api(verb, method, args, { ...options, status });
|
|
109
|
+
assert.equal(result.json?.ok, false, `${method}: expected ok:false, got ${JSON.stringify(result.json).slice(0, 300)}`);
|
|
110
|
+
assert.equal(result.json.error, error, `${method} ${JSON.stringify(args)}: ${JSON.stringify(result.json)}`);
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Canonical operation endpoint (also how chat.reply is reached over HTTP: it has no Slack route). */
|
|
115
|
+
async function op(operationId, args, expected = "ok") {
|
|
116
|
+
const response = await fetch(`${HTTP}/v1/operations/slack/${operationId}`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" },
|
|
119
|
+
body: JSON.stringify({ arguments: args }),
|
|
120
|
+
});
|
|
121
|
+
const json = await response.json();
|
|
122
|
+
assert.ok(json.outcome, `canonical ${operationId}: ${JSON.stringify(json).slice(0, 300)}`);
|
|
123
|
+
if (expected === "ok") {
|
|
124
|
+
assert.equal(json.outcome.status, "ok", `${operationId}: ${JSON.stringify(json.outcome).slice(0, 300)}`);
|
|
125
|
+
return json.outcome.value;
|
|
126
|
+
}
|
|
127
|
+
assert.equal(json.outcome.status, "tool_error", `${operationId} ${JSON.stringify(args)}: ${JSON.stringify(json.outcome).slice(0, 300)}`);
|
|
128
|
+
assert.equal(json.outcome.error.code, `tool.${expected}`, JSON.stringify(json.outcome.error));
|
|
129
|
+
return json.outcome.error;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let rpcId = 0;
|
|
133
|
+
async function rpc(method, params) {
|
|
134
|
+
const response = await fetch(MCP, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
authorization: `Bearer ${MCP_TOKEN}`,
|
|
138
|
+
"content-type": "application/json",
|
|
139
|
+
accept: "application/json, text/event-stream",
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
|
|
142
|
+
});
|
|
143
|
+
assert.equal(response.status, 200, `MCP ${method} -> HTTP ${response.status}`);
|
|
144
|
+
const text = await response.text();
|
|
145
|
+
const type = response.headers.get("content-type") ?? "";
|
|
146
|
+
const messages = type.includes("text/event-stream")
|
|
147
|
+
? text
|
|
148
|
+
.split(/\r?\n/)
|
|
149
|
+
.filter((line) => line.startsWith("data:"))
|
|
150
|
+
.map((line) => JSON.parse(line.slice(5).trim()))
|
|
151
|
+
: [JSON.parse(text)];
|
|
152
|
+
const reply = messages.find((message) => message.id === rpcId);
|
|
153
|
+
assert.ok(reply, `MCP ${method}: no JSON-RPC reply`);
|
|
154
|
+
if (reply.error) throw new Error(`MCP ${method} failed: ${JSON.stringify(reply.error)}`);
|
|
155
|
+
return reply.result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function mcp(name, args) {
|
|
159
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
160
|
+
assert.ok(!result.isError, `${name}: ${JSON.stringify(result).slice(0, 400)}`);
|
|
161
|
+
return result.structuredContent;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function mcpError(name, args, code) {
|
|
165
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
166
|
+
assert.ok(result.isError, `${name} unexpectedly succeeded`);
|
|
167
|
+
if (code) {
|
|
168
|
+
const error = result.structuredContent?.error;
|
|
169
|
+
assert.ok(error, `${name}: ${JSON.stringify(result).slice(0, 400)}`);
|
|
170
|
+
assert.equal(error.code, code, JSON.stringify(error));
|
|
171
|
+
}
|
|
172
|
+
return result.structuredContent;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const tsList = (messages) => messages.map((message) => message.ts);
|
|
176
|
+
const decodeCursor = (cursor) => Buffer.from(cursor, "base64url").toString("utf8");
|
|
177
|
+
const engineeringHistory = (args) => get("conversations.history", { channel: C.engineering, ...args });
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------------------------
|
|
180
|
+
// Drill: web-api (member = Dana, baseline)
|
|
181
|
+
// ---------------------------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
async function webApi() {
|
|
184
|
+
// Identity over both route shapes -----------------------------------------------------------
|
|
185
|
+
const who = (await get("auth.test")).json;
|
|
186
|
+
assert.deepEqual(who, { ok: true, url: "https://example-team.slack.com/", team: "Example Team", user: "dana.reyes", team_id: "T01EXAMPLE0", user_id: U.dana, is_enterprise_install: false });
|
|
187
|
+
assert.equal((await post("auth.test")).json.user_id, U.dana);
|
|
188
|
+
|
|
189
|
+
// Users ---------------------------------------------------------------------------------------
|
|
190
|
+
const everyone = (await get("users.list")).json;
|
|
191
|
+
assert.deepEqual(everyone.members.map((member) => member.id), [U.dana, U.lee, U.mira, U.opsbot, U.priya, U.sam], "sorted by id");
|
|
192
|
+
assert.equal(everyone.response_metadata.next_cursor, "");
|
|
193
|
+
assert.equal(everyone.cache_ts, 1789376400, "virtual now");
|
|
194
|
+
assert.equal(everyone.members.find((member) => member.id === U.lee).deleted, true);
|
|
195
|
+
assert.equal(everyone.members.find((member) => member.id === U.opsbot).bot_id, "B01OPSBOT01");
|
|
196
|
+
assert.ok(!("bot_id" in everyone.members[0]));
|
|
197
|
+
let page = (await post("users.list", { limit: "2" })).json;
|
|
198
|
+
const walked = [...page.members.map((member) => member.id)];
|
|
199
|
+
let pages = 1;
|
|
200
|
+
while (page.response_metadata.next_cursor) {
|
|
201
|
+
assert.equal(decodeCursor(page.response_metadata.next_cursor).startsWith("user:"), true);
|
|
202
|
+
page = (await get("users.list", { limit: "2", cursor: page.response_metadata.next_cursor })).json;
|
|
203
|
+
walked.push(...page.members.map((member) => member.id));
|
|
204
|
+
pages += 1;
|
|
205
|
+
}
|
|
206
|
+
assert.equal(pages, 3);
|
|
207
|
+
assert.deepEqual(walked, everyone.members.map((member) => member.id));
|
|
208
|
+
const usersCursor = (await get("users.list", { limit: "2" })).json.response_metadata.next_cursor;
|
|
209
|
+
await apiError("GET", "users.list", { cursor: "not-a-cursor" }, 400, "invalid_cursor");
|
|
210
|
+
await apiError("GET", "users.list", { limit: "0" }, 400, "invalid_arguments");
|
|
211
|
+
await apiError("GET", "conversations.list", { cursor: usersCursor }, 400, "invalid_cursor");
|
|
212
|
+
await apiError("GET", "conversations.list", { cursor: "%%%" }, 400, "invalid_cursor");
|
|
213
|
+
|
|
214
|
+
const mira = (await get("users.info", { user: U.mira })).json.user;
|
|
215
|
+
assert.equal(mira.tz, "Asia/Singapore");
|
|
216
|
+
assert.equal(mira.profile.status_emoji, ":palm_tree:");
|
|
217
|
+
assert.equal((await post("users.info", { user: U.lee })).json.user.deleted, true);
|
|
218
|
+
await apiError("POST", "users.info", { user: "U0NOBODY000" }, 404, "user_not_found");
|
|
219
|
+
await op("users.info", { user: U.mira, user_id: U.mira }, "INVALID_ARGUMENTS");
|
|
220
|
+
await op("users.info", {}, "INVALID_ARGUMENTS");
|
|
221
|
+
const ownProfile = (await get("users.profile.get")).json.profile;
|
|
222
|
+
assert.equal(ownProfile.title, "Engineering Manager");
|
|
223
|
+
assert.equal(ownProfile.avatar_hash, "");
|
|
224
|
+
assert.equal((await post("users.profile.get", { user: U.lee })).json.profile.real_name, "Lee Marsh", "deactivated profiles are still returned");
|
|
225
|
+
await apiError("GET", "users.profile.get", { user: "U0NOBODY000" }, 404, "user_not_found");
|
|
226
|
+
await op("users.profile.get", { user: U.mira, user_id: U.mira }, "INVALID_ARGUMENTS");
|
|
227
|
+
|
|
228
|
+
// Conversation listing and info ---------------------------------------------------------------
|
|
229
|
+
const publicChannels = (await get("conversations.list")).json;
|
|
230
|
+
assert.deepEqual(publicChannels.channels.map((channel) => channel.id), [C.design, C.engineering, C.general, C.launch, C.onboarding, C.random]);
|
|
231
|
+
assert.equal(publicChannels.channels.find((channel) => channel.id === C.design).is_member, false);
|
|
232
|
+
assert.equal(publicChannels.channels.find((channel) => channel.id === C.general).is_member, true);
|
|
233
|
+
assert.ok(!("num_members" in publicChannels.channels[0]), "num_members only on request");
|
|
234
|
+
assert.equal((await post("conversations.list", { exclude_archived: "true" })).json.channels.length, 5);
|
|
235
|
+
const privateOnes = (await get("conversations.list", { types: "private_channel,mpim,im" })).json.channels;
|
|
236
|
+
assert.deepEqual(privateOnes.map((channel) => channel.id), [C.incidents, C.mpdm, C.im]);
|
|
237
|
+
assert.equal(privateOnes.find((channel) => channel.id === C.im).user, U.sam, "IM partner is the other party");
|
|
238
|
+
await apiError("GET", "conversations.list", { types: "public_channel,secret" }, 400, "invalid_arguments");
|
|
239
|
+
let listPage = (await get("conversations.list", { limit: "4" })).json;
|
|
240
|
+
assert.equal(listPage.channels.length, 4);
|
|
241
|
+
assert.equal(decodeCursor(listPage.response_metadata.next_cursor), `team:${C.launch}`);
|
|
242
|
+
listPage = (await get("conversations.list", { limit: "4", cursor: listPage.response_metadata.next_cursor })).json;
|
|
243
|
+
assert.deepEqual(listPage.channels.map((channel) => channel.id), [C.onboarding, C.random]);
|
|
244
|
+
assert.equal(listPage.response_metadata.next_cursor, "");
|
|
245
|
+
|
|
246
|
+
await apiError("GET", "conversations.info", { channel: C.leadership }, 404, "channel_not_found");
|
|
247
|
+
await apiError("GET", "conversations.info", { channel: "C0NOPE00000" }, 404, "channel_not_found");
|
|
248
|
+
const design = (await post("conversations.info", { channel: C.design })).json.channel;
|
|
249
|
+
assert.equal(design.is_member, false);
|
|
250
|
+
const general = (await get("conversations.info", { channel: C.general, include_num_members: "true" })).json.channel;
|
|
251
|
+
assert.equal(general.num_members, 5);
|
|
252
|
+
assert.equal(general.is_general, true);
|
|
253
|
+
assert.equal(general.topic.value, "Company-wide announcements");
|
|
254
|
+
|
|
255
|
+
const members = (await get("conversations.members", { channel: C.general })).json;
|
|
256
|
+
assert.deepEqual(members.members, [U.dana, U.mira, U.opsbot, U.priya, U.sam]);
|
|
257
|
+
let membersPage = (await post("conversations.members", { channel: C.general, limit: "2" })).json;
|
|
258
|
+
const walkedMembers = [...membersPage.members];
|
|
259
|
+
let memberPages = 1;
|
|
260
|
+
while (membersPage.response_metadata.next_cursor) {
|
|
261
|
+
membersPage = (await get("conversations.members", { channel: C.general, limit: "2", cursor: membersPage.response_metadata.next_cursor })).json;
|
|
262
|
+
walkedMembers.push(...membersPage.members);
|
|
263
|
+
memberPages += 1;
|
|
264
|
+
}
|
|
265
|
+
assert.equal(memberPages, 3);
|
|
266
|
+
assert.deepEqual(walkedMembers, members.members);
|
|
267
|
+
await apiError("GET", "conversations.members", { channel: C.general, cursor: "bogus" }, 400, "invalid_cursor");
|
|
268
|
+
assert.equal((await get("conversations.members", { channel: C.design })).json.members.length, 2, "public channel members are visible without membership");
|
|
269
|
+
|
|
270
|
+
// Sweeps: channel_not_found on an invisible private channel ----------------------------------
|
|
271
|
+
const hidden = { channel: C.leadership };
|
|
272
|
+
await apiError("GET", "conversations.members", hidden, 404, "channel_not_found");
|
|
273
|
+
await apiError("GET", "conversations.history", hidden, 404, "channel_not_found");
|
|
274
|
+
await apiError("GET", "conversations.replies", { ...hidden, ts: TS.e1 }, 404, "channel_not_found");
|
|
275
|
+
await apiError("GET", "pins.list", hidden, 404, "channel_not_found");
|
|
276
|
+
await apiError("POST", "conversations.join", hidden, 404, "channel_not_found");
|
|
277
|
+
await apiError("POST", "conversations.invite", { ...hidden, users: U.priya }, 404, "channel_not_found");
|
|
278
|
+
await apiError("POST", "conversations.archive", hidden, 404, "channel_not_found");
|
|
279
|
+
await apiError("POST", "conversations.setTopic", { ...hidden, topic: "x" }, 404, "channel_not_found");
|
|
280
|
+
await apiError("POST", "conversations.open", hidden, 404, "channel_not_found");
|
|
281
|
+
await apiError("POST", "chat.postMessage", { ...hidden, text: "x" }, 404, "channel_not_found");
|
|
282
|
+
await apiError("POST", "chat.update", { ...hidden, ts: TS.e1, text: "x" }, 404, "channel_not_found");
|
|
283
|
+
await apiError("POST", "chat.delete", { ...hidden, ts: TS.e1 }, 404, "channel_not_found");
|
|
284
|
+
await apiError("POST", "reactions.add", { ...hidden, timestamp: TS.e1, name: "eyes" }, 404, "channel_not_found");
|
|
285
|
+
await apiError("POST", "reactions.remove", { ...hidden, timestamp: TS.e1, name: "eyes" }, 404, "channel_not_found");
|
|
286
|
+
await apiError("POST", "pins.add", { ...hidden, timestamp: TS.e1 }, 404, "channel_not_found");
|
|
287
|
+
await apiError("POST", "pins.remove", { ...hidden, timestamp: TS.e1 }, 404, "channel_not_found");
|
|
288
|
+
await op("chat.reply", { channel: C.leadership, thread_ts: TS.e1, text: "x" }, "CHANNEL_NOT_FOUND");
|
|
289
|
+
|
|
290
|
+
// Sweeps: not_in_channel on a public channel Dana has not joined -----------------------------
|
|
291
|
+
const outside = { channel: C.design };
|
|
292
|
+
await apiError("GET", "conversations.history", outside, 403, "not_in_channel");
|
|
293
|
+
await apiError("GET", "conversations.replies", { ...outside, ts: TS.e1 }, 403, "not_in_channel");
|
|
294
|
+
await apiError("GET", "pins.list", outside, 403, "not_in_channel");
|
|
295
|
+
await apiError("POST", "conversations.invite", { ...outside, users: U.priya }, 403, "not_in_channel");
|
|
296
|
+
await apiError("POST", "conversations.archive", outside, 403, "not_in_channel");
|
|
297
|
+
await apiError("POST", "conversations.setTopic", { ...outside, topic: "x" }, 403, "not_in_channel");
|
|
298
|
+
await apiError("POST", "chat.postMessage", { ...outside, text: "x" }, 403, "not_in_channel");
|
|
299
|
+
await apiError("POST", "chat.update", { ...outside, ts: TS.e1, text: "x" }, 403, "not_in_channel");
|
|
300
|
+
await apiError("POST", "chat.delete", { ...outside, ts: TS.e1 }, 403, "not_in_channel");
|
|
301
|
+
await apiError("POST", "reactions.add", { ...outside, timestamp: TS.e1, name: "eyes" }, 403, "not_in_channel");
|
|
302
|
+
await apiError("POST", "reactions.remove", { ...outside, timestamp: TS.e1, name: "eyes" }, 403, "not_in_channel");
|
|
303
|
+
await apiError("POST", "pins.add", { ...outside, timestamp: TS.e1 }, 403, "not_in_channel");
|
|
304
|
+
await apiError("POST", "pins.remove", { ...outside, timestamp: TS.e1 }, 403, "not_in_channel");
|
|
305
|
+
await op("chat.reply", { channel: C.design, thread_ts: TS.e1, text: "x" }, "NOT_IN_CHANNEL");
|
|
306
|
+
|
|
307
|
+
// Sweeps: is_archived on #launch-q3 (Dana is a member) ----------------------------------------
|
|
308
|
+
const archived = { channel: C.launch };
|
|
309
|
+
await apiError("POST", "conversations.join", archived, 400, "is_archived");
|
|
310
|
+
await apiError("POST", "conversations.invite", { ...archived, users: U.sam }, 400, "is_archived");
|
|
311
|
+
await apiError("POST", "conversations.setTopic", { ...archived, topic: "x" }, 400, "is_archived");
|
|
312
|
+
await apiError("POST", "chat.postMessage", { ...archived, text: "x" }, 400, "is_archived");
|
|
313
|
+
await apiError("POST", "chat.update", { ...archived, ts: TS.l1, text: "x" }, 400, "is_archived");
|
|
314
|
+
await apiError("POST", "chat.delete", { ...archived, ts: TS.l1 }, 400, "is_archived");
|
|
315
|
+
await apiError("POST", "reactions.add", { ...archived, timestamp: TS.l1, name: "eyes" }, 400, "is_archived");
|
|
316
|
+
await apiError("POST", "reactions.remove", { ...archived, timestamp: TS.l1, name: "eyes" }, 400, "is_archived");
|
|
317
|
+
await apiError("POST", "pins.add", { ...archived, timestamp: TS.l1 }, 400, "is_archived");
|
|
318
|
+
await apiError("POST", "pins.remove", { ...archived, timestamp: TS.l1 }, 400, "is_archived");
|
|
319
|
+
await op("chat.reply", { channel: C.launch, thread_ts: TS.l1, text: "x" }, "IS_ARCHIVED");
|
|
320
|
+
assert.equal((await get("conversations.history", archived)).json.messages.length, 2, "archived channels stay readable");
|
|
321
|
+
|
|
322
|
+
// Sweeps: invalid_arguments when both argument spellings are given (canonical endpoint) -------
|
|
323
|
+
const both = { channel: C.general, channel_id: C.general };
|
|
324
|
+
for (const [operationId, extra] of [
|
|
325
|
+
["conversations.info", {}],
|
|
326
|
+
["conversations.members", {}],
|
|
327
|
+
["conversations.join", {}],
|
|
328
|
+
["conversations.invite", { users: U.sam }],
|
|
329
|
+
["conversations.archive", {}],
|
|
330
|
+
["conversations.set-topic", { topic: "x" }],
|
|
331
|
+
["conversations.history", {}],
|
|
332
|
+
["conversations.replies", { ts: TS.g1 }],
|
|
333
|
+
["chat.post-message", { text: "x" }],
|
|
334
|
+
["chat.reply", { thread_ts: TS.g1, text: "x" }],
|
|
335
|
+
["chat.update", { ts: TS.g1, text: "x" }],
|
|
336
|
+
["chat.delete", { ts: TS.g1 }],
|
|
337
|
+
["reactions.add", { timestamp: TS.g1, name: "eyes" }],
|
|
338
|
+
["reactions.remove", { timestamp: TS.g1, name: "eyes" }],
|
|
339
|
+
["pins.add", { timestamp: TS.g1 }],
|
|
340
|
+
["pins.remove", { timestamp: TS.g1 }],
|
|
341
|
+
["pins.list", {}],
|
|
342
|
+
]) {
|
|
343
|
+
await op(operationId, { ...both, ...extra }, "INVALID_ARGUMENTS");
|
|
344
|
+
}
|
|
345
|
+
await op("conversations.replies", { channel: C.engineering, ts: TS.e1, thread_ts: TS.e1 }, "INVALID_ARGUMENTS");
|
|
346
|
+
await op("reactions.add", { channel: C.general, timestamp: TS.g1, name: "eyes", reaction: "eyes" }, "INVALID_ARGUMENTS");
|
|
347
|
+
await op("conversations.open", {}, "INVALID_ARGUMENTS");
|
|
348
|
+
await op("conversations.open", { users: U.sam, channel: C.im }, "INVALID_ARGUMENTS");
|
|
349
|
+
await op("conversations.open", { users: U.dana }, "INVALID_ARGUMENTS");
|
|
350
|
+
|
|
351
|
+
// History: pagination, window, empty channel -------------------------------------------------
|
|
352
|
+
let history = (await engineeringHistory({ limit: "5" })).json;
|
|
353
|
+
assert.deepEqual(tsList(history.messages), [TS.e11, TS.e10, TS.e9, TS.e8, TS.e7]);
|
|
354
|
+
assert.equal(history.has_more, true);
|
|
355
|
+
assert.equal(history.pin_count, 0);
|
|
356
|
+
assert.ok(!("channel" in history.messages[0]) && !("permalink" in history.messages[0]), "history messages omit channel and permalink");
|
|
357
|
+
assert.equal(history.messages.find((message) => message.ts === TS.e7).blocks.length, 1);
|
|
358
|
+
assert.equal(history.messages.find((message) => message.ts === TS.e8).reactions[0].name, "eyes");
|
|
359
|
+
history = (await post("conversations.history", { channel: C.engineering, limit: "5", cursor: history.response_metadata.next_cursor })).json;
|
|
360
|
+
assert.deepEqual(tsList(history.messages), [TS.e6, TS.e5, TS.e3, TS.e2, TS.r3], "the thread_broadcast reply is top-level, plain replies are not");
|
|
361
|
+
assert.equal(history.messages.find((message) => message.ts === TS.e6).edited.user, U.dana);
|
|
362
|
+
assert.equal(history.has_more, true);
|
|
363
|
+
history = (await engineeringHistory({ limit: "5", cursor: history.response_metadata.next_cursor })).json;
|
|
364
|
+
assert.deepEqual(tsList(history.messages), [TS.e1, TS.join]);
|
|
365
|
+
assert.equal(history.messages[0].reply_count, 4);
|
|
366
|
+
assert.deepEqual(history.messages[0].reply_users, [U.dana, U.mira, U.opsbot]);
|
|
367
|
+
assert.equal(history.messages[1].subtype, "channel_join");
|
|
368
|
+
assert.equal(history.has_more, false);
|
|
369
|
+
assert.equal(history.response_metadata.next_cursor, "");
|
|
370
|
+
assert.deepEqual(tsList((await engineeringHistory({ oldest: TS.e2, latest: TS.e6 })).json.messages), [TS.e5, TS.e3], "exclusive window");
|
|
371
|
+
assert.deepEqual(tsList((await engineeringHistory({ oldest: TS.e2, latest: TS.e6, inclusive: "true" })).json.messages), [TS.e6, TS.e5, TS.e3, TS.e2]);
|
|
372
|
+
assert.deepEqual(tsList((await engineeringHistory({ oldest: "1789000000" })).json.messages), [TS.e11, TS.e10], "plain epoch seconds are accepted");
|
|
373
|
+
await apiError("GET", "conversations.history", { channel: C.engineering, oldest: "yesterday" }, 400, "invalid_arguments");
|
|
374
|
+
await apiError("GET", "conversations.history", { channel: C.engineering, cursor: "bmV4dF90czpub3Bl" }, 400, "invalid_cursor");
|
|
375
|
+
await apiError("GET", "conversations.history", { channel: C.engineering, cursor: usersCursor }, 400, "invalid_cursor");
|
|
376
|
+
const onboarding = (await get("conversations.history", { channel: C.onboarding })).json;
|
|
377
|
+
assert.deepEqual(onboarding, { ok: true, messages: [], has_more: false, pin_count: 0, response_metadata: { next_cursor: "" } });
|
|
378
|
+
assert.equal((await get("conversations.history", { channel: C.general })).json.pin_count, 1);
|
|
379
|
+
|
|
380
|
+
// Replies ------------------------------------------------------------------------------------
|
|
381
|
+
const thread = (await get("conversations.replies", { channel: C.engineering, ts: TS.e1 })).json;
|
|
382
|
+
assert.deepEqual(tsList(thread.messages), [TS.e1, TS.r1, TS.r2, TS.r3, TS.r4], "parent first, then replies oldest first");
|
|
383
|
+
assert.equal(thread.has_more, false);
|
|
384
|
+
let replyPage = (await post("conversations.replies", { channel: C.engineering, ts: TS.e1, limit: "2" })).json;
|
|
385
|
+
assert.deepEqual(tsList(replyPage.messages), [TS.e1, TS.r1, TS.r2]);
|
|
386
|
+
assert.equal(replyPage.has_more, true);
|
|
387
|
+
replyPage = (await get("conversations.replies", { channel: C.engineering, ts: TS.e1, limit: "2", cursor: replyPage.response_metadata.next_cursor })).json;
|
|
388
|
+
assert.deepEqual(tsList(replyPage.messages), [TS.r3, TS.r4], "later pages omit the parent");
|
|
389
|
+
assert.equal(replyPage.has_more, false);
|
|
390
|
+
assert.deepEqual(tsList((await get("conversations.replies", { channel: C.engineering, ts: TS.e1, oldest: TS.r2, latest: TS.r4 })).json.messages), [TS.e1, TS.r3]);
|
|
391
|
+
await apiError("GET", "conversations.replies", { channel: C.engineering, ts: TS.r1 }, 404, "thread_not_found", {});
|
|
392
|
+
await apiError("GET", "conversations.replies", { channel: C.engineering, ts: TS.unknown }, 404, "thread_not_found");
|
|
393
|
+
await apiError("GET", "conversations.replies", { channel: C.engineering, ts: TS.e1, cursor: "nope" }, 400, "invalid_cursor");
|
|
394
|
+
assert.equal((await get("conversations.replies", { channel: C.general, ts: TS.g1 })).json.messages.length, 1, "a message without replies is a one-message thread");
|
|
395
|
+
|
|
396
|
+
// Search (before this flow adds messages, so the counts are the authored ones) -----------------
|
|
397
|
+
const search = async (query, extra = {}) => (await get("search.messages", { query, ...extra })).json.messages;
|
|
398
|
+
const deploy = await search("deploy");
|
|
399
|
+
assert.equal(deploy.total, 4);
|
|
400
|
+
assert.deepEqual(deploy.matches.map((match) => match.ts), [TS.e10, TS.e3, TS.e2, TS.r3], "score then ts desc");
|
|
401
|
+
assert.equal(deploy.matches[1].username, "sam.okafor");
|
|
402
|
+
assert.equal(deploy.matches[1].channel.name, "engineering");
|
|
403
|
+
assert.equal(deploy.matches[1].permalink, `https://example-team.slack.com/archives/${C.engineering}/p1787934600000126`);
|
|
404
|
+
assert.deepEqual(deploy.pagination, { total_count: 4, page: 1, per_page: 20, page_count: 1, first: 1, last: 4 });
|
|
405
|
+
assert.deepEqual(deploy.paging, { count: 20, total: 4, page: 1, pages: 1 });
|
|
406
|
+
assert.deepEqual(tsList((await search("deploy", { sort: "timestamp", sort_dir: "asc" })).matches), [TS.r3, TS.e2, TS.e3, TS.e10]);
|
|
407
|
+
const secondPage = await search("deploy", { count: "2", page: "2" });
|
|
408
|
+
assert.deepEqual(tsList(secondPage.matches), [TS.e2, TS.r3]);
|
|
409
|
+
assert.equal(secondPage.pagination.page_count, 2);
|
|
410
|
+
assert.equal((await search("deploy", { count: "2", page: "3" })).matches.length, 0, "a page past the end is empty, not an error");
|
|
411
|
+
assert.deepEqual(tsList((await search("deploy in:#engineering from:@sam.okafor")).matches), [TS.e3]);
|
|
412
|
+
assert.deepEqual(tsList((await search("is:thread postgres")).matches), [TS.r1]);
|
|
413
|
+
assert.deepEqual(tsList((await search("has:pin")).matches).sort(), [TS.g1, "1788166800.000144"].sort());
|
|
414
|
+
assert.deepEqual(tsList((await search('"rollback plan"')).matches), [TS.e6]);
|
|
415
|
+
assert.equal((await search("rollback")).total, 2);
|
|
416
|
+
assert.deepEqual(tsList((await search("has:reaction in:#engineering")).matches).sort(), [TS.e2, TS.e8].sort());
|
|
417
|
+
assert.deepEqual(tsList((await search("has::eyes:")).matches), [TS.e8]);
|
|
418
|
+
assert.deepEqual(tsList((await search("from:me in:#engineering")).matches).sort(), [TS.r1, TS.e2, TS.e6, TS.e10].sort());
|
|
419
|
+
assert.equal((await search("in:@sam.okafor rota")).total, 2, "IM by handle");
|
|
420
|
+
assert.equal((await search(`from:<@${U.opsbot}>`)).total, 4);
|
|
421
|
+
assert.deepEqual(tsList((await search(`in:<#${C.engineering}> standup`)).matches), [TS.e11]);
|
|
422
|
+
assert.equal((await search("in:#engineering before:2026-08-25")).total, 3, "channel_join messages are not searchable");
|
|
423
|
+
assert.equal((await search("in:#engineering on:2026-08-24")).total, 3);
|
|
424
|
+
assert.equal((await search("in:#engineering after:2026-09-10")).total, 2);
|
|
425
|
+
assert.equal((await search("in:#engineering during:2026-09")).total, 7);
|
|
426
|
+
assert.equal((await search("in:#engineering is:thread -flaky")).total, 5);
|
|
427
|
+
assert.equal((await search("in:#nowhere deploy")).total, 0, "unknown channel gives zero matches");
|
|
428
|
+
assert.equal((await post("search.messages", { query: "standup" })).json.messages.total, 1);
|
|
429
|
+
await apiError("GET", "search.messages", { query: "to:@dana.reyes" }, 400, "invalid_arguments");
|
|
430
|
+
await apiError("GET", "search.messages", { query: "has:link" }, 400, "invalid_arguments");
|
|
431
|
+
await apiError("GET", "search.messages", { query: "before:yesterday" }, 400, "invalid_arguments");
|
|
432
|
+
await apiError("GET", "search.messages", { query: "deploy", sort: "relevance" }, 400, "invalid_arguments");
|
|
433
|
+
await apiError("GET", "search.messages", { query: "deploy", count: "0" }, 400, "invalid_arguments");
|
|
434
|
+
await apiError("GET", "search.messages", { query: " " }, 400, "no_query");
|
|
435
|
+
// Mangled percent-encoding: the framework decodes %E0%A4%A to U+FFFD in query strings and form bodies. The search
|
|
436
|
+
// must fail with invalid_arguments instead of answering an empty result; %EF%BF%BD is rejected the same way.
|
|
437
|
+
const rawSearch = async (verb, encoded) => {
|
|
438
|
+
const init = { method: verb, headers: { authorization: `Bearer ${HTTP_TOKEN}` } };
|
|
439
|
+
if (verb === "POST") {
|
|
440
|
+
init.headers["content-type"] = "application/x-www-form-urlencoded";
|
|
441
|
+
init.body = `query=${encoded}`;
|
|
442
|
+
}
|
|
443
|
+
const response = await fetch(verb === "GET" ? `${HTTP}/api/search.messages?query=${encoded}` : `${HTTP}/api/search.messages`, init);
|
|
444
|
+
return { status: response.status, json: await response.json() };
|
|
445
|
+
};
|
|
446
|
+
for (const verb of ["GET", "POST"]) {
|
|
447
|
+
for (const encoded of ["deploy%E0%A4%A", "%E0%A4%A", "deploy%EF%BF%BD", "in%3A%23engineering%20depl%E0%A4%A"]) {
|
|
448
|
+
const mangled = await rawSearch(verb, encoded);
|
|
449
|
+
assert.equal(mangled.status, 400, `${verb} ${encoded}: ${JSON.stringify(mangled.json)}`);
|
|
450
|
+
assert.equal(mangled.json.ok, false);
|
|
451
|
+
assert.equal(mangled.json.error, "invalid_arguments", `${verb} ${encoded}`);
|
|
452
|
+
assert.ok(mangled.json.response_metadata.messages[0].includes("U+FFFD"));
|
|
453
|
+
}
|
|
454
|
+
const literal = await rawSearch(verb, "deploy%ZZ");
|
|
455
|
+
assert.equal(literal.json.ok, true, "%ZZ stays literal text");
|
|
456
|
+
assert.equal(literal.json.query, "deploy%ZZ");
|
|
457
|
+
assert.equal((await rawSearch(verb, "deploy")).json.messages.total > 0, true);
|
|
458
|
+
}
|
|
459
|
+
await op("search.messages", { query: "deploy \uFFFD" }, "INVALID_ARGUMENTS");
|
|
460
|
+
// Legitimate non-ASCII searches keep working.
|
|
461
|
+
const unicodeText = "Café 漢字 🔥 encoding check";
|
|
462
|
+
await post("chat.postMessage", { channel: "C01ENGINEER", text: unicodeText });
|
|
463
|
+
for (const term of ["café", "漢字", "🔥"]) {
|
|
464
|
+
const found = await search(term);
|
|
465
|
+
assert.ok(found.matches.some((match) => match.text === unicodeText), `search ${term}: ${JSON.stringify(found).slice(0, 300)}`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Join ----------------------------------------------------------------------------------------
|
|
469
|
+
const joined = (await post("conversations.join", { channel: C.design })).json;
|
|
470
|
+
assert.equal(joined.channel.is_member, true);
|
|
471
|
+
assert.equal(joined.channel.num_members, 3);
|
|
472
|
+
assert.ok(!("warning" in joined));
|
|
473
|
+
const designHistory = (await get("conversations.history", { channel: C.design })).json;
|
|
474
|
+
assert.equal(designHistory.messages.length, 3, "two authored messages plus the channel_join");
|
|
475
|
+
assert.equal(designHistory.messages[0].subtype, "channel_join");
|
|
476
|
+
assert.equal(designHistory.messages[0].text, `<@${U.dana}> has joined the channel`);
|
|
477
|
+
const again = (await post("conversations.join", { channel: C.design })).json;
|
|
478
|
+
assert.equal(again.warning, "already_in_channel");
|
|
479
|
+
assert.deepEqual(again.response_metadata, { warnings: ["already_in_channel"] });
|
|
480
|
+
await apiError("POST", "conversations.join", { channel: C.incidents }, 400, "method_not_supported_for_channel_type");
|
|
481
|
+
await apiError("POST", "conversations.join", { channel: C.im }, 400, "method_not_supported_for_channel_type");
|
|
482
|
+
|
|
483
|
+
// Create, invite, topic -----------------------------------------------------------------------
|
|
484
|
+
const created = (await post("conversations.create", { name: "#Release-Notes" })).json.channel;
|
|
485
|
+
assert.equal(created.id, "C000000001F");
|
|
486
|
+
assert.equal(created.name, "release-notes");
|
|
487
|
+
assert.equal(created.is_member, true);
|
|
488
|
+
assert.equal(created.num_members, 1);
|
|
489
|
+
assert.equal(created.creator, U.dana);
|
|
490
|
+
await apiError("POST", "conversations.create", { name: "release-notes" }, 409, "name_taken");
|
|
491
|
+
await apiError("POST", "conversations.create", { name: "General" }, 409, "name_taken", {});
|
|
492
|
+
await apiError("POST", "conversations.create", { name: "bad name!" }, 400, "invalid_name");
|
|
493
|
+
const invited = (await post("conversations.invite", { channel: created.id, users: `${U.sam},${U.mira}` })).json.channel;
|
|
494
|
+
assert.equal(invited.num_members, 3);
|
|
495
|
+
await apiError("POST", "conversations.invite", { channel: created.id, users: U.sam }, 409, "already_in_channel");
|
|
496
|
+
await apiError("POST", "conversations.invite", { channel: created.id, users: U.lee }, 404, "user_not_found");
|
|
497
|
+
await apiError("POST", "conversations.invite", { channel: created.id, users: "U0NOBODY000" }, 404, "user_not_found");
|
|
498
|
+
await apiError("POST", "conversations.invite", { channel: created.id, users: U.dana }, 400, "cant_invite_self");
|
|
499
|
+
await apiError("POST", "conversations.invite", { channel: created.id, users: `${U.priya},${U.lee}` }, 404, "user_not_found");
|
|
500
|
+
assert.deepEqual((await get("conversations.members", { channel: created.id })).json.members, [U.dana, U.mira, U.sam], "all-or-nothing: Priya was not added");
|
|
501
|
+
await apiError("POST", "conversations.invite", { channel: C.im, users: U.mira }, 400, "method_not_supported_for_channel_type");
|
|
502
|
+
await apiError("POST", "conversations.invite", { channel: created.id, users: " , " }, 400, "invalid_arguments");
|
|
503
|
+
assert.equal((await get("conversations.history", { channel: created.id })).json.messages.length, 2, "one channel_join per invitee");
|
|
504
|
+
const topic = (await post("conversations.setTopic", { channel: created.id, topic: "Weekly release notes" })).json.channel;
|
|
505
|
+
assert.equal(topic.topic.value, "Weekly release notes");
|
|
506
|
+
assert.equal(topic.topic.creator, U.dana);
|
|
507
|
+
assert.equal(topic.topic.last_set, 1789376400);
|
|
508
|
+
assert.equal((await post("conversations.setTopic", { channel: created.id, topic: "" })).json.channel.topic.value, "");
|
|
509
|
+
|
|
510
|
+
// Open IMs and group DMs ---------------------------------------------------------------------
|
|
511
|
+
const existingIm = (await post("conversations.open", { users: U.sam })).json;
|
|
512
|
+
assert.deepEqual(existingIm, { ok: true, no_op: true, already_open: true, channel: { id: C.im } });
|
|
513
|
+
assert.equal((await post("conversations.open", { users: `${U.sam},${U.mira}` })).json.channel.id, C.mpdm);
|
|
514
|
+
assert.equal((await post("conversations.open", { users: `${U.mira},${U.sam},${U.dana}` })).json.channel.id, C.mpdm, "self is ignored, order does not matter");
|
|
515
|
+
const newIm = (await post("conversations.open", { users: U.priya, return_im: "true" })).json;
|
|
516
|
+
assert.equal(newIm.already_open, false);
|
|
517
|
+
assert.equal(newIm.channel.id, "D000000001G");
|
|
518
|
+
assert.equal(newIm.channel.is_im, true);
|
|
519
|
+
assert.equal(newIm.channel.user, U.priya);
|
|
520
|
+
assert.equal(newIm.channel.num_members, 2);
|
|
521
|
+
assert.equal((await post("conversations.open", { users: U.priya })).json.already_open, true);
|
|
522
|
+
const newGroup = (await post("conversations.open", { users: `${U.priya},${U.mira}`, return_im: "true" })).json.channel;
|
|
523
|
+
assert.equal(newGroup.is_mpim, true);
|
|
524
|
+
assert.equal(newGroup.name, "mpdm-dana.reyes--mira.chen--priya.n-1");
|
|
525
|
+
await apiError("POST", "conversations.open", { users: U.lee }, 404, "user_not_found");
|
|
526
|
+
assert.equal((await post("conversations.open", { channel: C.im })).json.channel.id, C.im);
|
|
527
|
+
const ims = (await get("conversations.list", { types: "im" })).json.channels;
|
|
528
|
+
assert.deepEqual(ims.map((channel) => channel.id), [newIm.channel.id, C.im], "ids sort as strings");
|
|
529
|
+
|
|
530
|
+
// Post messages --------------------------------------------------------------------------------
|
|
531
|
+
const hello = (await post("chat.postMessage", { channel: C.general, text: "Hello from the conformance flow" })).json;
|
|
532
|
+
assert.equal(hello.channel, C.general);
|
|
533
|
+
assert.match(hello.ts, /^1789376400\.0000\d\d$/, "the six-digit fraction restarts in every virtual second");
|
|
534
|
+
assert.equal(hello.message.text, "Hello from the conformance flow");
|
|
535
|
+
assert.equal(hello.message.user, U.dana);
|
|
536
|
+
assert.ok(!("subtype" in hello.message));
|
|
537
|
+
const withBlocks = (await post("chat.postMessage", { channel: created.id, text: "Release 2026.37 is out", blocks: [{ type: "section", text: { type: "mrkdwn", text: "*Release 2026.37* is out" } }] })).json;
|
|
538
|
+
assert.equal(withBlocks.message.blocks.length, 1, "blocks arrive as a JSON string in the form and are stored");
|
|
539
|
+
// Blocks that are not JSON answer invalid_blocks_format; JSON of the wrong shape answers invalid_blocks. Nothing is stored.
|
|
540
|
+
const blocksRefused = async (method, args, error) => apiError("POST", method, args, 400, error, { rawBody: new URLSearchParams(args).toString() });
|
|
541
|
+
await blocksRefused("chat.postMessage", { channel: created.id, text: "x", blocks: "{" }, "invalid_blocks_format");
|
|
542
|
+
await blocksRefused("chat.postMessage", { channel: created.id, text: "x", blocks: '{"type":"section"}' }, "invalid_blocks");
|
|
543
|
+
await blocksRefused("chat.postMessage", { channel: created.id, text: "x", blocks: '[{"text":"no type"}]' }, "invalid_blocks");
|
|
544
|
+
await blocksRefused("chat.postMessage", { channel: created.id, text: "x", blocks: `[{"type":"section","a":${"[".repeat(40)}${"]".repeat(40)}}]` }, "invalid_blocks");
|
|
545
|
+
await blocksRefused("chat.update", { channel: created.id, ts: withBlocks.ts, text: "x", blocks: "%7B" }, "invalid_blocks_format");
|
|
546
|
+
await blocksRefused("chat.update", { channel: created.id, ts: withBlocks.ts, blocks: JSON.stringify([{ type: "section", text: "x".repeat(100001) }]) }, "invalid_blocks");
|
|
547
|
+
await op("chat.reply", { channel: created.id, thread_ts: withBlocks.ts, text: "x", blocks: "{" }, "INVALID_BLOCKS_FORMAT");
|
|
548
|
+
await op("chat.reply", { channel: created.id, thread_ts: withBlocks.ts, text: "x", blocks: [{ type: "section" }, { text: "no type" }] }, "INVALID_BLOCKS");
|
|
549
|
+
assert.equal((await get("conversations.history", { channel: created.id, limit: "1" })).json.messages[0].ts, withBlocks.ts, "refused posts stored nothing");
|
|
550
|
+
assert.ok(withBlocks.ts > hello.ts, "ts values are strictly increasing");
|
|
551
|
+
// The same methods accept an application/json object body with the bearer token, as the Web API does; blocks travel
|
|
552
|
+
// as a real array and booleans as JSON booleans. Bodies that are not a JSON object cannot be mapped to a call at all
|
|
553
|
+
// and get the framework's 400 envelope, with Slack's error name in the text (README "Protocol compatibility").
|
|
554
|
+
const beforeJson = (await get("conversations.history", { channel: created.id })).json.messages.length;
|
|
555
|
+
const jsonPost = (method, args, options = {}) =>
|
|
556
|
+
post(method, {}, { ...options, contentType: "application/json; charset=utf-8", rawBody: typeof args === "string" ? args : JSON.stringify(args) });
|
|
557
|
+
const viaJson = (await jsonPost("chat.postMessage", { channel: created.id, text: "Posted as JSON", blocks: [{ type: "section", text: { type: "mrkdwn", text: "json" } }], mrkdwn: true })).json;
|
|
558
|
+
assert.equal(viaJson.message.text, "Posted as JSON");
|
|
559
|
+
assert.equal(viaJson.message.blocks[0].text.text, "json", "JSON blocks arrive as an array and are stored");
|
|
560
|
+
assert.equal((await jsonPost("chat.update", { channel: created.id, ts: viaJson.ts, text: "Edited as JSON" })).json.text, "Edited as JSON");
|
|
561
|
+
const jsonInfo = (await jsonPost("conversations.info", { channel: created.id, include_num_members: true })).json.channel;
|
|
562
|
+
assert.equal(jsonInfo.is_member, true, "reads take JSON bodies on POST too");
|
|
563
|
+
assert.equal(typeof jsonInfo.num_members, "number", "a JSON boolean is read as the flag");
|
|
564
|
+
assert.equal((await jsonPost("chat.postMessage", { channel: created.id, text: "x", blocks: { type: "section" } }, { status: 400 })).json.error, "invalid_blocks");
|
|
565
|
+
assert.equal((await jsonPost("chat.postMessage", { channel: created.id, text: "x", blocks: "{" }, { status: 400 })).json.error, "invalid_blocks_format");
|
|
566
|
+
assert.equal((await jsonPost("chat.postMessage", { channel: created.id, text: "x", __proto__: { polluted: 1 }, constructor: { prototype: { polluted: 1 } } })).json.message.text, "x");
|
|
567
|
+
assert.equal(({}).polluted, undefined, "caller keys never reach Object.prototype");
|
|
568
|
+
for (const [rawBody, contentType, pattern] of [
|
|
569
|
+
['{"channel":', "application/json", /invalid_json/],
|
|
570
|
+
["[1]", "application/json", /json_not_object/],
|
|
571
|
+
["channel=x", "text/plain", /application\/x-www-form-urlencoded or application\/json/],
|
|
572
|
+
]) {
|
|
573
|
+
const unmapped = await post("chat.postMessage", {}, { status: 400, contentType, rawBody });
|
|
574
|
+
assert.equal(unmapped.json.code, "framework.HTTP_REQUEST_MAPPING_FAILED", JSON.stringify(unmapped.json));
|
|
575
|
+
assert.match(unmapped.json.error, pattern);
|
|
576
|
+
}
|
|
577
|
+
assert.equal((await get("conversations.history", { channel: created.id })).json.messages.length, beforeJson + 2, "only viaJson and the poison probe were stored");
|
|
578
|
+
const broadcast = (await post("chat.postMessage", { channel: C.engineering, text: "Broadcast reply from the conformance flow", thread_ts: TS.e1, reply_broadcast: "true" })).json;
|
|
579
|
+
assert.equal(broadcast.message.thread_ts, TS.e1);
|
|
580
|
+
assert.equal(broadcast.message.subtype, "thread_broadcast");
|
|
581
|
+
const grown = (await get("conversations.replies", { channel: C.engineering, ts: TS.e1 })).json.messages;
|
|
582
|
+
assert.equal(grown.length, 6);
|
|
583
|
+
assert.equal(grown[0].reply_count, 5);
|
|
584
|
+
assert.equal(grown[0].latest_reply, broadcast.ts);
|
|
585
|
+
assert.equal(grown[0].reply_users_count, 3, "Dana already counted among reply_users");
|
|
586
|
+
assert.equal(tsList((await engineeringHistory({ limit: "1" })).json.messages)[0], broadcast.ts, "broadcast replies show in history");
|
|
587
|
+
const dm = (await post("chat.postMessage", { channel: newIm.channel.id, text: "Hi Priya, this is a synthetic DM." })).json;
|
|
588
|
+
assert.equal(dm.channel, newIm.channel.id);
|
|
589
|
+
await apiError("POST", "chat.postMessage", { channel: C.general, text: " " }, 400, "no_text");
|
|
590
|
+
await apiError("POST", "chat.postMessage", { channel: C.general }, 400, "no_text");
|
|
591
|
+
await apiError("POST", "chat.postMessage", { channel: C.engineering, text: "x", thread_ts: TS.r1 }, 404, "thread_not_found");
|
|
592
|
+
await apiError("POST", "chat.postMessage", { channel: C.engineering, text: "x", thread_ts: TS.unknown }, 404, "thread_not_found");
|
|
593
|
+
const tooLong = await post("chat.postMessage", { channel: C.general, text: "x".repeat(40001) }, { status: 400 });
|
|
594
|
+
assert.equal(tooLong.json.error, "invalid_arguments", "framework schema failures are rendered Slack-shaped");
|
|
595
|
+
await op("chat.reply", { channel: C.general, thread_ts: TS.g1, text: "" }, "NO_TEXT");
|
|
596
|
+
await op("chat.reply", { channel: C.general, thread_ts: TS.unknown, text: "x" }, "THREAD_NOT_FOUND");
|
|
597
|
+
|
|
598
|
+
// Update ------------------------------------------------------------------------------------------
|
|
599
|
+
const edited = (await post("chat.update", { channel: C.general, ts: hello.ts, text: "Hello from the conformance flow (edited)" })).json;
|
|
600
|
+
assert.equal(edited.text, "Hello from the conformance flow (edited)");
|
|
601
|
+
assert.equal(edited.message.edited.user, U.dana);
|
|
602
|
+
assert.equal(edited.message.edited.ts, "1789376400.000000");
|
|
603
|
+
const generalNow = (await get("conversations.history", { channel: C.general })).json.messages;
|
|
604
|
+
assert.equal(generalNow.find((message) => message.ts === hello.ts).text, "Hello from the conformance flow (edited)", "reads reflect the write");
|
|
605
|
+
await apiError("POST", "chat.update", { channel: C.engineering, ts: TS.e3, text: "not mine" }, 403, "cant_update_message");
|
|
606
|
+
await apiError("POST", "chat.update", { channel: C.engineering, ts: TS.join, text: "system" }, 403, "cant_update_message");
|
|
607
|
+
await apiError("POST", "chat.update", { channel: C.general, ts: TS.unknown, text: "x" }, 404, "message_not_found");
|
|
608
|
+
await apiError("POST", "chat.update", { channel: C.general, ts: hello.ts, text: "" }, 400, "no_text");
|
|
609
|
+
await apiError("POST", "chat.update", { channel: C.general, ts: hello.ts }, 400, "no_text");
|
|
610
|
+
|
|
611
|
+
// Reactions -------------------------------------------------------------------------------------
|
|
612
|
+
await post("reactions.add", { channel: C.general, timestamp: hello.ts, name: ":rocket:" });
|
|
613
|
+
let reacted = (await get("conversations.history", { channel: C.general })).json.messages.find((message) => message.ts === hello.ts);
|
|
614
|
+
assert.deepEqual(reacted.reactions, [{ name: "rocket", users: [U.dana], count: 1 }]);
|
|
615
|
+
await apiError("POST", "reactions.add", { channel: C.general, timestamp: hello.ts, name: "rocket" }, 409, "already_reacted");
|
|
616
|
+
await apiError("POST", "reactions.add", { channel: C.general, timestamp: hello.ts, name: "Rocket!" }, 400, "invalid_name");
|
|
617
|
+
await apiError("POST", "reactions.add", { channel: C.general, timestamp: TS.unknown, name: "rocket" }, 404, "message_not_found");
|
|
618
|
+
await post("reactions.remove", { channel: C.general, timestamp: hello.ts, name: "rocket" });
|
|
619
|
+
reacted = (await get("conversations.history", { channel: C.general })).json.messages.find((message) => message.ts === hello.ts);
|
|
620
|
+
assert.ok(!("reactions" in reacted), "an empty reaction list is omitted, as in Slack");
|
|
621
|
+
await apiError("POST", "reactions.remove", { channel: C.general, timestamp: hello.ts, name: "rocket" }, 404, "no_reaction");
|
|
622
|
+
await apiError("POST", "reactions.remove", { channel: C.general, timestamp: hello.ts, name: "Rocket!" }, 400, "invalid_name");
|
|
623
|
+
await apiError("POST", "reactions.remove", { channel: C.general, timestamp: TS.unknown, name: "rocket" }, 404, "message_not_found");
|
|
624
|
+
await apiError("POST", "reactions.remove", { channel: C.engineering, timestamp: TS.e2, name: "+1" }, 404, "no_reaction", {});
|
|
625
|
+
await post("reactions.remove", { channel: C.engineering, timestamp: TS.e8, name: "eyes" });
|
|
626
|
+
for (let index = 0; index < 50; index += 1) await post("reactions.add", { channel: C.general, timestamp: hello.ts, name: `conf_${String(index).padStart(2, "0")}` });
|
|
627
|
+
await apiError("POST", "reactions.add", { channel: C.general, timestamp: hello.ts, name: "one_too_many" }, 400, "too_many_reactions");
|
|
628
|
+
await post("reactions.add", { channel: C.general, timestamp: hello.ts, name: "conf_00" }, { status: 409 });
|
|
629
|
+
|
|
630
|
+
// Pins ---------------------------------------------------------------------------------------------
|
|
631
|
+
await post("pins.add", { channel: C.general, timestamp: hello.ts });
|
|
632
|
+
await apiError("POST", "pins.add", { channel: C.general, timestamp: hello.ts }, 409, "already_pinned");
|
|
633
|
+
await apiError("POST", "pins.add", { channel: C.general, timestamp: TS.unknown }, 404, "message_not_found");
|
|
634
|
+
const pins = (await get("pins.list", { channel: C.general })).json.items;
|
|
635
|
+
assert.deepEqual(pins.map((item) => item.message.ts), [hello.ts, TS.g1], "newest pin first");
|
|
636
|
+
assert.equal(pins[0].created_by, U.dana);
|
|
637
|
+
assert.equal(pins[0].message.channel, C.general);
|
|
638
|
+
assert.deepEqual(pins[0].message.pinned_to, [C.general]);
|
|
639
|
+
assert.ok(pins[1].message.permalink.endsWith("/p1787216400000101"));
|
|
640
|
+
assert.equal((await get("conversations.history", { channel: C.general })).json.pin_count, 2);
|
|
641
|
+
await post("pins.remove", { channel: C.general, timestamp: hello.ts });
|
|
642
|
+
await apiError("POST", "pins.remove", { channel: C.general, timestamp: hello.ts }, 404, "not_pinned");
|
|
643
|
+
assert.equal((await post("pins.list", { channel: C.general })).json.items.length, 1);
|
|
644
|
+
|
|
645
|
+
// Delete -----------------------------------------------------------------------------------------
|
|
646
|
+
await post("chat.delete", { channel: C.engineering, ts: broadcast.ts });
|
|
647
|
+
const shrunk = (await get("conversations.replies", { channel: C.engineering, ts: TS.e1 })).json.messages;
|
|
648
|
+
assert.equal(shrunk.length, 5);
|
|
649
|
+
assert.equal(shrunk[0].reply_count, 4);
|
|
650
|
+
assert.equal(shrunk[0].latest_reply, TS.r4, "parent counters recomputed");
|
|
651
|
+
await post("chat.delete", { channel: C.engineering, ts: TS.e3 }, {});
|
|
652
|
+
assert.ok(!tsList((await engineeringHistory({})).json.messages).includes(TS.e3), "admins can delete other members' messages");
|
|
653
|
+
await post("chat.delete", { channel: C.engineering, ts: TS.e1 });
|
|
654
|
+
const tombstone = (await engineeringHistory({})).json.messages.find((message) => message.ts === TS.e1);
|
|
655
|
+
assert.equal(tombstone.subtype, "tombstone");
|
|
656
|
+
assert.equal(tombstone.text, "This message was deleted.");
|
|
657
|
+
assert.equal(tombstone.reply_count, 4);
|
|
658
|
+
assert.equal((await get("conversations.replies", { channel: C.engineering, ts: TS.e1 })).json.messages.length, 5, "replies survive the tombstone");
|
|
659
|
+
await apiError("POST", "chat.delete", { channel: C.engineering, ts: TS.e1 }, 404, "message_not_found");
|
|
660
|
+
await apiError("POST", "chat.delete", { channel: C.general, ts: TS.unknown }, 404, "message_not_found");
|
|
661
|
+
await apiError("POST", "chat.postMessage", { channel: C.engineering, text: "x", thread_ts: TS.e1 }, 404, "thread_not_found", {});
|
|
662
|
+
|
|
663
|
+
// Archive -----------------------------------------------------------------------------------------
|
|
664
|
+
assert.deepEqual((await post("conversations.archive", { channel: created.id })).json, { ok: true });
|
|
665
|
+
await apiError("POST", "conversations.archive", { channel: created.id }, 400, "already_archived");
|
|
666
|
+
await apiError("POST", "conversations.archive", { channel: C.general }, 400, "method_not_supported_for_channel_type");
|
|
667
|
+
await apiError("POST", "conversations.archive", { channel: C.im }, 400, "method_not_supported_for_channel_type");
|
|
668
|
+
assert.equal((await get("conversations.info", { channel: created.id })).json.channel.is_archived, true);
|
|
669
|
+
assert.equal((await get("conversations.list", { exclude_archived: "true" })).json.channels.some((channel) => channel.id === created.id), false);
|
|
670
|
+
|
|
671
|
+
// Framework-owned edges rendered Slack-shaped or as framework errors ------------------------------
|
|
672
|
+
const multipart = await post("chat.postMessage", {}, { status: 400, contentType: "multipart/form-data; boundary=x", rawBody: "--x--" });
|
|
673
|
+
assert.equal(multipart.json.code, "framework.HTTP_REQUEST_MAPPING_FAILED", "unsupported body encodings are refused by the codec, not stored");
|
|
674
|
+
assert.equal((await api("POST", "chat.meMessage", { channel: C.general, text: "x" }, { status: 404 })).json.code, "framework.HTTP_ROUTE_NOT_FOUND");
|
|
675
|
+
assert.equal((await api("GET", "chat.postMessage", { channel: C.general, text: "x" }, { status: 405 })).json.code, "framework.HTTP_METHOD_NOT_ALLOWED");
|
|
676
|
+
const missingToken = await fetch(`${HTTP}/api/auth.test`);
|
|
677
|
+
assert.equal(missingToken.status, 401);
|
|
678
|
+
return { created: created.id, posted: hello.ts };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------------------------
|
|
682
|
+
// Drill: mcp-aliases (member, baseline)
|
|
683
|
+
// ---------------------------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
async function mcpAliases() {
|
|
686
|
+
await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "slack-conformance", version: "0.1.0" } });
|
|
687
|
+
const tools = (await rpc("tools/list", {})).tools.map((tool) => tool.name);
|
|
688
|
+
const aliases = ["slack_list_channels", "slack_post_message", "slack_reply_to_thread", "slack_add_reaction", "slack_get_channel_history", "slack_get_thread_replies", "slack_get_users", "slack_get_user_profile"];
|
|
689
|
+
for (const alias of aliases) assert.ok(tools.includes(alias), `alias ${alias} missing`);
|
|
690
|
+
for (const operationId of ALL_OPERATIONS) assert.ok(tools.includes(`slack.${operationId}`), `canonical slack.${operationId} missing`);
|
|
691
|
+
|
|
692
|
+
const users = await mcp("slack_get_users", { limit: 2 });
|
|
693
|
+
assert.equal(users.members.length, 2);
|
|
694
|
+
assert.ok(users.response_metadata.next_cursor);
|
|
695
|
+
const profile = await mcp("slack_get_user_profile", { user_id: U.mira });
|
|
696
|
+
assert.equal(profile.profile.status_emoji, ":palm_tree:");
|
|
697
|
+
assert.equal(profile.profile.real_name, "Mira Chen");
|
|
698
|
+
const first = await mcp("slack_list_channels", { limit: 3 });
|
|
699
|
+
assert.deepEqual(first.channels.map((channel) => channel.id), [C.design, C.engineering, C.general]);
|
|
700
|
+
const second = await mcp("slack_list_channels", { limit: 3, cursor: first.response_metadata.next_cursor });
|
|
701
|
+
assert.deepEqual(second.channels.map((channel) => channel.id), [C.launch, C.onboarding, C.random]);
|
|
702
|
+
assert.equal(second.response_metadata.next_cursor, "");
|
|
703
|
+
const history = await mcp("slack_get_channel_history", { channel_id: C.engineering, limit: 5 });
|
|
704
|
+
assert.equal(history.messages.length, 5);
|
|
705
|
+
assert.equal(history.has_more, true);
|
|
706
|
+
const thread = await mcp("slack_get_thread_replies", { channel_id: C.engineering, thread_ts: TS.e1 });
|
|
707
|
+
assert.equal(thread.messages.length, 5);
|
|
708
|
+
const posted = await mcp("slack_post_message", { channel_id: C.general, text: "Posted through the reference MCP alias" });
|
|
709
|
+
assert.equal(posted.ok, true);
|
|
710
|
+
assert.equal(posted.channel, C.general);
|
|
711
|
+
const reply = await mcp("slack_reply_to_thread", { channel_id: C.engineering, thread_ts: TS.e1, text: "Replying through the reference MCP alias" });
|
|
712
|
+
assert.equal(reply.message.thread_ts, TS.e1);
|
|
713
|
+
assert.equal((await mcp("slack_get_thread_replies", { channel_id: C.engineering, thread_ts: TS.e1 })).messages[0].reply_count, 5);
|
|
714
|
+
assert.deepEqual(await mcp("slack_add_reaction", { channel_id: C.general, timestamp: posted.ts, reaction: "white_check_mark" }), { ok: true });
|
|
715
|
+
|
|
716
|
+
await mcpError("slack_post_message", { channel_id: C.general, channel: C.general, text: "x" }, "tool.INVALID_ARGUMENTS");
|
|
717
|
+
await mcpError("slack_post_message", { channel_id: C.general }, "tool.NO_TEXT");
|
|
718
|
+
await mcpError("slack_add_reaction", { channel_id: C.general, timestamp: posted.ts, reaction: "white_check_mark" }, "tool.ALREADY_REACTED");
|
|
719
|
+
await mcpError("slack_get_thread_replies", { channel_id: C.engineering, thread_ts: TS.unknown }, "tool.THREAD_NOT_FOUND");
|
|
720
|
+
await mcpError("slack_reply_to_thread", { channel_id: C.engineering, thread_ts: TS.e1 });
|
|
721
|
+
await mcpError("slack_get_channel_history", { channel_id: C.leadership }, "tool.CHANNEL_NOT_FOUND");
|
|
722
|
+
|
|
723
|
+
const confirm = (await get("conversations.history", { channel: C.general, limit: "1" })).json.messages[0];
|
|
724
|
+
assert.equal(confirm.ts, posted.ts, "the MCP write is visible over HTTP");
|
|
725
|
+
assert.deepEqual(confirm.reactions, [{ name: "white_check_mark", users: [U.dana], count: 1 }]);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ---------------------------------------------------------------------------------------------
|
|
729
|
+
// Drill: visibility (contractor = Priya, baseline)
|
|
730
|
+
// ---------------------------------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
async function visibility() {
|
|
733
|
+
assert.equal((await get("auth.test")).json.user_id, U.priya);
|
|
734
|
+
const visible = (await get("conversations.list", { types: "public_channel,private_channel,mpim,im" })).json.channels;
|
|
735
|
+
assert.deepEqual(visible.map((channel) => channel.id), [C.design, C.engineering, C.general, C.launch, C.onboarding, C.random]);
|
|
736
|
+
assert.ok(visible.every((channel) => channel.is_private === false), "no private conversation leaks");
|
|
737
|
+
assert.deepEqual(visible.filter((channel) => channel.is_member).map((channel) => channel.id), [C.general, C.random]);
|
|
738
|
+
await apiError("GET", "conversations.info", { channel: C.incidents }, 404, "channel_not_found");
|
|
739
|
+
await apiError("GET", "conversations.info", { channel: C.im }, 404, "channel_not_found");
|
|
740
|
+
await apiError("GET", "conversations.history", { channel: C.engineering }, 403, "not_in_channel");
|
|
741
|
+
assert.equal((await get("search.messages", { query: "deploy" })).json.messages.total, 0, "search covers only the member's conversations");
|
|
742
|
+
await post("conversations.join", { channel: C.engineering });
|
|
743
|
+
assert.ok((await get("conversations.history", { channel: C.engineering })).json.messages.length > 0);
|
|
744
|
+
assert.equal((await get("search.messages", { query: "deploy" })).json.messages.total, 4);
|
|
745
|
+
await apiError("POST", "chat.update", { channel: C.general, ts: TS.g1, text: "hijack" }, 403, "cant_update_message");
|
|
746
|
+
await apiError("POST", "chat.delete", { channel: C.general, ts: TS.g1 }, 403, "cant_delete_message");
|
|
747
|
+
await apiError("POST", "conversations.archive", { channel: C.general }, 400, "method_not_supported_for_channel_type");
|
|
748
|
+
const im = (await post("conversations.open", { users: U.dana, return_im: "true" })).json;
|
|
749
|
+
assert.equal(im.already_open, false, "Priya has no IM with Dana yet");
|
|
750
|
+
assert.equal(im.channel.user, U.dana);
|
|
751
|
+
const posted = (await post("chat.postMessage", { channel: im.channel.id, text: "Hi Dana, quick question about the contract." })).json;
|
|
752
|
+
assert.equal((await get("conversations.history", { channel: im.channel.id })).json.messages[0].ts, posted.ts);
|
|
753
|
+
assert.equal((await get("conversations.history", { channel: C.general })).json.messages.find((message) => message.ts === TS.g1).text.startsWith("Welcome to Example Team!"), true);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ---------------------------------------------------------------------------------------------
|
|
757
|
+
// Drill: denied (auditor, baseline)
|
|
758
|
+
// ---------------------------------------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
async function denied() {
|
|
761
|
+
const auth = await get("auth.test", {}, { status: 403 });
|
|
762
|
+
assert.deepEqual(auth.json, { ok: false, error: "missing_scope", needed: "slack.auth.test", provided: "" });
|
|
763
|
+
const posted = await post("chat.postMessage", { channel: C.general, text: "nope" }, { status: 403 });
|
|
764
|
+
assert.equal(posted.json.error, "missing_scope");
|
|
765
|
+
await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "slack-conformance", version: "0.1.0" } });
|
|
766
|
+
const result = await rpc("tools/call", { name: "slack_list_channels", arguments: {} });
|
|
767
|
+
assert.ok(result.isError);
|
|
768
|
+
assert.equal(result.structuredContent.status, "denied");
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ---------------------------------------------------------------------------------------------
|
|
772
|
+
// Drill: invalid-auth (ghost, baseline)
|
|
773
|
+
// ---------------------------------------------------------------------------------------------
|
|
774
|
+
|
|
775
|
+
async function invalidAuth() {
|
|
776
|
+
await apiError("GET", "auth.test", {}, 401, "invalid_auth");
|
|
777
|
+
await apiError("POST", "conversations.list", {}, 401, "invalid_auth");
|
|
778
|
+
const minimal = {
|
|
779
|
+
"users.list": {},
|
|
780
|
+
"users.info": { user: U.dana },
|
|
781
|
+
"users.profile.get": {},
|
|
782
|
+
"conversations.info": { channel: C.general },
|
|
783
|
+
"conversations.members": { channel: C.general },
|
|
784
|
+
"conversations.create": { name: "ghost-channel" },
|
|
785
|
+
"conversations.join": { channel: C.design },
|
|
786
|
+
"conversations.invite": { channel: C.general, users: U.sam },
|
|
787
|
+
"conversations.open": { users: U.sam },
|
|
788
|
+
"conversations.archive": { channel: C.random },
|
|
789
|
+
"conversations.set-topic": { channel: C.general, topic: "x" },
|
|
790
|
+
"conversations.history": { channel: C.general },
|
|
791
|
+
"conversations.replies": { channel: C.general, ts: TS.g1 },
|
|
792
|
+
"chat.post-message": { channel: C.general, text: "x" },
|
|
793
|
+
"chat.reply": { channel: C.general, thread_ts: TS.g1, text: "x" },
|
|
794
|
+
"chat.update": { channel: C.general, ts: TS.g1, text: "x" },
|
|
795
|
+
"chat.delete": { channel: C.general, ts: TS.g1 },
|
|
796
|
+
"reactions.add": { channel: C.general, timestamp: TS.g1, name: "eyes" },
|
|
797
|
+
"reactions.remove": { channel: C.general, timestamp: TS.g1, name: "eyes" },
|
|
798
|
+
"pins.add": { channel: C.general, timestamp: TS.g1 },
|
|
799
|
+
"pins.remove": { channel: C.general, timestamp: TS.g1 },
|
|
800
|
+
"pins.list": { channel: C.general },
|
|
801
|
+
"search.messages": { query: "deploy" },
|
|
802
|
+
};
|
|
803
|
+
for (const operationId of ALL_OPERATIONS) {
|
|
804
|
+
if (operationId === "auth.test" || operationId === "conversations.list") continue;
|
|
805
|
+
assert.ok(operationId in minimal, `no minimal arguments for ${operationId}`);
|
|
806
|
+
await op(operationId, minimal[operationId], "INVALID_AUTH");
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ---------------------------------------------------------------------------------------------
|
|
811
|
+
// Drill: post-rate-limited (member, scenario post-rate-limited)
|
|
812
|
+
// ---------------------------------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
async function postRateLimited() {
|
|
815
|
+
const before = (await get("conversations.history", { channel: C.general })).json.messages.length;
|
|
816
|
+
const limited = await apiError("POST", "chat.postMessage", { channel: C.general, text: "x" }, 429, "ratelimited");
|
|
817
|
+
assert.equal(limited.headers.get("retry-after"), "30");
|
|
818
|
+
await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "slack-conformance", version: "0.1.0" } });
|
|
819
|
+
await mcpError("slack_reply_to_thread", { channel_id: C.engineering, thread_ts: TS.e1, text: "x" }, "tool.RATELIMITED");
|
|
820
|
+
const update = await apiError("POST", "chat.update", { channel: C.engineering, ts: TS.e2, text: "x" }, 429, "ratelimited");
|
|
821
|
+
assert.equal(update.headers.get("retry-after"), "30");
|
|
822
|
+
assert.equal((await get("conversations.history", { channel: C.general })).json.messages.length, before, "no message was stored");
|
|
823
|
+
assert.deepEqual((await post("reactions.add", { channel: C.general, timestamp: TS.g1, name: "eyes" })).json, { ok: true }, "the fault is scoped to posting");
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ---------------------------------------------------------------------------------------------
|
|
827
|
+
// Drill: history-unavailable (member, scenario history-unavailable)
|
|
828
|
+
// ---------------------------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
async function historyUnavailable() {
|
|
831
|
+
await apiError("GET", "conversations.history", { channel: C.general }, 503, "service_unavailable");
|
|
832
|
+
await apiError("GET", "search.messages", { query: "deploy" }, 503, "service_unavailable");
|
|
833
|
+
assert.equal((await get("conversations.replies", { channel: C.engineering, ts: TS.e1 })).json.messages.length, 5, "replies keep working");
|
|
834
|
+
assert.equal((await get("conversations.info", { channel: C.general })).json.channel.id, C.general);
|
|
835
|
+
assert.equal((await get("pins.list", { channel: C.general })).json.items.length, 1);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ---------------------------------------------------------------------------------------------
|
|
839
|
+
// Drill: fresh-install (newcomer: no identity attributes, every grant; baseline)
|
|
840
|
+
// ---------------------------------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
async function freshInstall() {
|
|
843
|
+
// Without a userId attribute the actor acts as the first active human member in users row-id order (Dana).
|
|
844
|
+
const who = (await get("auth.test")).json;
|
|
845
|
+
assert.equal(who.user_id, U.dana);
|
|
846
|
+
assert.equal(who.user, "dana.reyes");
|
|
847
|
+
assert.equal((await get("users.profile.get")).json.profile.real_name, "Dana Reyes", "the default member's own profile");
|
|
848
|
+
assert.deepEqual((await get("conversations.list")).json.channels.map((channel) => channel.id), [C.design, C.engineering, C.general, C.launch, C.onboarding, C.random]);
|
|
849
|
+
assert.equal((await get("conversations.history", { channel: C.incidents })).json.messages.length, 3, "private membership follows the resolved member (3 top-level messages; the two thread replies are not)");
|
|
850
|
+
assert.equal((await get("users.list")).json.members.length, 6);
|
|
851
|
+
const posted = (await post("chat.postMessage", { channel: C.general, text: "Posted with no actor attributes" })).json;
|
|
852
|
+
assert.equal(posted.message.user, U.dana);
|
|
853
|
+
assert.equal(posted.ts, "1789376400.000001", "no message_second yet: the first allocation restarts the sequence");
|
|
854
|
+
await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "slack-conformance", version: "0.1.0" } });
|
|
855
|
+
assert.equal((await mcp("slack_get_users", { limit: 1 })).members[0].id, U.dana);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ---------------------------------------------------------------------------------------------
|
|
859
|
+
// Drill: bounds (member, scenario bounds)
|
|
860
|
+
// ---------------------------------------------------------------------------------------------
|
|
861
|
+
|
|
862
|
+
async function bounds() {
|
|
863
|
+
// workspace.limits { users: 5, conversations: 8, members: 1, messages: 2, pins: 1 } sit below the authored rows
|
|
864
|
+
// (6 users, 10 conversations, 2–5 members per conversation, 4 messages in #general including the scenario's thread with
|
|
865
|
+
// 50 distinct repliers, 15 in #engineering, and two pins in #general with the scenario's extra pin). Every bounded read must refuse explicitly and store nothing.
|
|
866
|
+
const tooMany = (verb, method, args) => apiError(verb, method, args, 400, "too_many_rows");
|
|
867
|
+
const users = await tooMany("GET", "users.list", {});
|
|
868
|
+
assert.match(users.json.response_metadata.messages[0], /^users exceed the supported bound of 5 rows/);
|
|
869
|
+
await tooMany("GET", "conversations.list", {});
|
|
870
|
+
await tooMany("GET", "conversations.info", { channel: C.general, include_num_members: "true" });
|
|
871
|
+
await tooMany("GET", "conversations.members", { channel: C.general });
|
|
872
|
+
await tooMany("POST", "conversations.create", { name: "bounds-test" });
|
|
873
|
+
await tooMany("POST", "conversations.join", { channel: C.general });
|
|
874
|
+
await tooMany("POST", "conversations.invite", { channel: C.engineering, users: U.priya });
|
|
875
|
+
await tooMany("POST", "conversations.open", { users: U.sam });
|
|
876
|
+
await tooMany("POST", "conversations.setTopic", { channel: C.im, topic: "x" });
|
|
877
|
+
const history = await tooMany("GET", "conversations.history", { channel: C.general });
|
|
878
|
+
assert.match(history.json.response_metadata.messages[0], /^messages in C01GENERAL1 exceed the supported bound of 2 rows/);
|
|
879
|
+
await tooMany("GET", "conversations.replies", { channel: C.engineering, ts: TS.e1 });
|
|
880
|
+
await tooMany("POST", "chat.delete", { channel: C.engineering, ts: TS.r1 });
|
|
881
|
+
// A reply to a thread whose reply_users list is already full looks for an earlier reply by the caller before any ts is
|
|
882
|
+
// allocated; with #general over its message bound that lookup refuses, naming only the existing channel.
|
|
883
|
+
const fullThread = "1789290000.000199";
|
|
884
|
+
const reply = await tooMany("POST", "chat.postMessage", { channel: C.general, thread_ts: fullThread, text: "one more" });
|
|
885
|
+
assert.match(reply.json.response_metadata.messages[0], /^messages in C01GENERAL1 exceed the supported bound of 2 rows/);
|
|
886
|
+
assert.equal(/\d{10}\.\d{6}/.test(reply.json.response_metadata.messages[0]), false, "the refusal names no ts");
|
|
887
|
+
await op("chat.reply", { channel: C.general, thread_ts: fullThread, text: "one more" }, "TOO_MANY_ROWS");
|
|
888
|
+
const pin = await tooMany("POST", "pins.add", { channel: C.general, timestamp: "1789036200.000103" });
|
|
889
|
+
assert.match(pin.json.response_metadata.messages[0], /^pins in C01GENERAL1 exceed the supported bound of 1 rows/);
|
|
890
|
+
await tooMany("GET", "pins.list", { channel: C.general });
|
|
891
|
+
await tooMany("GET", "search.messages", { query: "deploy" });
|
|
892
|
+
await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "slack-conformance", version: "0.1.0" } });
|
|
893
|
+
await mcpError("slack_get_channel_history", { channel_id: C.general }, "tool.TOO_MANY_ROWS");
|
|
894
|
+
|
|
895
|
+
// Reads and writes by id never scan and keep working.
|
|
896
|
+
assert.equal((await get("auth.test")).json.user_id, U.dana);
|
|
897
|
+
assert.equal((await get("users.info", { user: U.mira })).json.user.tz, "Asia/Singapore");
|
|
898
|
+
assert.equal((await get("conversations.info", { channel: C.general })).json.channel.is_member, true, "no member count requested, no membership scan");
|
|
899
|
+
const posted = (await post("chat.postMessage", { channel: C.general, text: "Bounded, but posting by id works" })).json;
|
|
900
|
+
assert.equal(posted.message.user, U.dana);
|
|
901
|
+
assert.equal((await post("chat.update", { channel: C.general, ts: posted.ts, text: "edited" })).json.text, "edited");
|
|
902
|
+
assert.deepEqual((await post("reactions.add", { channel: C.general, timestamp: posted.ts, name: "eyes" })).json, { ok: true });
|
|
903
|
+
assert.deepEqual((await post("pins.remove", { channel: C.general, timestamp: TS.g1 })).json, { ok: true });
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ---------------------------------------------------------------------------------------------
|
|
907
|
+
// Drill: open-member-bound (member, scenario open-member-bound)
|
|
908
|
+
// ---------------------------------------------------------------------------------------------
|
|
909
|
+
|
|
910
|
+
async function openMemberBound() {
|
|
911
|
+
// workspace.limits.members is 2. A three-person group DM exceeds it: the refusal happens before an id is allocated,
|
|
912
|
+
// so the error names the count and the limit only, and the ids the open would have used stay free.
|
|
913
|
+
const conversationIdPattern = /\b[CD][0-9A-Z]{10}\b/;
|
|
914
|
+
const refused = await apiError("POST", "conversations.open", { users: `${U.priya},${U.mira}`, return_im: "true" }, 400, "too_many_rows");
|
|
915
|
+
const message = refused.json.response_metadata.messages[0];
|
|
916
|
+
assert.equal(message, "members of the requested conversation (3) exceed the supported bound of 2 rows (workspace.limits.members)");
|
|
917
|
+
assert.doesNotMatch(JSON.stringify(refused.json), conversationIdPattern, "the error body names no conversation id");
|
|
918
|
+
const refusedPlain = await apiError("POST", "conversations.open", { users: `${U.priya},${U.mira}` }, 400, "too_many_rows");
|
|
919
|
+
assert.doesNotMatch(JSON.stringify(refusedPlain.json), conversationIdPattern, "without return_im the bound holds too");
|
|
920
|
+
|
|
921
|
+
// Nothing was stored: the id the open would have allocated does not exist and Dana's group DMs are unchanged.
|
|
922
|
+
await apiError("GET", "conversations.info", { channel: "C000000001F" }, 404, "channel_not_found");
|
|
923
|
+
assert.deepEqual((await get("conversations.list", { types: "mpim" })).json.channels.map((channel) => channel.id), [C.mpdm]);
|
|
924
|
+
|
|
925
|
+
// Within the bound, opening works and takes the first free id; its view is built from the rows just written.
|
|
926
|
+
const im = (await post("conversations.open", { users: U.priya, return_im: "true" })).json;
|
|
927
|
+
assert.equal(im.already_open, false);
|
|
928
|
+
assert.equal(im.channel.id, "D000000001F", "the refused opens consumed no id");
|
|
929
|
+
assert.equal(im.channel.is_im, true);
|
|
930
|
+
assert.equal(im.channel.user, U.priya);
|
|
931
|
+
assert.equal(im.channel.is_member, true);
|
|
932
|
+
assert.equal(im.channel.num_members, 2);
|
|
933
|
+
assert.deepEqual((await get("conversations.members", { channel: im.channel.id })).json.members, [U.dana, U.priya]);
|
|
934
|
+
|
|
935
|
+
// A later conversations.create succeeds with the next id, which never appeared in an error.
|
|
936
|
+
const created = (await post("conversations.create", { name: "after-refused-open" })).json.channel;
|
|
937
|
+
assert.equal(created.id, "C000000001G");
|
|
938
|
+
assert.equal(created.num_members, 1);
|
|
939
|
+
assert.equal(created.is_member, true);
|
|
940
|
+
const info = (await get("conversations.info", { channel: created.id, include_num_members: "true" })).json.channel;
|
|
941
|
+
assert.equal(info.name, "after-refused-open");
|
|
942
|
+
assert.equal(info.num_members, 1);
|
|
943
|
+
for (const id of [im.channel.id, created.id]) {
|
|
944
|
+
assert.ok(!JSON.stringify(refused.json).includes(id) && !JSON.stringify(refusedPlain.json).includes(id), `${id} appeared in an error`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ---------------------------------------------------------------------------------------------
|
|
949
|
+
// Drill: open-long-handles (member, scenario open-long-handles)
|
|
950
|
+
// ---------------------------------------------------------------------------------------------
|
|
951
|
+
|
|
952
|
+
async function openLongHandles() {
|
|
953
|
+
// Eight extra members have 21-character handles. A group DM's plain name `mpdm-<handles joined by -->-1` would pass the
|
|
954
|
+
// 80-character conversation name for five or more such members; those names keep a prefix and end in a digest.
|
|
955
|
+
const L = [1, 2, 3, 4, 5, 6, 7, 8].map((index) => ({ id: `U01LONG000${index}`, handle: `longhandle${index}xxxxxxxxxx` }));
|
|
956
|
+
const ids = (members) => members.map((member) => member.id).join(",");
|
|
957
|
+
const NAME = /^[a-z0-9._-]{1,80}$/;
|
|
958
|
+
|
|
959
|
+
const short = (await post("conversations.open", { users: ids(L.slice(0, 2)), return_im: "true" })).json.channel;
|
|
960
|
+
assert.equal(short.name, `mpdm-dana.reyes--${L[0].handle}--${L[1].handle}-1`, "names that fit keep the plain form");
|
|
961
|
+
|
|
962
|
+
const five = (await post("conversations.open", { users: ids(L.slice(0, 4)), return_im: "true" })).json;
|
|
963
|
+
assert.equal(five.already_open, false);
|
|
964
|
+
assert.equal(five.channel.is_mpim, true);
|
|
965
|
+
assert.equal(five.channel.num_members, 5);
|
|
966
|
+
assert.equal(five.channel.name, "mpdm-dana.reyes--longhandle1xxxxxxxxxx--longhandle2xxxxxxxxxx--longha-9c7e80ed-1");
|
|
967
|
+
assert.equal(five.channel.name_normalized, five.channel.name);
|
|
968
|
+
|
|
969
|
+
const nine = (await post("conversations.open", { users: ids(L), return_im: "true" })).json.channel;
|
|
970
|
+
assert.equal(nine.num_members, 9);
|
|
971
|
+
assert.equal(nine.name, "mpdm-dana.reyes--longhandle1xxxxxxxxxx--longhandle2xxxxxxxxxx--longha-98de6b61-1");
|
|
972
|
+
const four = (await post("conversations.open", { users: ids(L.slice(0, 3)) })).json.channel;
|
|
973
|
+
|
|
974
|
+
for (const channel of [short, five.channel, nine]) {
|
|
975
|
+
assert.ok(channel.name.length <= 80, `${channel.name} is longer than 80 characters`);
|
|
976
|
+
assert.match(channel.name, NAME);
|
|
977
|
+
}
|
|
978
|
+
const names = new Set([short.name, five.channel.name, nine.name, (await get("conversations.info", { channel: four.id })).json.channel.name]);
|
|
979
|
+
assert.equal(names.size, 4, "different member sets keep distinct names even when the prefix is shared");
|
|
980
|
+
|
|
981
|
+
// The group DMs were stored: reopening finds them (in any order), info and members read them back.
|
|
982
|
+
assert.equal((await post("conversations.open", { users: ids([...L].reverse()) })).json.channel.id, nine.id);
|
|
983
|
+
assert.equal((await post("conversations.open", { users: ids(L.slice(0, 4)) })).json.already_open, true);
|
|
984
|
+
const info = (await get("conversations.info", { channel: nine.id, include_num_members: "true" })).json.channel;
|
|
985
|
+
assert.equal(info.name, nine.name);
|
|
986
|
+
assert.equal(info.num_members, 9);
|
|
987
|
+
assert.equal((await get("conversations.members", { channel: nine.id })).json.members.length, 9);
|
|
988
|
+
const mpims = (await get("conversations.list", { types: "mpim" })).json.channels.map((channel) => channel.id);
|
|
989
|
+
for (const id of [short.id, five.channel.id, nine.id, four.id]) assert.ok(mpims.includes(id), `${id} missing from mpim list`);
|
|
990
|
+
const message = (await post("chat.postMessage", { channel: nine.id, text: "Hello, long handles" })).json;
|
|
991
|
+
assert.equal(message.channel, nine.id);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ---------------------------------------------------------------------------------------------
|
|
995
|
+
// Drill: thread-many-repliers (member, scenario thread-many-repliers)
|
|
996
|
+
// ---------------------------------------------------------------------------------------------
|
|
997
|
+
|
|
998
|
+
async function threadManyRepliers() {
|
|
999
|
+
// The scenario seeds a #general parent whose reply_users already lists 50 distinct repliers (the stored bound).
|
|
1000
|
+
const parentTs = "1789290000.000199";
|
|
1001
|
+
const first = (await post("chat.postMessage", { channel: C.general, thread_ts: parentTs, text: "51st participant here" })).json;
|
|
1002
|
+
assert.equal(first.ok, true);
|
|
1003
|
+
assert.equal(first.message.thread_ts, parentTs);
|
|
1004
|
+
let parent = (await get("conversations.replies", { channel: C.general, ts: parentTs })).json.messages[0];
|
|
1005
|
+
assert.equal(parent.reply_count, 51);
|
|
1006
|
+
assert.equal(parent.reply_users_count, 51);
|
|
1007
|
+
assert.equal(parent.reply_users.length, 50);
|
|
1008
|
+
assert.equal(parent.reply_users.includes(U.dana), false, "reply_users keeps the first 50 repliers");
|
|
1009
|
+
assert.equal(parent.latest_reply, first.ts);
|
|
1010
|
+
// The same participant replying again is not counted twice even though reply_users does not list them.
|
|
1011
|
+
const second = (await post("chat.postMessage", { channel: C.general, thread_ts: parentTs, text: "and a follow-up" })).json;
|
|
1012
|
+
parent = (await get("conversations.replies", { channel: C.general, ts: parentTs })).json.messages[0];
|
|
1013
|
+
assert.equal(parent.reply_count, 52);
|
|
1014
|
+
assert.equal(parent.reply_users_count, 51);
|
|
1015
|
+
assert.equal(parent.reply_users.length, 50);
|
|
1016
|
+
assert.equal(parent.latest_reply, second.ts);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// ---------------------------------------------------------------------------------------------
|
|
1020
|
+
// Drill: response-budget (member, scenario response-budget)
|
|
1021
|
+
// ---------------------------------------------------------------------------------------------
|
|
1022
|
+
|
|
1023
|
+
async function responseBudget() {
|
|
1024
|
+
// workspace.limits.response_bytes is 4,000: every list and read sizes its page by the UTF-8 bytes of the encoded body.
|
|
1025
|
+
const BUDGET = 4000;
|
|
1026
|
+
const call = async (verb, method, args) => {
|
|
1027
|
+
const query = new URLSearchParams(args).toString();
|
|
1028
|
+
const response = await fetch(verb === "GET" ? `${HTTP}/api/${method}?${query}` : `${HTTP}/api/${method}`, {
|
|
1029
|
+
method: verb,
|
|
1030
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, ...(verb === "GET" ? {} : { "content-type": "application/x-www-form-urlencoded" }) },
|
|
1031
|
+
...(verb === "GET" ? {} : { body: query }),
|
|
1032
|
+
});
|
|
1033
|
+
const text = await response.text();
|
|
1034
|
+
return { status: response.status, bytes: Buffer.byteLength(text), json: JSON.parse(text) };
|
|
1035
|
+
};
|
|
1036
|
+
const pages = async (method, args, listOf) => {
|
|
1037
|
+
const items = [];
|
|
1038
|
+
let cursor = "";
|
|
1039
|
+
let count = 0;
|
|
1040
|
+
do {
|
|
1041
|
+
const result = await call("GET", method, { ...args, ...(cursor ? { cursor } : {}) });
|
|
1042
|
+
assert.equal(result.status, 200, `${method}: ${JSON.stringify(result.json).slice(0, 300)}`);
|
|
1043
|
+
assert.ok(result.bytes <= BUDGET, `${method} page of ${result.bytes} bytes exceeds ${BUDGET}`);
|
|
1044
|
+
items.push(...listOf(result.json));
|
|
1045
|
+
cursor = result.json.response_metadata.next_cursor;
|
|
1046
|
+
count += 1;
|
|
1047
|
+
assert.ok(count < 100, `${method} never finished paging`);
|
|
1048
|
+
} while (cursor);
|
|
1049
|
+
return { items, count };
|
|
1050
|
+
};
|
|
1051
|
+
const users = await pages("users.list", { limit: "100" }, (json) => json.members.map((member) => member.id));
|
|
1052
|
+
assert.deepEqual(users.items, Object.values(U).sort(), "every user exactly once, in id order");
|
|
1053
|
+
assert.ok(users.count >= 1);
|
|
1054
|
+
const channels = await pages("conversations.list", { types: "public_channel,private_channel,mpim,im", limit: "100" }, (json) => json.channels.map((channel) => channel.id));
|
|
1055
|
+
assert.equal(new Set(channels.items).size, channels.items.length);
|
|
1056
|
+
assert.ok(channels.items.includes(C.general) && channels.items.includes(C.im));
|
|
1057
|
+
assert.deepEqual(channels.items, [...channels.items].sort(), "conversations in id order across pages");
|
|
1058
|
+
|
|
1059
|
+
// Messages with multi-byte text (ü is 2 bytes, 1 UTF-16 unit): pages hold fewer messages, none is skipped or repeated.
|
|
1060
|
+
const wide = "ü".repeat(600);
|
|
1061
|
+
const posted = [];
|
|
1062
|
+
for (let index = 0; index < 5; index += 1) posted.push((await post("chat.postMessage", { channel: C.general, text: `budgetprobe ${index} ${wide}` })).json.ts);
|
|
1063
|
+
const history = await pages("conversations.history", { channel: C.general, limit: "100" }, (json) => json.messages.map((message) => message.ts));
|
|
1064
|
+
assert.equal(new Set(history.items).size, history.items.length, "no message repeats across pages");
|
|
1065
|
+
for (const ts of posted) assert.ok(history.items.includes(ts), `${ts} missing from history`);
|
|
1066
|
+
assert.deepEqual(history.items, [...history.items].sort().reverse(), "newest first across pages");
|
|
1067
|
+
assert.ok(history.count >= 3, `history took ${history.count} pages`);
|
|
1068
|
+
const replies = [];
|
|
1069
|
+
for (let index = 0; index < 4; index += 1) replies.push((await post("chat.postMessage", { channel: C.general, thread_ts: posted[0], text: `budgetprobe reply ${index} ${wide}` })).json.ts);
|
|
1070
|
+
const thread = await pages("conversations.replies", { channel: C.general, ts: posted[0], limit: "100" }, (json) => json.messages.map((message) => message.ts));
|
|
1071
|
+
assert.deepEqual(thread.items, [posted[0], ...replies], "parent once, then every reply once");
|
|
1072
|
+
|
|
1073
|
+
// search.messages keeps page arithmetic uniform: per_page drops until every page fits.
|
|
1074
|
+
const found = [];
|
|
1075
|
+
let pageCount = 1;
|
|
1076
|
+
for (let page = 1; page <= pageCount; page += 1) {
|
|
1077
|
+
const result = await call("GET", "search.messages", { query: "budgetprobe", count: "100", page: String(page) });
|
|
1078
|
+
assert.equal(result.status, 200);
|
|
1079
|
+
assert.ok(result.bytes <= BUDGET, `search page ${page} is ${result.bytes} bytes`);
|
|
1080
|
+
assert.ok(result.json.messages.paging.count < 100 && result.json.messages.pagination.per_page === result.json.messages.paging.count);
|
|
1081
|
+
found.push(...result.json.messages.matches.map((match) => match.ts));
|
|
1082
|
+
pageCount = result.json.messages.paging.pages;
|
|
1083
|
+
}
|
|
1084
|
+
assert.deepEqual([...found].sort(), [...posted, ...replies].sort(), "every match exactly once");
|
|
1085
|
+
|
|
1086
|
+
// One message over the budget answers response_too_large, never an oversized or empty page.
|
|
1087
|
+
const huge = (await post("chat.postMessage", { channel: C.general, text: `budgethuge ${"ü".repeat(2000)}` })).json.ts;
|
|
1088
|
+
const tooLarge = async (method, args) => {
|
|
1089
|
+
const result = await call("GET", method, args);
|
|
1090
|
+
assert.equal(result.status, 400, `${method}: ${JSON.stringify(result.json).slice(0, 300)}`);
|
|
1091
|
+
assert.equal(result.json.error, "response_too_large");
|
|
1092
|
+
assert.match(result.json.response_metadata.messages[0], /response budget of 4000 bytes/);
|
|
1093
|
+
};
|
|
1094
|
+
await tooLarge("conversations.history", { channel: C.general });
|
|
1095
|
+
await tooLarge("search.messages", { query: "budgethuge" });
|
|
1096
|
+
assert.deepEqual((await post("pins.add", { channel: C.general, timestamp: huge })).json, { ok: true });
|
|
1097
|
+
await tooLarge("pins.list", { channel: C.general });
|
|
1098
|
+
await post("chat.postMessage", { channel: C.general, thread_ts: posted[0], text: `budgethuge reply ${"ü".repeat(2000)}` });
|
|
1099
|
+
let cursor = "";
|
|
1100
|
+
for (let step = 0; ; step += 1) {
|
|
1101
|
+
assert.ok(step < 20, "replies never reached the oversized reply");
|
|
1102
|
+
const result = await call("GET", "conversations.replies", { channel: C.general, ts: posted[0], ...(cursor ? { cursor } : {}) });
|
|
1103
|
+
if (result.status === 400) {
|
|
1104
|
+
assert.equal(result.json.error, "response_too_large");
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
assert.equal(result.status, 200);
|
|
1108
|
+
assert.equal(result.json.has_more, true, "the oversized reply is still ahead");
|
|
1109
|
+
cursor = result.json.response_metadata.next_cursor;
|
|
1110
|
+
}
|
|
1111
|
+
// Older history stays reachable past the oversized message with the ts window.
|
|
1112
|
+
const older = await call("GET", "conversations.history", { channel: C.general, latest: huge, limit: "1" });
|
|
1113
|
+
assert.equal(older.status, 200);
|
|
1114
|
+
assert.equal(older.json.messages[0].ts, posted[4]);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ---------------------------------------------------------------------------------------------
|
|
1118
|
+
// Drill: ts-sequence-full (member, scenario ts-sequence-full)
|
|
1119
|
+
// ---------------------------------------------------------------------------------------------
|
|
1120
|
+
|
|
1121
|
+
async function tsSequenceFull() {
|
|
1122
|
+
// workspace.message_second is the frozen virtual second (1789376400) with message_sequence 999997, and an authored
|
|
1123
|
+
// message already occupies 1789376400.999998 in #incidents. The first post skips the occupied fraction and takes the
|
|
1124
|
+
// last one, .999999; every further allocation in this second refuses with too_many_rows before anything is written.
|
|
1125
|
+
const first = (await post("chat.postMessage", { channel: C.incidents, text: "the last ts of this second" })).json;
|
|
1126
|
+
assert.equal(first.ts, "1789376400.999999");
|
|
1127
|
+
const full = await apiError("POST", "chat.postMessage", { channel: C.incidents, text: "one too many" }, 400, "too_many_rows");
|
|
1128
|
+
assert.match(full.json.response_metadata.messages[0], /^message ts limit reached: virtual second 1789376400 already holds 999999 message timestamps/);
|
|
1129
|
+
assert.equal(/\d{10}\.\d{6}/.test(full.json.response_metadata.messages[0]), false, "the refusal names no ts");
|
|
1130
|
+
await apiError("POST", "chat.postMessage", { channel: C.incidents, text: "reply", thread_ts: first.ts }, 400, "too_many_rows");
|
|
1131
|
+
await op("chat.reply", { channel: C.incidents, thread_ts: first.ts, text: "reply" }, "TOO_MANY_ROWS");
|
|
1132
|
+
await apiError("POST", "conversations.join", { channel: C.design }, 400, "too_many_rows");
|
|
1133
|
+
await apiError("POST", "conversations.invite", { channel: C.incidents, users: U.priya }, 400, "too_many_rows");
|
|
1134
|
+
// Nothing was written: no reply, no join, no membership.
|
|
1135
|
+
const history = (await get("conversations.history", { channel: C.incidents, limit: "2" })).json.messages;
|
|
1136
|
+
assert.deepEqual(history.map((message) => message.ts), ["1789376400.999999", "1789376400.999998"]);
|
|
1137
|
+
assert.ok(!("reply_count" in history[0]));
|
|
1138
|
+
assert.equal((await get("conversations.info", { channel: C.design })).json.channel.is_member, false);
|
|
1139
|
+
assert.deepEqual((await get("conversations.members", { channel: C.incidents })).json.members.sort(), [U.dana, U.opsbot, U.sam].sort());
|
|
1140
|
+
// Calls that allocate no ts keep working.
|
|
1141
|
+
assert.equal((await post("chat.update", { channel: C.incidents, ts: first.ts, text: "edited" })).json.text, "edited");
|
|
1142
|
+
assert.deepEqual((await post("reactions.add", { channel: C.incidents, timestamp: first.ts, name: "eyes" })).json, { ok: true });
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// ---------------------------------------------------------------------------------------------
|
|
1146
|
+
|
|
1147
|
+
const flows = {
|
|
1148
|
+
"web-api": webApi,
|
|
1149
|
+
"mcp-aliases": mcpAliases,
|
|
1150
|
+
visibility,
|
|
1151
|
+
denied,
|
|
1152
|
+
"invalid-auth": invalidAuth,
|
|
1153
|
+
"post-rate-limited": postRateLimited,
|
|
1154
|
+
"history-unavailable": historyUnavailable,
|
|
1155
|
+
"fresh-install": freshInstall,
|
|
1156
|
+
bounds,
|
|
1157
|
+
"open-member-bound": openMemberBound,
|
|
1158
|
+
"open-long-handles": openLongHandles,
|
|
1159
|
+
"thread-many-repliers": threadManyRepliers,
|
|
1160
|
+
"response-budget": responseBudget,
|
|
1161
|
+
"ts-sequence-full": tsSequenceFull,
|
|
1162
|
+
};
|
|
1163
|
+
const selected = Object.keys(flows).find((name) => instruction.includes(name));
|
|
1164
|
+
if (selected === undefined) throw new Error(`Unknown drill instruction: ${instruction}`);
|
|
1165
|
+
const result = await flows[selected]();
|
|
1166
|
+
process.stdout.write(JSON.stringify({ completed: true, flow: selected, ...(result ?? {}) }));
|