@opengeni/api-router 0.14.4 → 0.15.4

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.
@@ -96,10 +96,14 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
96
96
  signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
97
97
  });
98
98
  } catch {
99
- throw new HTTPException(502, { message: "Slack installation token exchange failed" });
99
+ throw new HTTPException(502, {
100
+ message: "Slack installation token exchange failed",
101
+ });
100
102
  }
101
103
  if (!response.ok) {
102
- throw new HTTPException(502, { message: "Slack installation token exchange failed" });
104
+ throw new HTTPException(502, {
105
+ message: "Slack installation token exchange failed",
106
+ });
103
107
  }
104
108
  const payload = await readResponseJsonBounded<unknown>(
105
109
  response,
@@ -112,7 +116,9 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
112
116
  }
113
117
  const accessToken = slackString(record.access_token);
114
118
  if (!accessToken?.startsWith("xoxb-")) {
115
- throw new HTTPException(502, { message: "Slack installation did not return a bot token" });
119
+ throw new HTTPException(502, {
120
+ message: "Slack installation did not return a bot token",
121
+ });
116
122
  }
117
123
  return accessToken;
118
124
  }
@@ -147,7 +153,10 @@ type SlackBotContext = {
147
153
  };
148
154
 
149
155
  export class SlackBotProviderError extends Error {
150
- constructor(readonly code: string) {
156
+ constructor(
157
+ readonly code: string,
158
+ readonly retryAfterMs: number | null = null,
159
+ ) {
151
160
  super(`Slack bot request failed: ${safeSlackCode(code)}`);
152
161
  this.name = "SlackBotProviderError";
153
162
  }
@@ -342,6 +351,11 @@ export class OpenGeniSlackBotClient {
342
351
  });
343
352
  }
344
353
 
