@company-semantics/contracts 51.3.0 → 52.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/identity/__tests__/people-org-chart.test.ts +75 -0
  3. package/src/identity/people-org-chart.ts +25 -6
  4. package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
  5. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
  6. package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
  7. package/src/notifications/renderers/README.md +8 -4
  8. package/src/notifications/renderers/ascii/README.md +75 -0
  9. package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
  10. package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
  11. package/src/notifications/renderers/ascii/chat.ts +179 -0
  12. package/src/notifications/renderers/ascii/cta.ts +57 -0
  13. package/src/notifications/renderers/ascii/geometry.ts +112 -0
  14. package/src/notifications/renderers/ascii/index.ts +40 -0
  15. package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
  16. package/src/notifications/renderers/ascii/rule.ts +53 -0
  17. package/src/notifications/renderers/ascii/runs.ts +84 -0
  18. package/src/notifications/renderers/ascii/signature.ts +41 -0
  19. package/src/notifications/renderers/ascii/wrap.ts +96 -0
  20. package/src/notifications/renderers/brand.ts +12 -0
  21. package/src/notifications/renderers/email/chat.ts +62 -146
  22. package/src/notifications/renderers/email/constants.ts +17 -2
  23. package/src/notifications/renderers/email/cta.ts +8 -13
  24. package/src/notifications/renderers/email/render.ts +29 -5
  25. package/src/notifications/renderers/layout.ts +31 -0
  26. package/src/notifications/renderers/slack/README.md +135 -79
  27. package/src/notifications/renderers/slack/__tests__/README.md +3 -2
  28. package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
  29. package/src/notifications/renderers/slack/blocks.ts +149 -0
  30. package/src/notifications/renderers/slack/chat.ts +167 -0
  31. package/src/notifications/renderers/slack/cta.ts +69 -0
  32. package/src/notifications/renderers/slack/index.ts +136 -229
  33. package/src/notifications/renderers/slack/message.ts +23 -0
@@ -3,18 +3,24 @@
3
3
  *
4
4
  * Where `../sms` proves the content model survives the poorest channel, this one
5
5
  * proves the other edge: a channel whose output is neither a string nor email's
6
- * `{ subject, text, html }`, but a tree of typed blocks. Between the three, `Out`
7
- * has been a record, a string and a different record — which is the seam property
8
- * `../../renderer.ts` states in prose ("each renderer returns its natural type",
9
- * no `ChannelOutput` supertype) held by real modules rather than asserted.
6
+ * `{ subject, text, html }`, but a tree of typed blocks. The blocks are Slack's
7
+ * OWN vocabulary `@slack/types`' `KnownBlock` is the union Slack's API
8
+ * validates against, so a drift from the wire format is a compile error rather
9
+ * than a silent one.
10
10
  *
11
- * The blocks are Slack's OWN vocabulary: `@slack/types` is the published type
12
- * package behind `node-slack-sdk`, so `KnownBlock` is the same union Slack's API
13
- * validates against. This channel used to hand-write a five-block approximation
14
- * of Block Kit; that is what ADR-CONTRACTS-090 retires. A renderer whose output
15
- * type is invented can drift from the wire format silently, and no test in this
16
- * package would notice — the compiler is the only thing that can, and it can only
17
- * help if the types are theirs.
11
+ * **The voice.** This channel's narrative content is monospace, drawn by
12
+ * `../ascii` the same functions the email's `text/plain` surface uses, so a
13
+ * conversation cannot come out a column apart on the two channels. Slack's
14
+ * `rich_text_preformatted` is the surface that makes it possible: it is set in a
15
+ * monospace face by Slack itself, so the voice here is REAL rather than a
16
+ * stylesheet the gallery applies and a workspace ignores.
17
+ *
18
+ * **Where the voice stops.** Controls, structure and the title are exempt and
19
+ * use Slack's own primitives: a linked CTA is an `actions` button (`./cta.ts`),
20
+ * a table is a `table` block (`./table.ts`), the headline is a `header`, and an
21
+ * image is an `image`. Sharing a VOCABULARY across channels is the goal; sharing
22
+ * a control REPRESENTATION is not, and a native button beats an ASCII box drawn
23
+ * inside a code block on every measure that matters to a reader.
18
24
  *
