@opengeni/api-router 0.16.4 → 0.17.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.
@@ -46,6 +46,18 @@ const SLACK_TIMEOUT_MS = 10_000;
46
46
  const MAX_CHANNEL_PAGE = 200;
47
47
  const MAX_HISTORY_PAGE = 100;
48
48
  const MAX_THREAD_PAGE = 100;
49
+ const MAX_REACTION_CONTEXT_MESSAGES = 15;
50
+ const MAX_REACTION_CONTEXT_PAGES = 8;
51
+ const MAX_REACTION_CONTEXT_SEEN_MESSAGES =
52
+ MAX_REACTION_CONTEXT_MESSAGES * MAX_REACTION_CONTEXT_PAGES;
53
+ // Leave headroom for PostgreSQL jsonb's canonical text spacing under the
54
+ // database's independent 128 KiB CHECK constraint.
55
+ const MAX_REACTION_CONTEXT_CHECKPOINT_BYTES = 120 * 1024;
56
+ const MAX_REACTION_CONTEXT_CHECKPOINT_AGE_MS = 24 * 60 * 60_000;
57
+ const MAX_REACTION_CONTEXT_CHECKPOINT_CLOCK_SKEW_MS = 5 * 60_000;
58
+ const MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS = 1_500;
59
+ const MAX_REACTION_CONTEXT_CHECKPOINT_FILES = 16;
60
+ const SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION = 1;
49
61
  const MAX_USER_PAGE = 200;
50
62
  const MAX_FILE_PAGE = 200;
51
63
  const MAX_FILE_CURSOR_LENGTH = 1_024;
@@ -152,6 +164,45 @@ type SlackBotContext = {
152
164
  scheduledTaskId?: string | null;
153
165
  };
154
166
 
167
+ export type SlackReactionContextCheckpointBinding = {
168
+ inboxId: string;
169
+ accountId: string;
170
+ workspaceId: string;
171
+ connectionId: string;
172
+ providerEventId: string;
173
+ providerMessageId: string;
174
+ slackTeamId: string;
175
+ slackChannelId: string;
176
+ slackMessageTs: string;
177
+ };
178
+
179
+ type SlackReactionCheckpointMessage = {
180
+ timestamp: string;
181
+ userId: string;
182
+ botId: string;
183
+ threadTimestamp: string;
184
+ text: string;
185
+ files: Array<{ id: string; label: string }>;
186
+ };
187
+
188
+ type SlackReactionContextCheckpointUnsigned = {
189
+ version: typeof SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION;
190
+ binding: SlackReactionContextCheckpointBinding;
191
+ state: {
192
+ createdAtMs: number;
193
+ pageCount: number;
194
+ nextCursor: string;
195
+ seenCursors: string[];
196
+ seenMessageTimestamps: string[];
197
+ threadTimestamp: string | null;
198
+ messages: SlackReactionCheckpointMessage[];
199
+ };
200
+ };
201
+
202
+ export type SlackReactionContextCheckpoint = SlackReactionContextCheckpointUnsigned & {
203
+ signature: string;
204
+ };
205
+
155
206
  export class SlackBotProviderError extends Error {
156
207
  constructor(
157
208
  readonly code: string,
@@ -395,6 +446,117 @@ export class OpenGeniSlackBotClient {
395
446
  });
396
447
  }
397
448
 
