@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.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +369 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/bounds.scenario.json +112 -0
  6. package/firedrill/conformance.suite.json +21 -0
  7. package/firedrill/history-unavailable.scenario.json +11 -0
  8. package/firedrill/open-long-handles.scenario.json +263 -0
  9. package/firedrill/open-member-bound.scenario.json +24 -0
  10. package/firedrill/post-rate-limited.scenario.json +11 -0
  11. package/firedrill/response-budget.scenario.json +24 -0
  12. package/firedrill/slack-bounds.drill.json +428 -0
  13. package/firedrill/slack-denied.drill.json +74 -0
  14. package/firedrill/slack-fresh-install.drill.json +132 -0
  15. package/firedrill/slack-history-unavailable.drill.json +83 -0
  16. package/firedrill/slack-invalid-auth.drill.json +408 -0
  17. package/firedrill/slack-mcp-aliases.drill.json +219 -0
  18. package/firedrill/slack-open-long-handles.drill.json +82 -0
  19. package/firedrill/slack-open-member-bound.drill.json +135 -0
  20. package/firedrill/slack-post-rate-limited.drill.json +96 -0
  21. package/firedrill/slack-response-budget.drill.json +118 -0
  22. package/firedrill/slack-thread-many-repliers.drill.json +100 -0
  23. package/firedrill/slack-ts-sequence-full.drill.json +165 -0
  24. package/firedrill/slack-visibility.drill.json +110 -0
  25. package/firedrill/slack-web-api-flow.drill.json +896 -0
  26. package/firedrill/thread-many-repliers.scenario.json +79 -0
  27. package/firedrill/tools/slack/app/assets/ATTRIBUTION.md +40 -0
  28. package/firedrill/tools/slack/app/assets/fonts/OFL.txt +93 -0
  29. package/firedrill/tools/slack/app/assets/fonts/lato-latin-400.woff2 +0 -0
  30. package/firedrill/tools/slack/app/assets/fonts/lato-latin-700.woff2 +0 -0
  31. package/firedrill/tools/slack/app/assets/fonts/lato-latin-900.woff2 +0 -0
  32. package/firedrill/tools/slack/app/assets/slack-wordmark.svg +1 -0
  33. package/firedrill/tools/slack/app/assets/slack.svg +1 -0
  34. package/firedrill/tools/slack/app/site/app.js +2202 -0
  35. package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-400.woff2 +0 -0
  36. package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-700.woff2 +0 -0
  37. package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-900.woff2 +0 -0
  38. package/firedrill/tools/slack/app/site/assets/slack-wordmark.svg +1 -0
  39. package/firedrill/tools/slack/app/site/assets/slack.svg +1 -0
  40. package/firedrill/tools/slack/app/site/chrome.css +73 -0
  41. package/firedrill/tools/slack/app/site/chrome.js +96 -0
  42. package/firedrill/tools/slack/app/site/icons.js +96 -0
  43. package/firedrill/tools/slack/app/site/index.html +253 -0
  44. package/firedrill/tools/slack/app/site/styles.css +2719 -0
  45. package/firedrill/tools/slack/app/site/ui.js +491 -0
  46. package/firedrill/tools/slack/behavior.mjs +1171 -0
  47. package/firedrill/tools/slack/lib/access.mjs +138 -0
  48. package/firedrill/tools/slack/lib/budget.mjs +67 -0
  49. package/firedrill/tools/slack/lib/ids.mjs +146 -0
  50. package/firedrill/tools/slack/lib/search.mjs +172 -0
  51. package/firedrill/tools/slack/lib/wire.mjs +144 -0
  52. package/firedrill/tools/slack/slack.tool.json +6114 -0
  53. package/firedrill/ts-sequence-full.scenario.json +38 -0
  54. package/firedrill/world.json +2008 -0
  55. package/firedrill.json +5 -0
  56. package/package.json +62 -0
  57. package/starter.json +1562 -0
  58. package/test/conformance.mjs +1166 -0