19
25
  * It is STILL non-functional in the one sense that matters most:
20
26
  *
@@ -22,143 +28,71 @@
22
28
  * renderer renders; delivery and the decision to deliver are the backend's
23
29
  * (`SlackChannelDelivery` throws `ChannelNotImplementedError` by design).
24
30
  *
25
- * What it is no longer: low fidelity. The copy is still invented rather than
26
- * relocated from a shipped Slack app — there is no golden here the way `../email`
27
- * has one — but the SHAPE is legitimate Block Kit and the `__tests__` pin it.
28
- *
29
31
  * INVARIANTS:
30
32
  * - Pure — a function of `(content, context)`. No clock, no environment, no I/O.
31
33
  * The brand and the copyright year come from `context.brand`.
32
- * - `supports` is total, and agrees with `render`: `chatUnit` is omitted, never
33
- * approximated and never thrown on.
34
- * - Escaping follows the SURFACE, not the string. `mrkdwn` is Slack's dialect and
35
- * every user-controlled string reaching one passes through `escapeMrkdwn`;
36
- * `plain_text` and `rich_text` are literal surfaces and must NOT be escaped, or
37
- * the reader sees `&`. See `escapeMrkdwn`.
34
+ * - `supports` is total-true: Slack can now depict every element. That is a
35
+ * claim about TODAY, and `assertNever` below is what keeps it true tomorrow —
36
+ * see the note on the switch.
37
+ * - Escaping follows the SURFACE, not the string. See `./blocks.ts`.
38
38
  * - Slack's documented limits are enforced here, not left to the API to reject:
39
- * a header truncates at 150 chars, section fields chunk at 10.
39
+ * a header truncates at 150 chars, an `actions` block splits at 25 elements.
40
40
  * - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
41
41
  * lost the model — the fix for a missing fact is upstream in `compose`.
42
42
  */
43
43
 
44
- import type {
45
- ActionsBlock,
46
- ContextBlock,
47
- DividerBlock,
48
- HeaderBlock,
49
- ImageBlock,
50
- KnownBlock,
51
- MrkdwnElement,
52
- PlainTextElement,
53
- RichTextBlock,
54
- RichTextSection,
55
- SectionBlock,
56
- } from "@slack/types";
44
+ import type { KnownBlock } from "@slack/types";
57
45
 
58
46
  import type { NotificationContent, NotificationElement } from "../../content";
59
47
  import type { RenderContext } from "../../context";
60
48
  import type { Renderer } from "../../renderer";
49
+ import {
50
+ type AsciiGeometry,
51
+ geometryFor,
52
+ keyValueLines,
53
+ proseLines,
54
+ fullRuleAscii,
55
+ signatureLines,
56
+ } from "../ascii";
57
+ import { COMPANY_URL } from "../brand";
58
+ import { MONOSPACE_COLUMNS } from "../layout";
61
59
 
62
- // =============================================================================
63
- // Slack's documented limits
64
- // =============================================================================
60
+ import { coalescePreformatted, preformatted } from "./blocks";
61
+ import { renderChatUnitBlocks } from "./chat";
62
+ import { renderCtaBlocks } from "./cta";
65
63
 
66
- /** A `header` block's text. Slack rejects the message beyond this. */
67
- const HEADER_MAX_CHARS = 150;
64
+ export type { SlackMessage } from "./message";
68
65
 
69
- /** Fields per `section`. A longer table chunks across sections rather than 400s. */
70
- const SECTION_MAX_FIELDS = 10;
66
+ import type { SlackMessage } from "./message";
71
67
 
72
- // =============================================================================
73
- // One Slack message
74
- // =============================================================================
68
+ /** The same budget email's plain-text surface draws inside (`../layout.ts`). */
69
+ const GEOMETRY: AsciiGeometry = geometryFor(MONOSPACE_COLUMNS);
75
70
 