449
+ async reactionMessageContext(input: {
450
+ channelId: string;
451
+ messageTimestamp: string;
452
+ checkpoint: unknown | null;
453
+ checkpointBinding: SlackReactionContextCheckpointBinding;
454
+ saveCheckpoint: (checkpoint: SlackReactionContextCheckpoint) => Promise<void>;
455
+ }) {
456
+ return await this.withAudit("thread_replies.read", async (headers) => {
457
+ const checkpointKey = environmentsEncryptionKeyBytes(this.settings);
458
+ if (!checkpointKey) throw new Error("connection encryption is not configured");
459
+ assertSlackReactionCheckpointBinding(
460
+ input.checkpointBinding,
461
+ this.context,
462
+ this.connection.id,
463
+ this.metadata.slackTeamId,
464
+ input.channelId,
465
+ input.messageTimestamp,
466
+ );
467
+ const restored = input.checkpoint
468
+ ? parseSlackReactionContextCheckpoint(
469
+ input.checkpoint,
470
+ input.checkpointBinding,
471
+ checkpointKey,
472
+ )
473
+ : null;
474
+ const info = await this.requireMemberChannel(headers, input.channelId);
475
+ if (info.isShared || info.isExternallyShared || info.isOrgShared) {
476
+ throw new SlackBotProviderError("slack_connect_unsupported");
477
+ }
478
+ const messages: ReturnType<typeof projectMessage>[] = restored
479
+ ? restored.state.messages.map(projectSlackReactionCheckpointMessage)
480
+ : [];
481
+ const seenMessageTimestamps = new Set(restored?.state.seenMessageTimestamps ?? []);
482
+ const seenCursors = new Set(restored?.state.seenCursors ?? []);
483
+ let cursor: string | null = restored?.state.nextCursor ?? null;
484
+ let nextCursor: string | null = cursor;
485
+ let threadTimestamp: string | null = restored?.state.threadTimestamp ?? null;
486
+ let reactedMessage: ReturnType<typeof projectMessage> | null = null;
487
+ const checkpointCreatedAtMs = restored?.state.createdAtMs ?? Date.now();
488
+ const firstPage = restored?.state.pageCount ?? 0;
489
+
490
+ for (let page = firstPage; page < MAX_REACTION_CONTEXT_PAGES; page += 1) {
491
+ const payload = await this.call(headers, "conversations.replies", {
492
+ channel: input.channelId,
493
+ // Slack accepts either the parent timestamp or a message timestamp from
494
+ // inside the thread and returns the containing thread.
495
+ ts: input.messageTimestamp,
496
+ limit: String(MAX_REACTION_CONTEXT_MESSAGES),
497
+ ...(cursor ? { cursor } : {}),
498
+ });
499
+ const pageMessages = slackArray(payload.messages)
500
+ .map(projectMessage)
501
+ .filter((message) => message.timestamp.length > 0);
502
+ const first = pageMessages[0];
503
+ threadTimestamp ??= first?.threadTimestamp || first?.timestamp || null;
504
+ for (const message of pageMessages) {
505
+ if (seenMessageTimestamps.has(message.timestamp)) continue;
506
+ seenMessageTimestamps.add(message.timestamp);
507
+ messages.push(message);
508
+ }
509
+ reactedMessage =
510
+ reactedMessage ??
511
+ pageMessages.find((message) => message.timestamp === input.messageTimestamp) ??
512
+ null;
513
+ nextCursor = responseCursor(payload);
514
+ if (reactedMessage || !nextCursor) break;
515
+ if (seenCursors.has(nextCursor)) {
516
+ throw new SlackBotProviderError("reaction_pagination_invalid");
517
+ }
518
+ seenCursors.add(nextCursor);
519
+ const pageCount = page + 1;
520
+ if (pageCount >= MAX_REACTION_CONTEXT_PAGES) {
521
+ throw new SlackBotProviderError("reaction_pagination_exhausted");
522
+ }
523
+ const retainedMessages = selectSlackReactionCheckpointMessages(messages);
524
+ messages.splice(0, messages.length, ...retainedMessages);
525
+ await input.saveCheckpoint(
526
+ createSlackReactionContextCheckpoint(
527
+ input.checkpointBinding,
528
+ {
529
+ createdAtMs: checkpointCreatedAtMs,
530
+ pageCount,
531
+ nextCursor,
532
+ seenCursors: [...seenCursors],
533
+ seenMessageTimestamps: [...seenMessageTimestamps],
534
+ threadTimestamp,
535
+ messages: retainedMessages.map(slackReactionCheckpointMessage),
536
+ },
537
+ checkpointKey,
538
+ ),
539
+ );
540
+ cursor = nextCursor;
541
+ }
542
+
543
+ if (!reactedMessage || !threadTimestamp) {
544
+ throw new SlackBotProviderError("message_not_found");
545
+ }
546
+ const boundedMessages = selectSlackReactionContextMessages(
547
+ messages,
548
+ reactedMessage.timestamp,
549
+ );
550
+ return {
551
+ channel: info,
552
+ threadTimestamp,
553
+ reactedMessage,
554
+ messages: boundedMessages,
555
+ truncated: nextCursor !== null || seenMessageTimestamps.size > boundedMessages.length,
556
+ };
557
+ });
558
+ }
559
+
398
560
  async listUsers(input: { limit?: number; cursor?: string } = {}) {
399
561
  return await this.withAudit("users.list", async (headers) => {
400
562
  const payload = await this.call(headers, "users.list", {
@@ -1263,6 +1425,9 @@ function projectChannel(value: unknown) {
1263
1425
  isMember: channel.is_member === true,
1264
1426
  isDirectMessage: channel.is_im === true,
1265
1427
  isArchived: channel.is_archived === true,
1428
+ isShared: channel.is_shared === true,
1429
+ isExternallyShared: channel.is_ext_shared === true,
1430
+ isOrgShared: channel.is_org_shared === true,
1266
1431
  topic: boundedSlackString(slackRecord(channel.topic)?.value, 1_024),
1267
1432
  purpose: boundedSlackString(slackRecord(channel.purpose)?.value, 1_024),
1268
1433
  numMembers:
@@ -1286,6 +1451,377 @@ function projectMessage(value: unknown) {
1286
1451
  };
1287
1452
  }
1288
1453
 
1454
+ function assertSlackReactionCheckpointBinding(
1455
+ binding: SlackReactionContextCheckpointBinding,
1456
+ context: SlackBotContext,
1457
+ connectionId: string,
1458
+ slackTeamId: string,
1459
+ channelId: string,
1460
+ messageTimestamp: string,
1461
+ ): void {
1462
+ if (
1463
+ binding.accountId !== context.accountId ||
1464
+ binding.workspaceId !== context.workspaceId ||
1465
+ binding.connectionId !== connectionId ||
1466
+ binding.slackTeamId !== slackTeamId ||
1467
+ binding.slackChannelId !== channelId ||
1468
+ binding.slackMessageTs !== messageTimestamp
1469
+ ) {
1470
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1471
+ }
1472
+ }
1473
+
1474
+ function createSlackReactionContextCheckpoint(
1475
+ binding: SlackReactionContextCheckpointBinding,
1476
+ state: SlackReactionContextCheckpointUnsigned["state"],
1477
+ key: Uint8Array,
1478
+ ): SlackReactionContextCheckpoint {
1479
+ const unsigned: SlackReactionContextCheckpointUnsigned = {
1480
+ version: SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION,
1481
+ binding: { ...binding },
1482
+ state: {
1483
+ createdAtMs: state.createdAtMs,
1484
+ pageCount: state.pageCount,
1485
+ nextCursor: state.nextCursor,
1486
+ seenCursors: [...state.seenCursors],
1487
+ seenMessageTimestamps: [...state.seenMessageTimestamps],
1488
+ threadTimestamp: state.threadTimestamp,
1489
+ messages: state.messages.map((message) => ({
1490
+ ...message,
1491
+ files: message.files.map((file) => ({ ...file })),
1492
+ })),
1493
+ },
1494
+ };
1495
+ const checkpoint: SlackReactionContextCheckpoint = {
1496
+ ...unsigned,
1497
+ signature: slackReactionContextCheckpointSignature(unsigned, key),
1498
+ };
1499
+ if (
1500
+ Buffer.byteLength(JSON.stringify(checkpoint), "utf8") > MAX_REACTION_CONTEXT_CHECKPOINT_BYTES
1501
+ ) {
1502
+ throw new SlackBotProviderError("reaction_checkpoint_too_large");
1503
+ }
1504
+ return checkpoint;
1505
+ }
1506
+
1507
+ function parseSlackReactionContextCheckpoint(
1508
+ value: unknown,
1509
+ expectedBinding: SlackReactionContextCheckpointBinding,
1510
+ key: Uint8Array,
1511
+ nowMs = Date.now(),
1512
+ ): SlackReactionContextCheckpoint {
1513
+ const checkpoint = slackRecord(value);
1514
+ if (
1515
+ !checkpoint ||
1516
+ !hasExactSlackCheckpointKeys(checkpoint, ["binding", "signature", "state", "version"]) ||
1517
+ Buffer.byteLength(JSON.stringify(checkpoint), "utf8") > MAX_REACTION_CONTEXT_CHECKPOINT_BYTES ||
1518
+ checkpoint.version !== SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION
1519
+ ) {
1520
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1521
+ }
1522
+ const bindingValue = slackRecord(checkpoint.binding);
1523
+ const stateValue = slackRecord(checkpoint.state);
1524
+ const signature = exactSlackCheckpointString(checkpoint.signature, 64);
1525
+ if (
1526
+ !bindingValue ||
1527
+ !stateValue ||
1528
+ !signature ||
1529
+ !/^[0-9a-f]{64}$/.test(signature) ||
1530
+ !hasExactSlackCheckpointKeys(bindingValue, [
1531
+ "accountId",
1532
+ "connectionId",
1533
+ "inboxId",
1534
+ "providerEventId",
1535
+ "providerMessageId",
1536
+ "slackChannelId",
1537
+ "slackMessageTs",
1538
+ "slackTeamId",
1539
+ "workspaceId",
1540
+ ]) ||
1541
+ !hasExactSlackCheckpointKeys(stateValue, [
1542
+ "createdAtMs",
1543
+ "messages",
1544
+ "nextCursor",
1545
+ "pageCount",
1546
+ "seenCursors",
1547
+ "seenMessageTimestamps",
1548
+ "threadTimestamp",
1549
+ ])
1550
+ ) {
1551
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1552
+ }
1553
+ const binding: SlackReactionContextCheckpointBinding = {
1554
+ inboxId: requiredSlackCheckpointString(bindingValue.inboxId, 64),
1555
+ accountId: requiredSlackCheckpointString(bindingValue.accountId, 64),
1556
+ workspaceId: requiredSlackCheckpointString(bindingValue.workspaceId, 64),
1557
+ connectionId: requiredSlackCheckpointString(bindingValue.connectionId, 64),
1558
+ providerEventId: requiredSlackCheckpointString(bindingValue.providerEventId, 256),
1559
+ providerMessageId: requiredSlackCheckpointString(bindingValue.providerMessageId, 256),
1560
+ slackTeamId: requiredSlackCheckpointString(bindingValue.slackTeamId, 64),
1561
+ slackChannelId: requiredSlackCheckpointString(bindingValue.slackChannelId, 64),
1562
+ slackMessageTs: requiredSlackCheckpointString(bindingValue.slackMessageTs, 64),
1563
+ };
1564
+ if (!slackReactionCheckpointBindingMatches(binding, expectedBinding)) {
1565
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1566
+ }
1567
+ const createdAtMs = stateValue.createdAtMs;
1568
+ const pageCount = stateValue.pageCount;
1569
+ const nextCursor = exactSlackCheckpointString(stateValue.nextCursor, 1_024);
1570
+ const threadTimestamp =
1571
+ stateValue.threadTimestamp === null
1572
+ ? null
1573
+ : exactSlackCheckpointString(stateValue.threadTimestamp, 64);
1574
+ if (
1575
+ typeof createdAtMs !== "number" ||
1576
+ !Number.isSafeInteger(createdAtMs) ||
1577
+ createdAtMs > nowMs + MAX_REACTION_CONTEXT_CHECKPOINT_CLOCK_SKEW_MS ||
1578
+ createdAtMs < nowMs - MAX_REACTION_CONTEXT_CHECKPOINT_AGE_MS ||
1579
+ typeof pageCount !== "number" ||
1580
+ !Number.isSafeInteger(pageCount) ||
1581
+ pageCount < 1 ||
1582
+ pageCount >= MAX_REACTION_CONTEXT_PAGES ||
1583
+ !nextCursor ||
1584
+ (threadTimestamp === "" && stateValue.threadTimestamp !== null) ||
1585
+ !Array.isArray(stateValue.seenCursors) ||
1586
+ !Array.isArray(stateValue.seenMessageTimestamps) ||
1587
+ !Array.isArray(stateValue.messages)
1588
+ ) {
1589
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1590
+ }
1591
+ const seenCursors = stateValue.seenCursors.map((cursor) =>
1592
+ requiredSlackCheckpointString(cursor, 1_024),
1593
+ );
1594
+ const seenMessageTimestamps = stateValue.seenMessageTimestamps.map((timestamp) =>
1595
+ requiredSlackCheckpointString(timestamp, 64),
1596
+ );
1597
+ if (
1598
+ seenCursors.length !== pageCount ||
1599
+ seenCursors.length > MAX_REACTION_CONTEXT_PAGES ||
1600
+ new Set(seenCursors).size !== seenCursors.length ||
1601
+ seenCursors.at(-1) !== nextCursor ||
1602
+ seenMessageTimestamps.length > MAX_REACTION_CONTEXT_SEEN_MESSAGES ||
1603
+ new Set(seenMessageTimestamps).size !== seenMessageTimestamps.length ||
1604
+ seenMessageTimestamps.includes(expectedBinding.slackMessageTs) ||
1605
+ stateValue.messages.length > MAX_REACTION_CONTEXT_MESSAGES
1606
+ ) {
1607
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1608
+ }
1609
+ const messages = stateValue.messages.map(parseSlackReactionCheckpointMessage);
1610
+ const seenTimestampIndexes = messages.map((message) =>
1611
+ seenMessageTimestamps.indexOf(message.timestamp),
1612
+ );
1613
+ if (
1614
+ (seenMessageTimestamps.length > 0 && messages.length === 0) ||
1615
+ (messages.length > 0 && threadTimestamp === null) ||
1616
+ (messages.length > 0 && messages[0]!.timestamp !== seenMessageTimestamps[0]) ||
1617
+ seenTimestampIndexes.some(
1618
+ (index, position) =>
1619
+ index < 0 || (position > 0 && index <= seenTimestampIndexes[position - 1]!),
1620
+ )
1621
+ ) {
1622
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1623
+ }
1624
+ const unsigned: SlackReactionContextCheckpointUnsigned = {
1625
+ version: SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION,
1626
+ binding,
1627
+ state: {
1628
+ createdAtMs,
1629
+ pageCount,
1630
+ nextCursor,
1631
+ seenCursors,
1632
+ seenMessageTimestamps,
1633
+ threadTimestamp,
1634
+ messages,
1635
+ },
1636
+ };
1637
+ const expectedSignature = slackReactionContextCheckpointSignature(unsigned, key);
1638
+ const actualBytes = Buffer.from(signature, "utf8");
1639
+ const expectedBytes = Buffer.from(expectedSignature, "utf8");
1640
+ if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes)) {
1641
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1642
+ }
1643
+ return { ...unsigned, signature };
1644
+ }
1645
+
1646
+ function slackReactionContextCheckpointSignature(
1647
+ checkpoint: SlackReactionContextCheckpointUnsigned,
1648
+ key: Uint8Array,
1649
+ ): string {
1650
+ return createHmac("sha256", key).update(JSON.stringify(checkpoint)).digest("hex");
1651
+ }
1652
+
1653
+ function slackReactionCheckpointBindingMatches(
1654
+ left: SlackReactionContextCheckpointBinding,
1655
+ right: SlackReactionContextCheckpointBinding,
1656
+ ): boolean {
1657
+ return (
1658
+ left.inboxId === right.inboxId &&
1659
+ left.accountId === right.accountId &&
1660
+ left.workspaceId === right.workspaceId &&
1661
+ left.connectionId === right.connectionId &&
1662
+ left.providerEventId === right.providerEventId &&
1663
+ left.providerMessageId === right.providerMessageId &&
1664
+ left.slackTeamId === right.slackTeamId &&
1665
+ left.slackChannelId === right.slackChannelId &&
1666
+ left.slackMessageTs === right.slackMessageTs
1667
+ );
1668
+ }
1669
+
1670
+ function parseSlackReactionCheckpointMessage(value: unknown): SlackReactionCheckpointMessage {
1671
+ const message = slackRecord(value);
1672
+ if (
1673
+ !message ||
1674
+ !hasExactSlackCheckpointKeys(message, [
1675
+ "botId",
1676
+ "files",
1677
+ "text",
1678
+ "threadTimestamp",
1679
+ "timestamp",
1680
+ "userId",
1681
+ ]) ||
1682
+ !Array.isArray(message.files) ||
1683
+ message.files.length > MAX_REACTION_CONTEXT_CHECKPOINT_FILES ||
1684
+ typeof message.timestamp !== "string" ||
1685
+ message.timestamp.length < 1 ||
1686
+ message.timestamp.length > 64 ||
1687
+ typeof message.userId !== "string" ||
1688
+ message.userId.length > 64 ||
1689
+ typeof message.botId !== "string" ||
1690
+ message.botId.length > 64 ||
1691
+ typeof message.threadTimestamp !== "string" ||
1692
+ message.threadTimestamp.length > 64 ||
1693
+ typeof message.text !== "string" ||
1694
+ message.text.length > MAX_PROJECTED_TEXT
1695
+ ) {
1696
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1697
+ }
1698
+ const files = message.files.map((candidate) => {
1699
+ const file = slackRecord(candidate);
1700
+ if (!file || !hasExactSlackCheckpointKeys(file, ["id", "label"])) {
1701
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1702
+ }
1703
+ return {
1704
+ id: requiredSlackCheckpointString(file.id, 64),
1705
+ label: requiredSlackCheckpointString(file.label, 512),
1706
+ };
1707
+ });
1708
+ let fileLabelChars = 0;
1709
+ for (const file of files) {
1710
+ fileLabelChars += file.label.length + (fileLabelChars > 0 ? 2 : 0);
1711
+ }
1712
+ if (fileLabelChars > MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS) {
1713
+ throw new SlackBotProviderError("reaction_checkpoint_invalid");
1714
+ }
1715
+ return {
1716
+ timestamp: message.timestamp,
1717
+ userId: message.userId,
1718
+ botId: message.botId,
1719
+ threadTimestamp: message.threadTimestamp,
1720
+ text: message.text,
1721
+ files,
1722
+ };
1723
+ }
1724
+
1725
+ function slackReactionCheckpointMessage(
1726
+ message: ReturnType<typeof projectMessage>,
1727
+ ): SlackReactionCheckpointMessage {
1728
+ const files: SlackReactionCheckpointMessage["files"] = [];
1729
+ let fileLabelChars = 0;
1730
+ for (const file of message.files) {
1731
+ const label = file.title || file.name || file.id;
1732
+ if (!label) continue;
1733
+ const addedChars = label.length + (files.length > 0 ? 2 : 0);
1734
+ if (
1735
+ files.length >= MAX_REACTION_CONTEXT_CHECKPOINT_FILES ||
1736
+ fileLabelChars + addedChars > MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS
1737
+ ) {
1738
+ break;
1739
+ }
1740
+ files.push({ id: file.id, label });
1741
+ fileLabelChars += addedChars;
1742
+ }
1743
+ return {
1744
+ timestamp: message.timestamp,
1745
+ userId: message.userId,
1746
+ botId: message.botId,
1747
+ threadTimestamp: message.threadTimestamp,
1748
+ text: message.text,
1749
+ files,
1750
+ };
1751
+ }
1752
+
1753
+ function projectSlackReactionCheckpointMessage(
1754
+ message: SlackReactionCheckpointMessage,
1755
+ ): ReturnType<typeof projectMessage> {
1756
+ return {
1757
+ timestamp: message.timestamp,
1758
+ userId: message.userId,
1759
+ botId: message.botId,
1760
+ threadTimestamp: message.threadTimestamp,
1761
+ text: message.text,
1762
+ files: message.files.map((file) => ({
1763
+ id: file.id,
1764
+ name: "",
1765
+ title: file.label,
1766
+ mimetype: "",
1767
+ filetype: "",
1768
+ mode: "",
1769
+ size: null,
1770
+ originatingHuddleId: "",
1771
+ huddleTranscriptFileId: "",
1772
+ })),
1773
+ };
1774
+ }
1775
+
1776
+ function selectSlackReactionCheckpointMessages(
1777
+ messages: ReturnType<typeof projectMessage>[],
1778
+ ): ReturnType<typeof projectMessage>[] {
1779
+ if (messages.length <= MAX_REACTION_CONTEXT_MESSAGES) return [...messages];
1780
+ return [messages[0]!, ...messages.slice(-(MAX_REACTION_CONTEXT_MESSAGES - 1))];
1781
+ }
1782
+
1783
+ function hasExactSlackCheckpointKeys(value: Record<string, unknown>, expected: string[]): boolean {
1784
+ return Object.keys(value).sort().join(",") === [...expected].sort().join(",");
1785
+ }
1786
+
1787
+ function exactSlackCheckpointString(value: unknown, max: number): string {
1788
+ return typeof value === "string" && value.length <= max ? value : "";
1789
+ }
1790
+
1791
+ function requiredSlackCheckpointString(value: unknown, max: number): string {
1792
+ const result = exactSlackCheckpointString(value, max);
1793
+ if (!result) throw new SlackBotProviderError("reaction_checkpoint_invalid");
1794
+ return result;
1795
+ }
1796
+
1797
+ function selectSlackReactionContextMessages(
1798
+ messages: ReturnType<typeof projectMessage>[],
1799
+ reactedTimestamp: string,
1800
+ ) {
1801
+ if (messages.length <= MAX_REACTION_CONTEXT_MESSAGES) return messages;
1802
+ const reactedIndex = messages.findIndex((message) => message.timestamp === reactedTimestamp);
1803
+ if (reactedIndex < 0) return [];
1804
+
1805
+ const selected = new Set<number>([0, reactedIndex]);
1806
+ for (
1807
+ let distance = 1;
1808
+ selected.size < MAX_REACTION_CONTEXT_MESSAGES && distance < messages.length;
1809
+ distance += 1
1810
+ ) {
1811
+ const before = reactedIndex - distance;
1812
+ const after = reactedIndex + distance;
1813
+ if (before > 0) selected.add(before);
1814
+ if (selected.size < MAX_REACTION_CONTEXT_MESSAGES && after < messages.length) {
1815
+ selected.add(after);
1816
+ }
1817
+ }
1818
+ for (let index = 0; selected.size < MAX_REACTION_CONTEXT_MESSAGES; index += 1) {
1819
+ if (index >= messages.length) break;
1820
+ selected.add(index);
1821
+ }
1822
+ return [...selected].sort((left, right) => left - right).map((index) => messages[index]!);
1823
+ }
1824
+
1289
1825
  function projectFile(value: unknown) {
1290
1826
  const file = slackRecord(value);
1291
1827
  const id = slackString(file?.id);