@@ -0,0 +1,138 @@
1
+ // Bot-token shaped visibility, membership and archive rules. Every rule reads context.state; nothing is cached.
2
+ import { SCAN_CAP, SCAN_STEP, membershipRowId } from "./ids.mjs";
3
+
4
+ export function fail(context, code, message = code.toLowerCase()) {
5
+ return context.fail({ code, message });
6
+ }
7
+
8
+ /**
9
+ * Rows this synthetic workspace serves per namespace (users and conversations per workspace; members, messages and
10
+ * pins per conversation) unless the workspace row carries `limits`. A read that finds more rows than its bound fails
11
+ * with TOO_MANY_ROWS instead of returning the first N rows; pins.add refuses to exceed the pin bound (Slack's own limit
12
+ * is 100 pins per conversation).
13
+ */
14
+ export const DEFAULT_LIMITS = { users: 1000, conversations: 1000, members: 1000, messages: 10000, pins: 100 };
15
+ const LIMIT_OF_NAMESPACE = { users: "users", conversations: "conversations", memberships: "members", messages: "messages", pins: "pins" };
16
+
17
+ export function limitsOf(context) {
18
+ const team = context.state.get("workspace", "team");
19
+ return { ...DEFAULT_LIMITS, ...(team !== null && team.limits !== undefined ? team.limits : {}) };
20
+ }
21
+
22
+ function boundOf(context, namespace) {
23
+ return Math.min(SCAN_CAP, limitsOf(context)[LIMIT_OF_NAMESPACE[namespace]] ?? SCAN_CAP);
24
+ }
25
+
26
+ /**
27
+ * Refuse, before anything is allocated or written, a set that would exceed its bound once stored (e.g. the memberships
28
+ * of a conversation conversations.open is about to create). A declared error rolls back every write of the operation,
29
+ * id counters included, so the message names the requested count and the workspace limit only, never an id the
30
+ * operation would have allocated.
31
+ */
32
+ export function requireWithinBound(context, namespace, count, subject) {
33
+ const bound = boundOf(context, namespace);
34
+ if (count > bound) {
35
+ return fail(context, "TOO_MANY_ROWS", `${subject} (${count}) exceed the supported bound of ${bound} rows (workspace.limits.${LIMIT_OF_NAMESPACE[namespace]})`);
36
+ }
37
+ return count;
38
+ }
39
+
40
+ function tooManyRows(context, namespace, prefix, bound) {
41
+ const where = prefix === undefined ? namespace : `${namespace} in ${prefix.slice(0, -1)}`;
42
+ return fail(context, "TOO_MANY_ROWS", `${where} exceed the supported bound of ${bound} rows (workspace.limits.${LIMIT_OF_NAMESPACE[namespace]})`);
43
+ }
44
+
45
+ /**
46
+ * Every row of one namespace whose row id starts with `prefix` (or the whole namespace when no prefix is given), via
47
+ * bounded scans that read one row past the bound: more rows than the bound is an explicit TOO_MANY_ROWS, never a
48
+ * silently shortened list.
49
+ */
50
+ export function prefixRows(context, namespace, prefix) {
51
+ const bound = boundOf(context, namespace);
52
+ const rows = [];
53
+ let after = prefix;
54
+ while (rows.length <= bound) {
55
+ const limit = Math.min(SCAN_STEP, bound + 1 - rows.length);
56
+ const batch = context.state.scan(namespace, { ...(after === undefined ? {} : { afterRowId: after }), limit });
57
+ for (const record of batch) {
58
+ if (prefix !== undefined && !record.rowId.startsWith(prefix)) return rows;
59
+ after = record.rowId;
60
+ rows.push(record.value);
61
+ }
62
+ if (batch.length < limit) break;
63
+ }
64
+ if (rows.length > bound) return tooManyRows(context, namespace, prefix, bound);
65
+ return rows;
66
+ }
67
+
68
+ /** Every row of a workspace-wide namespace (users, conversations), bounded like prefixRows. */
69
+ export function allRows(context, namespace) {
70
+ return prefixRows(context, namespace, undefined);
71
+ }
72
+
73
+ /**
74
+ * The member a caller without a `userId` attribute acts as: the first active human member in `users` row-id order
75
+ * (deactivated and bot users skipped), so a fresh `firedrill tool add` actor is a member of the seeded workspace. The
76
+ * scan stops at the first match; a workspace without such a member yields null.
77
+ */
78
+ function defaultMember(context) {
79
+ let after;
80
+ for (let scanned = 0; scanned < SCAN_CAP; scanned += SCAN_STEP) {
81
+ const batch = context.state.scan("users", { ...(after === undefined ? {} : { afterRowId: after }), limit: SCAN_STEP });
82
+ for (const record of batch) {
83
+ after = record.rowId;
84
+ const user = record.value;
85
+ if (user.deleted !== true && user.is_bot !== true) return user;
86
+ }
87
+ if (batch.length < SCAN_STEP) break;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * The workspace member this actor acts as: `attributes.userId` when present (a token that resolves to no member is
94
+ * `invalid_auth`), otherwise the workspace's default member (see defaultMember).
95
+ */
96
+ export function requireActor(context) {
97
+ const userId = context.actor.attributes.userId;
98
+ let user;
99
+ if (typeof userId === "string" && userId.length > 0) {
100
+ user = context.state.get("users", userId);
101
+ if (user === null || user.id !== userId) return fail(context, "INVALID_AUTH");
102
+ } else {
103
+ user = defaultMember(context);
104
+ if (user === null) return fail(context, "INVALID_AUTH", "invalid_auth: this workspace has no active human member to act as; set the actor attribute userId");
105
+ }
106
+ const teamId = context.actor.attributes.teamId;
107
+ if (typeof teamId === "string" && teamId.length > 0 && teamId !== user.team_id) return fail(context, "INVALID_AUTH");
108
+ return user;
109
+ }
110
+
111
+ export function isPublicChannel(conversation) {
112
+ return conversation.is_channel === true && conversation.is_private !== true;
113
+ }
114
+
115
+ export function isMember(context, channelId, userId) {
116
+ return context.state.get("memberships", membershipRowId(channelId, userId)) !== null;
117
+ }
118
+
119
+ /**
120
+ * A conversation the actor may know about: public channels always, private channels, IMs and group DMs
121
+ * only through membership. Anything else is indistinguishable from missing.
122
+ */
123
+ export function visibleConversation(context, actorId, channelId) {
124
+ const conversation = typeof channelId === "string" && channelId.length > 0 ? context.state.get("conversations", channelId) : null;
125
+ if (conversation === null) return fail(context, "CHANNEL_NOT_FOUND");
126
+ if (isPublicChannel(conversation) || isMember(context, channelId, actorId)) return conversation;
127
+ return fail(context, "CHANNEL_NOT_FOUND");
128
+ }
129
+
130
+ export function requireMember(context, conversation, actorId) {
131
+ if (!isMember(context, conversation.id, actorId)) return fail(context, "NOT_IN_CHANNEL");
132
+ return conversation;
133
+ }
134
+
135
+ export function requireActive(context, conversation) {
136
+ if (conversation.is_archived === true) return fail(context, "IS_ARCHIVED");
137
+ return conversation;
138
+ }
@@ -0,0 +1,67 @@
1
+ // Response byte budget. The HTTP layer refuses route responses over 1 MiB, so every list and read sizes its page by the
2
+ // UTF-8 bytes of the encoded JSON body as well as by count. Bytes are computed from code points (no Buffer), because
3
+ // String#length counts UTF-16 code units and undercounts CJK and emoji text by up to three times.
4
+
5
+ /** Body bytes a list or read may produce unless the workspace row lowers it (workspace.limits.response_bytes). */
6
+ export const RESPONSE_BYTES = 900000;
7
+
8
+ /** UTF-8 byte length of a string, computed from its code points (lone surrogates count as U+FFFD, 3 bytes). */
9
+ export function utf8Bytes(text) {
10
+ let bytes = 0;
11
+ for (let index = 0; index < text.length; index += 1) {
12
+ const unit = text.charCodeAt(index);
13
+ if (unit < 0x80) bytes += 1;
14
+ else if (unit < 0x800) bytes += 2;
15
+ else if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < text.length) {
16
+ const next = text.charCodeAt(index + 1);
17
+ if (next >= 0xdc00 && next <= 0xdfff) {
18
+ bytes += 4;
19
+ index += 1;
20
+ } else bytes += 3;
21
+ } else bytes += 3;
22
+ }
23
+ return bytes;
24
+ }
25
+
26
+ /** UTF-8 bytes of the compact JSON encoding of a value (the shape the route sends). */
27
+ export function jsonBytes(value) {
28
+ return utf8Bytes(JSON.stringify(value));
29
+ }
30
+
31
+ /**
32
+ * How many leading `items` fit in a body whose envelope (with an empty list) is `envelopeBytes`, given at most `limit`
33
+ * items. Each admitted item adds its own bytes plus one comma after the first. Returns 0 when the first item alone does
34
+ * not fit; the caller answers its declared error instead of an empty or oversized page.
35
+ */
36
+ export function fitCount(items, limit, envelopeBytes, budget, sizeOf) {
37
+ let used = envelopeBytes;
38
+ let count = 0;
39
+ const cap = Math.min(limit, items.length);
40
+ while (count < cap) {
41
+ const next = sizeOf(items[count]) + (count === 0 ? 0 : 1);
42
+ if (used + next > budget) break;
43
+ used += next;
44
+ count += 1;
45
+ }
46
+ return count;
47
+ }
48
+
49
+ /**
50
+ * Largest page size k ≤ `perPage` for which every page of k consecutive items (search's page arithmetic) fits the
51
+ * budget. Sizes are measured once; each candidate is checked with a prefix sum, so the cost is items × candidates.
52
+ * Returns 0 when a single item alone exceeds the budget.
53
+ */
54
+ export function uniformPageSize(sizes, perPage, envelopeBytes, budget) {
55
+ if (sizes.length === 0) return perPage;
56
+ const prefix = [0];
57
+ for (const size of sizes) prefix.push(prefix[prefix.length - 1] + size);
58
+ for (let size = perPage; size >= 1; size -= 1) {
59
+ let fits = true;
60
+ for (let start = 0; start < sizes.length && fits; start += size) {
61
+ const end = Math.min(start + size, sizes.length);
62
+ fits = envelopeBytes + prefix[end] - prefix[start] + (end - start - 1) <= budget;
63
+ }
64
+ if (fits) return size;
65
+ }
66
+ return 0;
67
+ }
@@ -0,0 +1,146 @@
1
+ // Identifier, timestamp and cursor rendering shared by the behavior module, the HTTP codecs and the
2
+ // conformance data. Pure and deterministic: no Node built-ins, no wall clock, no randomness.
3
+
4
+ /** Bounded state scans: rows are read in steps and never beyond the cap. */
5
+ export const SCAN_STEP = 1000;
6
+ export const SCAN_CAP = 10000;
7
+
8
+ /** Slack message timestamps look like "1789376400.000301": epoch seconds, a dot, six digits. */
9
+ export const TS_PATTERN = /^[0-9]{10}\.[0-9]{6}$/;
10
+
11
+ export function pad6(value) {
12
+ return String(value).padStart(6, "0");
13
+ }
14
+
15
+ /** The most message timestamps one virtual second can hold: the fraction is exactly six digits. */
16
+ export const MAX_TS_SEQUENCE = 999_999;
17
+
18
+ /** Render a `ts` from virtual epoch seconds and the per-workspace message sequence. */
19
+ export function renderTs(seconds, sequence) {
20
+ return `${String(seconds).padStart(10, "0")}.${pad6(sequence)}`;
21
+ }
22
+
23
+ export function isTs(value) {
24
+ return typeof value === "string" && TS_PATTERN.test(value);
25
+ }
26
+
27
+ /** `oldest`/`latest` arguments: a full ts or plain epoch seconds (optionally with a fraction). */
28
+ export function isTsLike(value) {
29
+ return typeof value === "string" && /^[0-9]{1,10}(?:\.[0-9]{1,6})?$/.test(value);
30
+ }
31
+
32
+ export function tsNumber(value) {
33
+ return Number(value);
34
+ }
35
+
36
+ export function tsSeconds(ts) {
37
+ return Number(ts.slice(0, 10));
38
+ }
39
+
40
+ /** Two well-formed ts values compare as strings because seconds are fixed-width. */
41
+ export function compareTs(left, right) {
42
+ return left < right ? -1 : left > right ? 1 : 0;
43
+ }
44
+
45
+ /** Generated conversation ids: "C" or "D" followed by a zero-padded base-36 sequence (11 characters). */
46
+ export function conversationId(prefix, sequence) {
47
+ return `${prefix}${sequence.toString(36).toUpperCase().padStart(10, "0")}`;
48
+ }
49
+
50
+ export function messageRowId(channel, ts) {
51
+ return `${channel}:${ts}`;
52
+ }
53
+
54
+ export function membershipRowId(channel, user) {
55
+ return `${channel}:${user}`;
56
+ }
57
+
58
+ export function pinRowId(channel, ts) {
59
+ return `${channel}:${ts}`;
60
+ }
61
+
62
+ /** Slack-style permalink built from the workspace url stored in data; it resolves nowhere. */
63
+ export function permalink(workspaceUrl, channel, ts) {
64
+ return `${workspaceUrl}archives/${channel}/p${ts.replace(".", "")}`;
65
+ }
66
+
67
+ /** Wire error strings are the lower-snake spelling of the declared code (CHANNEL_NOT_FOUND → channel_not_found). */
68
+ export function lowerSnake(code) {
69
+ return String(code).toLowerCase();
70
+ }
71
+
72
+ /**
73
+ * UTC calendar day (YYYY-MM-DD) of an epoch-seconds value, computed arithmetically (Howard Hinnant's
74
+ * civil-from-days) so the search date modifiers never touch the host Date implementation.
75
+ */
76
+ export function isoDay(seconds) {
77
+ const days = Math.floor(seconds / 86400);
78
+ const z = days + 719468;
79
+ const era = Math.floor(z / 146097);
80
+ const dayOfEra = z - era * 146097;
81
+ const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1460) + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365);
82
+ const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
83
+ const monthIndex = Math.floor((5 * dayOfYear + 2) / 153);
84
+ const day = dayOfYear - Math.floor((153 * monthIndex + 2) / 5) + 1;
85
+ const month = monthIndex < 10 ? monthIndex + 3 : monthIndex - 9;
86
+ const year = yearOfEra + era * 400 + (month <= 2 ? 1 : 0);
87
+ return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
88
+ }
89
+
90
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
91
+
92
+ /** base64url without padding, ASCII input only (cursors contain ids, digits, dots and colons). */
93
+ export function base64url(text) {
94
+ let output = "";
95
+ let index = 0;
96
+ while (index < text.length) {
97
+ const first = text.charCodeAt(index++);
98
+ const second = index < text.length ? text.charCodeAt(index++) : undefined;
99
+ const third = index < text.length ? text.charCodeAt(index++) : undefined;
100
+ if (first > 127 || (second !== undefined && second > 127) || (third !== undefined && third > 127)) {
101
+ throw new TypeError("cursor text must be ASCII");
102
+ }
103
+ output += ALPHABET[first >> 2];
104
+ output += ALPHABET[((first & 3) << 4) | ((second ?? 0) >> 4)];
105
+ if (second !== undefined) output += ALPHABET[((second & 15) << 2) | ((third ?? 0) >> 6)];
106
+ if (third !== undefined) output += ALPHABET[third & 63];
107
+ }
108
+ return output;
109
+ }
110
+
111
+ /** Inverse of base64url; returns null for anything that is not well-formed ASCII base64url. */
112
+ export function fromBase64url(value) {
113
+ if (typeof value !== "string" || value.length === 0 || value.length % 4 === 1 || /[^A-Za-z0-9_-]/.test(value)) {
114
+ return null;
115
+ }
116
+ let bits = 0;
117
+ let buffer = 0;
118
+ let output = "";
119
+ for (const character of value) {
120
+ buffer = (buffer << 6) | ALPHABET.indexOf(character);
121
+ bits += 6;
122
+ if (bits >= 8) {
123
+ bits -= 8;
124
+ const code = (buffer >> bits) & 255;
125
+ if (code > 127) return null;
126
+ output += String.fromCharCode(code);
127
+ }
128
+ }
129
+ return output;
130
+ }
131
+
132
+ /** Cursors mirror Slack's opaque-but-decodable shape: base64url("<kind>:<value>"). */
133
+ export function encodeCursor(kind, value) {
134
+ return base64url(`${kind}:${value}`);
135
+ }
136
+
137
+ /** Decode a cursor of the expected kind; null when malformed or of another kind. */
138
+ export function decodeCursor(cursor, kind) {
139
+ const text = fromBase64url(cursor);
140
+ if (text === null) return null;
141
+ const separator = text.indexOf(":");
142
+ if (separator <= 0) return null;
143
+ if (text.slice(0, separator) !== kind) return null;
144
+ const value = text.slice(separator + 1);
145
+ return value.length > 0 ? value : null;
146
+ }
@@ -0,0 +1,172 @@
1
+ // search.messages query grammar: whitespace-separated terms, "quoted phrases", -negation and a documented
2
+ // modifier subset. Pure functions; the behavior module supplies the environment (handles, membership, pins).
3
+ import { isoDay, tsSeconds } from "./ids.mjs";
4
+
5
+ export class SearchError extends Error {}
6
+
7
+ const MODIFIER = /^(-?)([a-z_]+):(.*)$/s;
8
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
9
+ const MONTH = /^\d{4}-\d{2}$/;
10
+
11
+ /** Split on whitespace while keeping double-quoted phrases together. */
12
+ function tokenize(query) {
13
+ const tokens = [];
14
+ let current = "";
15
+ let quoted = false;
16
+ for (const character of query) {
17
+ if (character === '"') {
18
+ quoted = !quoted;
19
+ current += character;
20
+ continue;
21
+ }
22
+ if (!quoted && /\s/.test(character)) {
23
+ if (current.length > 0) tokens.push(current);
24
+ current = "";
25
+ continue;
26
+ }
27
+ current += character;
28
+ }
29
+ if (current.length > 0) tokens.push(current);
30
+ return tokens;
31
+ }
32
+
33
+ function unquote(value) {
34
+ return value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
35
+ }
36
+
37
+ /**
38
+ * Parse a query into bare terms and modifiers. Throws SearchError for modifiers outside the subset,
39
+ * which the handler reports as INVALID_ARGUMENTS (Slack would treat them as text; documented deviation).
40
+ */
41
+ export function parseQuery(query) {
42
+ const terms = [];
43
+ const modifiers = [];
44
+ for (const token of tokenize(query)) {
45
+ const match = token.startsWith('"') ? null : MODIFIER.exec(token);
46
+ if (match === null) {
47
+ const negate = token.startsWith("-") && token.length > 1 && !token.startsWith('-"');
48
+ const raw = negate ? token.slice(1) : token.startsWith('-"') ? token.slice(1) : token;
49
+ const text = unquote(raw).toLowerCase();
50
+ if (text.length > 0) terms.push({ text, negate: negate || token.startsWith('-"'), phrase: raw.startsWith('"') });
51
+ continue;
52
+ }
53
+ const negate = match[1] === "-";
54
+ const op = match[2];
55
+ const value = unquote(match[3]);
56
+ switch (op) {
57
+ case "in":
58
+ case "from": {
59
+ if (value.length === 0) throw new SearchError(`Search modifier "${op}:" needs a value`);
60
+ modifiers.push({ op, value, negate });
61
+ break;
62
+ }
63
+ case "is": {
64
+ if (value !== "thread") throw new SearchError(`Unsupported search modifier "is:${value}"`);
65
+ modifiers.push({ op, value, negate });
66
+ break;
67
+ }
68
+ case "has": {
69
+ const emoji = /^:([a-z0-9_+-]{1,100}):$/.exec(value);
70
+ if (value === "pin" || value === "reaction") modifiers.push({ op, value, negate });
71
+ else if (emoji !== null) modifiers.push({ op: "has-emoji", value: emoji[1], negate });
72
+ else throw new SearchError(`Unsupported search modifier "has:${value}"`);
73
+ break;
74
+ }
75
+ case "before":
76
+ case "after":
77
+ case "on": {
78
+ if (!DAY.test(value)) throw new SearchError(`Search modifier "${op}:" needs a YYYY-MM-DD date`);
79
+ modifiers.push({ op, value, negate });
80
+ break;
81
+ }
82
+ case "during": {
83
+ if (!MONTH.test(value)) throw new SearchError(`Search modifier "during:" needs a YYYY-MM month`);
84
+ modifiers.push({ op, value, negate });
85
+ break;
86
+ }
87
+ default:
88
+ throw new SearchError(`Unsupported search modifier "${op}:"`);
89
+ }
90
+ }
91
+ return { terms, modifiers };
92
+ }
93
+
94
+ function escapeRegExp(text) {
95
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
96
+ }
97
+
98
+ /**
99
+ * Does `message` match the parsed query? Returns null for no match, otherwise a deterministic integer score:
100
+ * 10 per matching bare term, +5 when the term is a whole word, +1 when the message is a thread parent or plain post.
101
+ *
102
+ * `env`: { conversation, actorId, authorHandle(userId) → handle|null, partnerHandle(conversation) → handle|null,
103
+ * isPinned(message) → boolean }
104
+ */
105
+ export function scoreMessage(parsed, message, env) {
106
+ const text = String(message.text ?? "");
107
+ const lower = text.toLowerCase();
108
+ let score = 0;
109
+ for (const term of parsed.terms) {
110
+ const present = lower.includes(term.text);
111
+ if (term.negate ? present : !present) return null;
112
+ if (!term.negate) {
113
+ score += 10;
114
+ if (new RegExp(`(^|[^a-z0-9_])${escapeRegExp(term.text)}($|[^a-z0-9_])`, "i").test(text)) score += 5;
115
+ }
116
+ }
117
+ for (const modifier of parsed.modifiers) {
118
+ let matched;
119
+ switch (modifier.op) {
120
+ case "in":
121
+ matched = conversationMatches(env.conversation, modifier.value, env);
122
+ break;
123
+ case "from":
124
+ matched = authorMatches(message, modifier.value, env);
125
+ break;
126
+ case "is":
127
+ matched = typeof message.thread_ts === "string";
128
+ break;
129
+ case "has":
130
+ matched = modifier.value === "pin" ? env.isPinned(message) : Array.isArray(message.reactions) && message.reactions.length > 0;
131
+ break;
132
+ case "has-emoji":
133
+ matched = Array.isArray(message.reactions) && message.reactions.some((reaction) => reaction.name === modifier.value);
134
+ break;
135
+ case "before":
136
+ matched = isoDay(tsSeconds(message.ts)) < modifier.value;
137
+ break;
138
+ case "after":
139
+ matched = isoDay(tsSeconds(message.ts)) > modifier.value;
140
+ break;
141
+ case "on":
142
+ matched = isoDay(tsSeconds(message.ts)) === modifier.value;
143
+ break;
144
+ case "during":
145
+ matched = isoDay(tsSeconds(message.ts)).startsWith(`${modifier.value}-`);
146
+ break;
147
+ default:
148
+ matched = false;
149
+ }
150
+ if (modifier.negate ? matched : !matched) return null;
151
+ }
152
+ const isReply = typeof message.thread_ts === "string" && message.thread_ts !== message.ts;
153
+ return score + (isReply ? 0 : 1);
154
+ }
155
+
156
+ function conversationMatches(conversation, value, env) {
157
+ const channelRef = /^<#([A-Z0-9]+)(?:\|[^>]*)?>$/.exec(value);
158
+ if (channelRef !== null) return conversation.id === channelRef[1];
159
+ if (value.startsWith("@")) {
160
+ return conversation.is_im === true && env.partnerHandle(conversation) === value.slice(1).toLowerCase();
161
+ }
162
+ const name = value.startsWith("#") ? value.slice(1) : value;
163
+ return conversation.id === name || conversation.name === name.toLowerCase();
164
+ }
165
+
166
+ function authorMatches(message, value, env) {
167
+ if (value === "me") return message.user === env.actorId;
168
+ const userRef = /^<@([A-Z0-9]+)(?:\|[^>]*)?>$/.exec(value);
169
+ if (userRef !== null) return message.user === userRef[1];
170
+ const handle = (value.startsWith("@") ? value.slice(1) : value).toLowerCase();
171
+ return message.user === handle || env.authorHandle(message.user) === handle;
172
+ }
@@ -0,0 +1,144 @@
1
+ // Slack Web API wire helpers: query/form parameters → typed arguments, and the `{ ok: false, error }` envelope.
2
+ // Pure functions with no state access, so the HTTP codecs can use them.
3
+ import { lowerSnake } from "./ids.mjs";
4
+
5
+ /** Seconds a rate-limited client should wait; sent as `Retry-After` with every 429. */
6
+ export const RETRY_AFTER_SECONDS = "30";
7
+
8
+ /**
9
+ * Parameters of a Slack call: the query string on GET; on POST an `application/x-www-form-urlencoded` body (the SDK
10
+ * convention, also assumed when no content type is sent) or an `application/json` object body, as the Web API accepts
11
+ * both with a bearer token. Routes declare the body as raw text so this codec can tell the two apart itself. Every
12
+ * value is normalised to the form shape (name → list of strings; JSON arrays and objects re-encoded as JSON text), so
13
+ * the typed readers below work the same for both encodings.
14
+ */
15
+ export function params(request) {
16
+ if (request.method === "GET") return request.query;
17
+ const body = request.body ?? {};
18
+ if (body.kind === "form") return body.value;
19
+ if (body.kind !== "text" || typeof body.value !== "string") return Object.create(null);
20
+ const type = mediaType(last(request.headers?.["content-type"]));
21
+ if (type === "application/json" || type.endsWith("+json")) return jsonParams(body.value);
22
+ if (type === "" || type === "application/x-www-form-urlencoded") return formParams(body.value);
23
+ throw new TypeError("request content type must be application/x-www-form-urlencoded or application/json");
24
+ }
25
+
26
+ function mediaType(header) {
27
+ return typeof header === "string" ? header.split(";", 1)[0].trim().toLowerCase() : "";
28
+ }
29
+
30
+ function formParams(text) {
31
+ const form = Object.create(null);
32
+ for (const [name, value] of new URLSearchParams(text)) {
33
+ if (form[name] === undefined) form[name] = [];
34
+ form[name].push(value);
35
+ }
36
+ return form;
37
+ }
38
+
39
+ /** A JSON body must be an object (Slack: invalid_json / json_not_object); values become form strings. */
40
+ function jsonParams(text) {
41
+ if (text.trim().length === 0) return Object.create(null);
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(text);
45
+ } catch {
46
+ throw new TypeError("request body must be valid JSON (Slack: invalid_json)");
47
+ }
48
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
49
+ throw new TypeError("request body must be a JSON object (Slack: json_not_object)");
50
+ }
51
+ const form = Object.create(null);
52
+ for (const [name, value] of Object.entries(parsed)) {
53
+ if (value === null || value === undefined) continue;
54
+ form[name] = [typeof value === "object" ? jsonText(name, value) : String(value)];
55
+ }
56
+ return form;
57
+ }
58
+
59
+ function jsonText(name, value) {
60
+ try {
61
+ return JSON.stringify(value);
62
+ } catch {
63
+ throw new TypeError(`${name} nests too deeply`);
64
+ }
65
+ }
66
+
67
+ /** Slack keeps the last value of a repeated parameter. */
68
+ function last(values) {
69
+ return values === undefined || values.length === 0 ? undefined : values[values.length - 1];
70
+ }
71
+
72
+ export function str(source, name) {
73
+ return last(source[name]);
74
+ }
75
+
76
+ export function int(source, name) {
77
+ const value = last(source[name]);
78
+ if (value === undefined) return undefined;
79
+ if (!/^-?[0-9]{1,9}$/.test(value)) throw new TypeError(`${name} must be an integer`);
80
+ return Number(value);
81
+ }
82
+
83
+ export function bool(source, name) {
84
+ const value = last(source[name]);
85
+ if (value === undefined) return undefined;
86
+ if (value === "true" || value === "1") return true;
87
+ if (value === "false" || value === "0") return false;
88
+ throw new TypeError(`${name} must be true, false, 1 or 0`);
89
+ }
90
+
91
+ /** `blocks`/`attachments` travel as a JSON string inside the form, exactly as the official SDKs send them. */
92
+ export function json(source, name) {
93
+ const value = last(source[name]);
94
+ if (value === undefined) return undefined;
95
+ try {
96
+ return JSON.parse(value);
97
+ } catch {
98
+ throw new TypeError(`${name} must be a JSON string`);
99
+ }
100
+ }
101
+
102
+ /** Drop undefined values so the arguments object validates against the input schema. */
103
+ export function defined(object) {
104
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
105
+ }
106
+
107
+ /** Mutations accept an optional X-Firedrill-Idempotency-Key header (Slack clients never send one). */
108
+ export function operationInput(request, args) {
109
+ const key = last(request.headers["x-firedrill-idempotency-key"]);
110
+ return key === undefined || key.length === 0 ? { arguments: args } : { arguments: args, idempotencyKey: key };
111
+ }
112
+
113
+ /**
114
+ * Slack-shaped error body for every non-ok outcome. Status codes are framework-owned (declared per route);
115
+ * real Slack answers HTTP 200 with ok:false for most of these, which this Tool cannot reproduce.
116
+ */
117
+ export function slackError(invocation, outcome) {
118
+ const error = outcome.error ?? {};
119
+ const message = typeof error.message === "string" ? error.message : "";
120
+ if (outcome.status === "denied") {
121
+ const needed = `${invocation.operation.packageId}.${invocation.operation.operationId}`;
122
+ return {
123
+ headers: {},
124
+ body: { kind: "json", value: { ok: false, error: "missing_scope", needed, provided: "" } },
125
+ };
126
+ }
127
+ if (outcome.status === "unsupported") {
128
+ return { headers: {}, body: { kind: "json", value: { ok: false, error: "unknown_method" } } };
129
+ }
130
+ if (outcome.status === "invalid") {
131
+ return {
132
+ headers: {},
133
+ body: {
134
+ kind: "json",
135
+ value: { ok: false, error: "invalid_arguments", response_metadata: { messages: [message || "invalid arguments"] } },
136
+ },
137
+ };
138
+ }
139
+ const code = String(error.code ?? "").replace(/^tool\./, "");
140
+ const wire = code.length > 0 ? lowerSnake(code) : "internal_error";
141
+ const value = { ok: false, error: wire };
142
+ if (message.length > 0 && message !== wire) value.response_metadata = { messages: [message] };
143
+ return { headers: wire === "ratelimited" ? { "retry-after": RETRY_AFTER_SECONDS } : {}, body: { kind: "json", value } };
144
+ }