76
71
  /**
77
- * This channel's natural output type.
72
+ * A refusal to let a future element disappear quietly.
78
73
  *
79
- * `blocks` is `KnownBlock[]` Slack's own union, not ours. `text` is the
80
- * notification-and-fallback string Slack shows where blocks cannot render (a push
81
- * notification, a screen reader). It is `metadata.title`, which is the same field
82
- * email spends as its subject and this channel ALSO spends as its `header` block:
83
- * one content-level fact, three channel-level uses, which is why it lives on the
84
- * content rather than in a channel's registry.
85
- */
86
- export interface SlackMessage {
87
- text: string;
88
- blocks: KnownBlock[];
89
- }
90
-
91
- // =============================================================================
92
- // Surfaces
93
- // =============================================================================
94
-
95
- /**
96
- * Slack's three reserved characters, for `mrkdwn` surfaces ONLY.
74
+ * `supports` returns true for everything, which is a fact about the twelve
75
+ * elements that exist today. Left alone, that answer would also cover a
76
+ * thirteenth: the switch below would fall through, return no blocks, and Slack
77
+ * would post a message with a silent hole in it which is precisely the
78
+ * `chatUnit` bug, reincarnated under a new name and harder to see, because there
79
+ * would no longer be a `supports` returning false to point at it.
97
80
  *
98
- * Everything else is literal, so unlike HTML this is the whole of the rule.
99
- *
100
- * ⚠️ Applying this to a `plain_text` or `rich_text` surface is a DEFECT, not
101
- * belt-and-braces: those surfaces do not parse `&`/`<`/`>`, so an escaped string
102
- * renders the entity itself and the reader sees `Acme &amp; Co`. The old
103
- * hand-written subset could not express the distinction because it had no literal
104
- * surfaces beyond a button label; `header` and `rich_text` make it load-bearing.
105
- * `__tests__/index.test.ts` pins both halves.
81
+ * So the switch has no `default`. Adding a `NotificationElement` variant makes
82
+ * this line fail to compile until someone has chosen how Slack says it.
106
83
  */