354
+ async verifyChannelAccess(channelId: string) {
355
+ const headers = await this.headersFor("channel_history.read");
356
+ return await this.requireMemberChannel(headers, channelId);
357
+ }
358
+
345
359
  async channelHistory(input: { channelId: string; limit?: number; cursor?: string }) {
346
360
  return await this.withAudit("channel_history.read", async (headers) => {
347
361
  const info = await this.requireMemberChannel(headers, input.channelId);
@@ -484,7 +498,9 @@ export class OpenGeniSlackBotClient {
484
498
  const headers = await this.headersFor(operation);
485
499
  let channelId = input.channelId;
486
500
  if (input.userId) {
487
- const opened = await this.call(headers, "conversations.open", { users: input.userId });
501
+ const opened = await this.call(headers, "conversations.open", {
502
+ users: input.userId,
503
+ });
488
504
  channelId = requiredSlackString(slackRecord(opened.channel)?.id, "channel.id");
489
505
  } else if (channelId) {
490
506
  await this.requireMemberChannel(headers, channelId);
@@ -704,7 +720,10 @@ export class OpenGeniSlackBotClient {
704
720
  `${SLACK_API_BASE}chat.getPermalink`,
705
721
  );
706
722
  try {
707
- await this.call(headers, "chat.getPermalink", { channel: channelId, message_ts: timestamp });
723
+ await this.call(headers, "chat.getPermalink", {
724
+ channel: channelId,
725
+ message_ts: timestamp,
726
+ });
708
727
  return true;
709
728
  } catch (error) {
710
729
  if (error instanceof SlackBotProviderError && error.code === "message_not_found") {
@@ -715,9 +734,14 @@ export class OpenGeniSlackBotClient {
715
734
  }
716
735
 
717
736
  private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
718
- const payload = await this.call(headers, "conversations.info", { channel: channelId });
737
+ const payload = await this.call(headers, "conversations.info", {
738
+ channel: channelId,
739
+ });
719
740
  const projected = projectChannel(payload.channel);
720
- if (!projected || projected.isMember !== true) {
741
+ // Slack omits `is_member` for a bot's one-to-one App Home conversation.
742
+ // A successful conversations.info response with `is_im` is itself proof
743
+ // that this installation can address that direct conversation.
744
+ if (!projected || (projected.isMember !== true && projected.isDirectMessage !== true)) {
721
745
  throw new SlackBotProviderError("not_in_channel");
722
746
  }
723
747
  return projected;
@@ -946,7 +970,10 @@ export class OpenGeniSlackBotClient {
946
970
  }
947
971
 
948
972
  private completedDeleteResult(
949
- operation: { slackChannelId: string | null; slackMessageTimestamp: string | null },
973
+ operation: {
974
+ slackChannelId: string | null;
975
+ slackMessageTimestamp: string | null;
976
+ },
950
977
  operationId: string,
951
978
  ) {
952
979
  if (!operation.slackChannelId || !operation.slackMessageTimestamp) {
@@ -1008,9 +1035,15 @@ export class OpenGeniSlackBotClient {
1008
1035
  return { type: "subject", id: this.context.subjectId };
1009
1036
  }
1010
1037
  if (this.context.scheduledTaskId) {
1011
- return { type: "service", id: `scheduler:${this.context.scheduledTaskId}` };
1038
+ return {
1039
+ type: "service",
1040
+ id: `scheduler:${this.context.scheduledTaskId}`,
1041
+ };
1012
1042
  }
1013
- return { type: "service", id: `session:${this.context.sessionId ?? "workspace"}` };
1043
+ return {
1044
+ type: "service",
1045
+ id: `session:${this.context.sessionId ?? "workspace"}`,
1046
+ };
1014
1047
  }
1015
1048
 
1016
1049
  private fileListPage(input: {
@@ -1020,13 +1053,19 @@ export class OpenGeniSlackBotClient {
1020
1053
  }): SlackFilesListPage {
1021
1054
  const key = environmentsEncryptionKeyBytes(this.settings);
1022
1055
  if (!key) throw new Error("connection encryption is not configured");
1023
- return resolveSlackFilesListPage(input, { connectionId: this.connection.id, key });
1056
+ return resolveSlackFilesListPage(input, {
1057
+ connectionId: this.connection.id,
1058
+ key,
1059
+ });
1024
1060
  }
1025
1061
 
1026
1062
  private fileListCursor(input: { channelId: string; count: number; page: number }): string {
1027
1063
  const key = environmentsEncryptionKeyBytes(this.settings);
1028
1064
  if (!key) throw new Error("connection encryption is not configured");
1029
- return createSlackFilesListCursor(input, { connectionId: this.connection.id, key });
1065
+ return createSlackFilesListCursor(input, {
1066
+ connectionId: this.connection.id,
1067
+ key,
1068
+ });
1030
1069
  }
1031
1070
 
1032
1071
  private async recordAudit(
@@ -1076,6 +1115,42 @@ export function createOpenGeniSlackBotClient(
1076
1115
  );
1077
1116
  }
1078
1117
 
1118
+ export async function createOpenGeniSlackBotInteractionClient(
1119
+ deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
1120
+ input: {
1121
+ accountId: string;
1122
+ workspaceId: string;
1123
+ connectionId: string;
1124
+ subjectId: string;
1125
+ sessionId?: string | null;
1126
+ },
1127
+ ): Promise<OpenGeniSlackBotClient> {
1128
+ const connection = await requireOpenGeniSlackBotConnection(
1129
+ deps.db,
1130
+ input.workspaceId,
1131
+ input.connectionId,
1132
+ );
1133
+ if (connection.accountId !== input.accountId) {
1134
+ throw new Error("OpenGeni Slack bot connection tenant mismatch");
1135
+ }
1136
+ const metadata = openGeniSlackBotMetadata(connection.metadata);
1137
+ if (!metadata) throw new Error("OpenGeni Slack bot connection metadata is invalid");
1138
+ return new OpenGeniSlackBotClient(
1139
+ deps.db,
1140
+ deps.settings,
1141
+ connection,
1142
+ metadata,
1143
+ {
1144
+ accountId: input.accountId,
1145
+ workspaceId: input.workspaceId,
1146
+ subjectId: input.subjectId,
1147
+ sessionId: input.sessionId ?? null,
1148
+ scheduledTaskId: null,
1149
+ },
1150
+ deps.slackFetch,
1151
+ );
1152
+ }
1153
+
1079
1154
  async function slackApiFetch(
1080
1155
  fetchImpl: FetchLike,
1081
1156
  method: string,
@@ -1117,8 +1192,9 @@ async function slackApiFetchWithHeaders(
1117
1192
  throw new SlackBotProviderError("transport_error");
1118
1193
  }
1119
1194
  if (!response.ok) {
1195
+ const retryAfterMs = slackRetryAfterMs(response);
1120
1196
  await response.body?.cancel().catch(() => undefined);
1121
- throw new SlackBotProviderError(`http_${response.status}`);
1197
+ throw new SlackBotProviderError(`http_${response.status}`, retryAfterMs);
1122
1198
  }
1123
1199
  let payload: SlackPayload;
1124
1200
  try {
@@ -1136,6 +1212,15 @@ async function slackApiFetchWithHeaders(
1136
1212
  return { response, payload };
1137
1213
  }
1138
1214
 
1215
+ function slackRetryAfterMs(response: Response): number | null {
1216
+ if (response.status !== 429) return null;
1217
+ const raw = response.headers.get("retry-after");
1218
+ if (!raw || !/^\d+$/.test(raw)) return null;
1219
+ const seconds = Number(raw);
1220
+ if (!Number.isSafeInteger(seconds) || seconds < 1) return null;
1221
+ return Math.min(seconds, 3_600) * 1_000;
1222
+ }
1223
+
1139
1224
  function assertOpenGeniSlackBotScopes(grantedScopes: string[]): void {
1140
1225
  const policy = evaluateOpenGeniSlackBotScopes(grantedScopes);
1141
1226
  if (!policy.accepted) {
@@ -1176,6 +1261,7 @@ function projectChannel(value: unknown) {
1176
1261
  name: boundedSlackString(channel.name, 256),
1177
1262
  isPrivate: channel.is_private === true,
1178
1263
  isMember: channel.is_member === true,
1264
+ isDirectMessage: channel.is_im === true,
1179
1265
  isArchived: channel.is_archived === true,
1180
1266
  topic: boundedSlackString(slackRecord(channel.topic)?.value, 1_024),
1181
1267
  purpose: boundedSlackString(slackRecord(channel.purpose)?.value, 1_024),