107
- function escapeMrkdwn(value: string): string {
108
- return value
109
- .replace(/&/g, "&amp;")
110
- .replace(/</g, "&lt;")
111
- .replace(/>/g, "&gt;");
112
- }
113
-
114
- /** An mrkdwn text object, from an already-escaped string. */
115
- function mrkdwn(text: string): MrkdwnElement {
116
- return { type: "mrkdwn", text };
117
- }
118
-
119
- /** A literal text object. Never escaped — see `escapeMrkdwn`. */
120
- function plainText(text: string): PlainTextElement {
121
- return { type: "plain_text", text };
122
- }
123
-
124
- /** A section carrying pre-escaped mrkdwn. */
125
- function section(text: string): SectionBlock {
126
- return { type: "section", text: mrkdwn(text) };
127
- }
128
-
129
- /** A context block carrying pre-escaped mrkdwn. */
130
- function context(text: string): ContextBlock {
131
- return { type: "context", elements: [mrkdwn(text)] };
84
+ function assertNever(element: never): never {
85
+ throw new Error(
86
+ `Slack renderer has no representation for element: ${JSON.stringify(element)}`,
87
+ );
132
88
  }
133
89
 
134
- /** A rich-text section wrapping literal text. */
135
- function richTextSection(text: string): RichTextSection {
136
- return { type: "rich_text_section", elements: [{ type: "text", text }] };
137
- }
138
-
139
- /** Split into chunks of at most `size`. Slack caps several collections. */
140
- function chunk<T>(items: T[], size: number): T[][] {
141
- const chunks: T[][] = [];
142
- for (let index = 0; index < items.length; index += size) {
143
- chunks.push(items.slice(index, index + size));
144
- }
145
- return chunks;
146
- }
147
-
148
- // =============================================================================
149
- // Rendering
150
- // =============================================================================
151
-
152
90
  /**
153
91
  * One element as zero or more blocks.
154
92
  *
155
- * Zero and many are both real here `chatUnit` yields nothing, and an element is
156
- * free to become several blocks which is why this returns an array where
157
- * `../sms`'s equivalent returns a string. The switch is total over all twelve
158
- * types rather than the eleven `supports` admits: the declined arm is unreachable
159
- * (`render` filters on `supports` first), but writing it out is what makes the
160
- * compiler prove the two lists agree. A `default` arm would silently swallow a
161
- * thirteenth member of the union.
93
+ * Zero is still reachable an empty table or an empty drawing yields nothing
94
+ * but it is now always a statement about EMPTY CONTENT, never about a channel
95
+ * that gave up on the element.
162
96
  */
163
97
  function renderElement(
164
98
  element: NotificationElement,
@@ -166,58 +100,25 @@ function renderElement(
166
100
  ): KnownBlock[] {
167
101
  switch (element.type) {
168
102
  case "greeting":
169
- return [section(`Hi ${escapeMrkdwn(element.recipientName ?? "there")},`)];
103
+ return preformatted(
104
+ proseLines(`Hi ${element.recipientName ?? "there"},`, GEOMETRY),
105
+ );
170
106
  case "body":
171
- return [section(escapeMrkdwn(element.text))];
107
+ return preformatted(proseLines(element.text, GEOMETRY));
172
108
  case "keyValueTable":
173
- // `fields` is Slack's own two-column device for exactly this facts that
174
- // belong together, laid out side by side. The old subset had no `fields`,
175
- // so it flattened the table into `*label:* value` lines and lost the
176
- // grouping that `KeyValueTable` exists to carry. The `:` was presentation
177
- // and lives in no element (see ../../kinds/README.md); Slack's answer is a
178
- // bold label above its value, so the colon is simply gone.
179
- //
180
- // Slack caps a section at ten fields; a longer table becomes more sections
181
- // rather than an API rejection.
182
- return chunk(element.rows, SECTION_MAX_FIELDS).map((rows) => ({
183
- type: "section",
184
- fields: rows.map((row) =>
185
- mrkdwn(`*${escapeMrkdwn(row.label)}*\n${escapeMrkdwn(row.value)}`),
186
- ),
187
- }));
109
+ // `Label: value` lines in the monospace flow, exactly as the email says
110
+ // them. A `table` block was tried here and removed: it draws its own
111
+ // bordered grid, so every key/value pair cut the message into another
112
+ // container and a two-fact notification arrived as five stacked boxes.
113
+ // The email states these as plain lines and reads as one thing; matching
114
+ // that is worth more than column alignment two rows never needed.
115
+ return preformatted(keyValueLines(element.rows));
188
116
  case "callToAction":
189
- // `href` absent means the label IS the payload (an OTP code), so it becomes
190
- // a preformatted block — Slack's code-block surface, which renders it in a
191
- // monospaced box the reader can select cleanly — rather than a button with
192
- // a fabricated destination. `rich_text` is a LITERAL surface: no escaping.
193
- return element.href
194
- ? [
195
- {
196
- type: "actions",
197
- elements: [
198
- {
199
- type: "button",
200
- text: plainText(element.label),
201
- url: element.href,
202
- },
203
- ],
204
- },
205
- ]
206
- : [
207
- {
208
- type: "rich_text",
209
- elements: [
210
- {
211
- type: "rich_text_preformatted",
212
- elements: [{ type: "text", text: element.label }],
213
- },
214
- ],
215
- },
216
- ];
117
+ return renderCtaBlocks(element, GEOMETRY);
217
118
  case "list":
218
- // A real `rich_text_list`, not bullet characters glued onto a string. Slack
219
- // draws the markers, the indentation and the numbering; `ordered` maps onto
220
- // its own `style`. Literal surface — no escaping.
119
+ // A real `rich_text_list`, not bullet characters glued onto a string.
120
+ // Slack draws the markers, the indentation and the numbering; `ordered`
121
+ // maps onto its own `style`. Literal surface — no escaping.
221
122
  return [
222
123
  {
223
124
  type: "rich_text",
@@ -225,7 +126,10 @@ function renderElement(
225
126
  {
226
127
  type: "rich_text_list",
227
128
  style: element.ordered ? "ordered" : "bullet",
228
- elements: element.items.map(richTextSection),
129
+ elements: element.items.map((item) => ({
130
+ type: "rich_text_section",
131
+ elements: [{ type: "text", text: item }],
132
+ })),
229
133
  },
230
134
  ],
231
135
  },
@@ -233,68 +137,68 @@ function renderElement(
233
137
  case "divider":
234
138
  return [{ type: "divider" }];
235
139
  case "warning":
236
- // Fieldless by design: the banner IS the content and its prominence is the
237
- // channel's to choose. Slack's answer is an emoji and bold text.
238
- return [section(":warning: *Security notice*")];
140
+ // Fieldless by design: the banner IS the content. Email sets it in the
141
+ // same squared glyphs, and they are the only part of this notification
142
+ // whose shape survives a channel with no colour of its own.
143
+ return preformatted(proseLines("🆆🅰🆁🅽🅸🅽🅶", GEOMETRY));
239
144
  case "metadata":
240
- // A context block, because this element exists to BE de-emphasisable —
241
- // diagnostic detail about the request, distinct from the facts themselves.
242
- return [
243
- context(
244
- [
245
- element.label ? `*${escapeMrkdwn(element.label)}*` : "",
246
- ...element.rows.map(
247
- (row) => `${escapeMrkdwn(row.label)}: ${escapeMrkdwn(row.value)}`,
248
- ),
249
- ]
250
- .filter(Boolean)
251
- .join(" · "),
252
- ),
253
- ];
145
+ // Same lines as `keyValueTable`, under the element's own heading. The
146
+ // de-emphasis a `context` block used to give this is dropped on purpose:
147
+ // it pulled the diagnostics out of the monospace flow into Slack's sans,
148
+ // so the one element meant to recede was the one that looked different
149
+ // from everything around it.
150
+ return preformatted(keyValueLines(element.rows, element.label));
254
151
  case "notice":
255
- return [context(element.lines.map(escapeMrkdwn).join("\n"))];
152
+ return preformatted(
153
+ element.lines.flatMap((line) => proseLines(line, GEOMETRY)),
154
+ );
155
+ case "chatUnit":
156
+ return renderChatUnitBlocks(element.items, GEOMETRY);
256
157
  case "signature":
158
+ // The same sign-off email draws, from the same function — the marker, the
159
+ // copyright line, the URL. The URL arrives as a LINK run, which the
160
+ // preformatted surface admits; email's plain-text body can only flatten it.
161
+ //
257
162
  // Both the signer fallback and the year come from the context. This is the
258
163
  // element that used to hide a `new Date()`; here there is nowhere to hide.
259
- return [
260
- context(
261
- ${renderContext.brand.copyrightYear} ${escapeMrkdwn(
262
- element.signer ?? renderContext.brand.name,
263
- )}`,
164
+ return preformatted(
165
+ signatureLines(
166
+ element.signer ?? renderContext.brand.name,
167
+ renderContext.brand.copyrightYear,
168
+ COMPANY_URL,
264
169
  ),
265
- ];
170
+ );
266
171
  case "heroImage":
267
172
  return [{ type: "image", image_url: element.src, alt_text: element.alt }];
268
- case "chatUnit":
269
- return [];
173
+ default:
174
+ return assertNever(element);
270
175
  }
271
176
  }
272
177
 
273
178
  /**
274
- * The `header` block — `metadata.title`, the same fact email spends as its
275
- * subject.
179
+ * The title, as the first lines of the message body.
276
180
  *
277
- * A real Slack message leads with its headline; the old subset spent the title
278
- * only on the invisible `text` fallback, so the message opened straight into "Hi
279
- * there," and the reader had to infer what it was about from the body. Both uses
280
- * are correct and neither replaces the other: `text` is what a push notification
281
- * and a screen reader get, `header` is what the message looks like.
181
+ * NOT a `header` block. A header is `plain_text` and Slack owns its typography
182
+ * entirely `HeaderBlock` is `{ type, text }` and there is no font, size,
183
+ * weight or colour anywhere in the schema so a header would be the one line of
184
+ * the notification that could never be set in the brand's face. Putting the
185
+ * title in the monospace body makes it monospace FOR REAL, in a workspace as
186
+ * well as in the gallery, rather than by a stylesheet the gallery applies and
187
+ * Slack ignores.
282
188
  *
283
- * `plain_text` is a literal surface AND Slack's only option here a header does
284
- * not parse mrkdwn so the title is passed through unescaped and truncated to
285
- * Slack's documented 150.
189
+ * What that costs is Slack's large headline treatment, which is what makes a
190
+ * message scannable in a busy channel. The trade is deliberate: the title is
191
+ * still the FALLBACK (`text`), so a push notification and a screen reader
192
+ * announce it exactly as before — the header block was never what carried it
193
+ * there.
194
+ *
195
+ * A rule underneath does the work the type size used to.
286
196
  */
287
- function renderHeader(content: NotificationContent): HeaderBlock[] {
197
+ function renderTitle(content: NotificationContent): KnownBlock[] {
288
198
  const title = content.metadata.title.trim();
289
199
  if (!title) return [];
290
- return [
291
- { type: "header", text: plainText(truncate(title, HEADER_MAX_CHARS)) },
292
- ];
293
- }
294
-
295
- /** Cut to `max` characters, marking the cut so a clipped headline reads as one. */
296
- function truncate(value: string, max: number): string {
297
- return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
200
+ const lines = proseLines(title, GEOMETRY);
201
+ return preformatted([...lines, fullRuleAscii(GEOMETRY)]);
298
202
  }
299
203
 
300
204
  /**
@@ -305,25 +209,27 @@ function truncate(value: string, max: number): string {
305
209
  export const slackRenderer: Renderer<SlackMessage> = {
306
210
  id: "slack",
307
211
  /**
308
- * One decline: `chatUnit`.
212
+ * Slack depicts every element.
309
213
  *
310
- * Block Kit has no vocabulary for turn-taking. Flattening a conversation into a
311
- * run of sections would drop the attribution that IS the meaning, which the
312
- * content model names as the reason to decline the whole element rather than
313
- * approximate it. Everything else Slack can genuinely depict including the
314
- * hero image `../sms` has no surface for, which is the point of having two
315
- * stubs rather than one.
214
+ * `chatUnit` was the one decline, on the grounds that Block Kit has no
215
+ * vocabulary for turn-taking. True of blocks, and beside the point about
216
+ * messages: the kinds that nest a payload inside a `chatUnit` were arriving
217
+ * with the payload missing. A monospace drawing depicts the conversation and
218
+ * keeps the attribution the decline was protecting, so there is nothing left
219
+ * to decline.
220
+ *
221
+ * `assertNever` in `renderElement`, not this answer, is what protects the
222
+ * NEXT element.
316
223
  */
317
- supports: (elementType) => elementType !== "chatUnit",
224
+ supports: () => true,
318
225
  render: (content: NotificationContent, renderContext: RenderContext) => ({
319
226
  text: content.metadata.title,
320
- blocks: [
321
- ...renderHeader(content),
227
+ blocks: coalescePreformatted([
228
+ ...renderTitle(content),
322
229
  ...content.sections
323
230
  .flatMap((section_) => section_.elements)
324
- .filter((element) => slackRenderer.supports(element.type))
325
231
  .flatMap((element) => renderElement(element, renderContext)),
326
- ],
232
+ ]),
327
233
  }),
328
234
  };
329
235
 
@@ -343,4 +249,5 @@ export type {
343
249
  PlainTextElement,
344
250
  RichTextBlock,
345
251
  SectionBlock,
346
- };
252
+ TableBlock,
253
+ } from "@slack/types";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * This channel's natural output type.
3
+ *
4
+ * In its own module so `./index.ts`, `./chat.ts` and the backend's delivery can
5
+ * all name it without importing the renderer itself.
6
+ */
7
+
8
+ import type { KnownBlock } from "@slack/types";
9
+
10
+ /**
11
+ * One Slack message.
12
+ *
13
+ * `blocks` is `KnownBlock[]` — Slack's own union, not ours. `text` is the
14
+ * notification-and-fallback string Slack shows where blocks cannot render (a push
15
+ * notification, a screen reader). It is `metadata.title`, which is the same field
16
+ * email spends as its subject and this channel ALSO spends as its `header` block:
17
+ * one content-level fact, three channel-level uses, which is why it lives on the
18
+ * content rather than in a channel's registry.
19
+ */
20
+ export interface SlackMessage {
21
+ text: string;
22
+ blocks: KnownBlock[];
23
+ }