@opengeni/api-router 0.11.8 → 0.12.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.
@@ -0,0 +1,694 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
3
+ import {
4
+ OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
5
+ OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
6
+ OPENGENI_SLACK_BOT_FORBIDDEN_SCOPES,
7
+ OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
8
+ type AccessGrant,
9
+ type ConnectionMetadata,
10
+ type OpenGeniSlackBotConnectionMetadata,
11
+ } from "@opengeni/contracts";
12
+ import {
13
+ isTrustedScheduledSlackBotSession,
14
+ openGeniSlackBotMetadata,
15
+ requireOpenGeniSlackBotConnection,
16
+ scheduledSlackBotConnectionId,
17
+ } from "@opengeni/core";
18
+ import {
19
+ buildConnectionTokenResolver,
20
+ claimSlackBotPostOperation,
21
+ completeSlackBotPostOperation,
22
+ getSession,
23
+ recordAuditEvent,
24
+ releaseSlackBotPostOperationClaim,
25
+ type Database,
26
+ } from "@opengeni/db";
27
+ import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
28
+ import { HTTPException } from "hono/http-exception";
29
+
30
+ const SLACK_API_BASE = "https://slack.com/api/";
31
+ const SLACK_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
32
+ const SLACK_TIMEOUT_MS = 10_000;
33
+ const MAX_CHANNEL_PAGE = 200;
34
+ const MAX_HISTORY_PAGE = 100;
35
+ const MAX_USER_PAGE = 200;
36
+ const MAX_PROJECTED_TEXT = 4_000;
37
+ const SLACK_POST_CLAIM_LEASE_MS = 30_000;
38
+
39
+ type SlackPayload = Record<string, unknown> & { ok?: unknown; error?: unknown };
40
+
41
+ export type VerifiedOpenGeniSlackBot = {
42
+ grantedScopes: string[];
43
+ metadata: OpenGeniSlackBotConnectionMetadata;
44
+ };
45
+
46
+ export type SlackBotReceipt = {
47
+ credentialRole: typeof OPENGENI_SLACK_BOT_CREDENTIAL_ROLE;
48
+ credentialLabel: typeof OPENGENI_SLACK_BOT_CREDENTIAL_LABEL;
49
+ connectionId: string;
50
+ slackTeamId: string;
51
+ operation: SlackBotOperation;
52
+ operationId?: string;
53
+ clientMessageId?: string;
54
+ };
55
+
56
+ type SlackBotOperation = "channels.list" | "channel_history.read" | "users.list" | "message.post";
57
+
58
+ type SlackBotContext = {
59
+ accountId: string;
60
+ workspaceId: string;
61
+ subjectId: string | null;
62
+ sessionId?: string | null;
63
+ scheduledTaskId?: string | null;
64
+ };
65
+
66
+ export class SlackBotProviderError extends Error {
67
+ constructor(readonly code: string) {
68
+ super(`Slack bot request failed: ${safeSlackCode(code)}`);
69
+ this.name = "SlackBotProviderError";
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Validates a write-only xoxb credential before it can enter encrypted storage.
75
+ * Slack does not expose the app's display_information name to this scope set;
76
+ * users.info provides the authoritative installed bot display name, while the
77
+ * documented manifest fixes the app name itself to the same exact value.
78
+ */
79
+ export async function verifyOpenGeniSlackBotCredential(
80
+ token: string,
81
+ fetchImpl: FetchLike = fetch,
82
+ now: Date = new Date(),
83
+ ): Promise<VerifiedOpenGeniSlackBot> {
84
+ const authResponse = await slackApiFetch(fetchImpl, "auth.test", token, {});
85
+ const grantedScopes = parseGrantedScopes(authResponse.response.headers.get("x-oauth-scopes"));
86
+ assertExactOpenGeniSlackBotScopes(grantedScopes);
87
+ const auth = authResponse.payload;
88
+ const slackTeamId = requiredSlackString(auth.team_id, "team_id");
89
+ const slackTeamName = requiredSlackString(auth.team, "team");
90
+ const botUserId = requiredSlackString(auth.user_id, "user_id");
91
+ const botId = requiredSlackString(auth.bot_id, "bot_id");
92
+
93
+ const userResponse = await slackApiFetch(fetchImpl, "users.info", token, {
94
+ user: botUserId,
95
+ });
96
+ const user = slackRecord(userResponse.payload.user);
97
+ if (!user || user.is_bot !== true || user.deleted === true) {
98
+ throw new HTTPException(422, { message: "Slack credential must identify an active bot user" });
99
+ }
100
+ const profile = slackRecord(user.profile);
101
+ const displayName = slackString(profile?.display_name) || slackString(profile?.real_name);
102
+ if (displayName !== "OpenGeni") {
103
+ throw new HTTPException(422, {
104
+ message: 'Slack bot display name must be exactly "OpenGeni"',
105
+ });
106
+ }
107
+
108
+ return {
109
+ grantedScopes,
110
+ metadata: {
111
+ credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
112
+ credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
113
+ slackTeamId,
114
+ slackTeamName,
115
+ botUserId,
116
+ botId,
117
+ botDisplayName: "OpenGeni",
118
+ verifiedAt: now.toISOString(),
119
+ },
120
+ };
121
+ }
122
+
123
+ export async function resolveSlackBotConnectionForTool(input: {
124
+ db: Database;
125
+ grant: AccessGrant;
126
+ sessionId: string | null;
127
+ requestedConnectionId?: string;
128
+ }): Promise<{
129
+ connection: ConnectionMetadata;
130
+ metadata: OpenGeniSlackBotConnectionMetadata;
131
+ context: SlackBotContext;
132
+ }> {
133
+ const session = input.sessionId
134
+ ? await getSession(input.db, input.grant.workspaceId, input.sessionId)
135
+ : null;
136
+ if (input.sessionId && !session) {
137
+ throw new Error("signed Slack bot session was not found");
138
+ }
139
+ const boundConnectionId = scheduledSlackBotConnectionId(session?.metadata);
140
+ if (boundConnectionId && (!session || !isTrustedScheduledSlackBotSession(session))) {
141
+ throw new Error("OpenGeni Slack bot routing metadata is not scheduler-authorized");
142
+ }
143
+ if (
144
+ boundConnectionId &&
145
+ input.requestedConnectionId &&
146
+ input.requestedConnectionId !== boundConnectionId
147
+ ) {
148
+ throw new Error("this scheduled session is bound to a different OpenGeni Slack bot connection");
149
+ }
150
+ const connectionId = boundConnectionId ?? input.requestedConnectionId;
151
+ if (!connectionId) {
152
+ throw new Error("connectionId is required outside a Slack-bot-bound scheduled session");
153
+ }
154
+ if (!boundConnectionId && !input.grant.permissions.includes("connections:read")) {
155
+ throw new Error("connections:read is required to select an OpenGeni Slack bot connection");
156
+ }
157
+ const connection = await requireOpenGeniSlackBotConnection(
158
+ input.db,
159
+ input.grant.workspaceId,
160
+ connectionId,
161
+ );
162
+ const metadata = openGeniSlackBotMetadata(connection.metadata);
163
+ if (!metadata) {
164
+ throw new Error("OpenGeni Slack bot connection metadata is invalid");
165
+ }
166
+ return {
167
+ connection,
168
+ metadata,
169
+ context: {
170
+ accountId: input.grant.accountId,
171
+ workspaceId: input.grant.workspaceId,
172
+ subjectId: input.grant.subjectId,
173
+ sessionId: input.sessionId,
174
+ scheduledTaskId:
175
+ typeof session?.metadata.scheduledTaskId === "string"
176
+ ? session.metadata.scheduledTaskId
177
+ : null,
178
+ },
179
+ };
180
+ }
181
+
182
+ export class OpenGeniSlackBotClient {
183
+ private readonly resolveCredential: ReturnType<typeof buildConnectionTokenResolver>;
184
+
185
+ constructor(
186
+ private readonly db: Database,
187
+ private readonly settings: Settings,
188
+ private readonly connection: ConnectionMetadata,
189
+ private readonly metadata: OpenGeniSlackBotConnectionMetadata,
190
+ private readonly context: SlackBotContext,
191
+ private readonly fetchImpl: FetchLike = fetch,
192
+ ) {
193
+ this.resolveCredential = buildConnectionTokenResolver(db, settings);
194
+ }
195
+
196
+ async listChannels(input: { limit?: number; cursor?: string } = {}) {
197
+ return await this.withAudit("channels.list", async (headers) => {
198
+ const payload = await this.call(headers, "conversations.list", {
199
+ types: "public_channel,private_channel",
200
+ exclude_archived: "true",
201
+ limit: String(boundedInt(input.limit, MAX_CHANNEL_PAGE, 100)),
202
+ ...(input.cursor ? { cursor: input.cursor } : {}),
203
+ });
204
+ return {
205
+ channels: slackArray(payload.channels)
206
+ .map(projectChannel)
207
+ .filter((channel): channel is NonNullable<typeof channel> => channel !== null),
208
+ nextCursor: responseCursor(payload),
209
+ };
210
+ });
211
+ }
212
+
213
+ async channelHistory(input: { channelId: string; limit?: number; cursor?: string }) {
214
+ return await this.withAudit("channel_history.read", async (headers) => {
215
+ const info = await this.requireMemberChannel(headers, input.channelId);
216
+ const payload = await this.call(headers, "conversations.history", {
217
+ channel: input.channelId,
218
+ limit: String(boundedInt(input.limit, MAX_HISTORY_PAGE, 50)),
219
+ ...(input.cursor ? { cursor: input.cursor } : {}),
220
+ });
221
+ return {
222
+ channel: info,
223
+ messages: slackArray(payload.messages).map(projectMessage),
224
+ nextCursor: responseCursor(payload),
225
+ };
226
+ });
227
+ }
228
+
229
+ async listUsers(input: { limit?: number; cursor?: string } = {}) {
230
+ return await this.withAudit("users.list", async (headers) => {
231
+ const payload = await this.call(headers, "users.list", {
232
+ limit: String(boundedInt(input.limit, MAX_USER_PAGE, 100)),
233
+ ...(input.cursor ? { cursor: input.cursor } : {}),
234
+ });
235
+ return {
236
+ users: slackArray(payload.members)
237
+ .map(projectUser)
238
+ .filter((user): user is NonNullable<typeof user> => user !== null),
239
+ nextCursor: responseCursor(payload),
240
+ };
241
+ });
242
+ }
243
+
244
+ async postMessage(input: {
245
+ operationId: string;
246
+ channelId?: string;
247
+ userId?: string;
248
+ text: string;
249
+ }) {
250
+ const operation = "message.post" as const;
251
+ const claimHolderId = crypto.randomUUID();
252
+ let claimAcquired = false;
253
+ let providerCallStarted = false;
254
+ try {
255
+ const headers = await this.headersFor(operation);
256
+ let channelId = input.channelId;
257
+ if (input.userId) {
258
+ const opened = await this.call(headers, "conversations.open", { users: input.userId });
259
+ channelId = requiredSlackString(slackRecord(opened.channel)?.id, "channel.id");
260
+ } else if (channelId) {
261
+ await this.requireMemberChannel(headers, channelId);
262
+ }
263
+ if (!channelId) {
264
+ throw new Error("exactly one of channelId or userId is required");
265
+ }
266
+ const targetKind = input.userId ? "user" : "channel";
267
+ const targetId = input.userId ?? input.channelId!;
268
+ const requestDigest = this.postRequestDigest({
269
+ operationId: input.operationId,
270
+ targetKind,
271
+ targetId,
272
+ text: input.text,
273
+ });
274
+ const claim = await claimSlackBotPostOperation(this.db, {
275
+ accountId: this.context.accountId,
276
+ workspaceId: this.context.workspaceId,
277
+ connectionId: this.connection.id,
278
+ operationId: input.operationId,
279
+ targetKind,
280
+ targetId,
281
+ requestDigest,
282
+ claimHolderId,
283
+ claimLeaseMs: SLACK_POST_CLAIM_LEASE_MS,
284
+ });
285
+ if (claim.kind === "connection_not_found") {
286
+ throw new Error("OpenGeni Slack bot connection no longer exists");
287
+ }
288
+ if (claim.kind === "conflict") {
289
+ throw new Error("operationId is already bound to a different Slack post request");
290
+ }
291
+ if (claim.kind === "in_progress") {
292
+ throw new Error("Slack post operation is already in progress; retry the same operationId");
293
+ }
294
+ if (claim.kind === "completed") {
295
+ return this.completedPostResult(claim.operation, input.operationId);
296
+ }
297
+ claimAcquired = true;
298
+ providerCallStarted = true;
299
+ const posted = await this.call(headers, "chat.postMessage", {
300
+ channel: channelId,
301
+ text: input.text,
302
+ client_msg_id: input.operationId,
303
+ });
304
+ const slackChannelId = requiredSlackString(posted.channel, "channel");
305
+ const slackMessageTimestamp = requiredSlackString(posted.ts, "ts");
306
+ const completed = await completeSlackBotPostOperation(this.db, {
307
+ accountId: this.context.accountId,
308
+ workspaceId: this.context.workspaceId,
309
+ connectionId: this.connection.id,
310
+ operationId: input.operationId,
311
+ claimHolderId,
312
+ slackChannelId,
313
+ slackMessageTimestamp,
314
+ subjectId: this.context.subjectId,
315
+ auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId),
316
+ });
317
+ if (completed.kind !== "completed") {
318
+ throw new Error("Slack post completion lost its durable operation claim");
319
+ }
320
+ claimAcquired = false;
321
+ return this.completedPostResult(completed.operation, input.operationId);
322
+ } catch (error) {
323
+ const failureCode = safeFailureCode(error);
324
+ if (claimAcquired) {
325
+ await releaseSlackBotPostOperationClaim(this.db, {
326
+ accountId: this.context.accountId,
327
+ workspaceId: this.context.workspaceId,
328
+ connectionId: this.connection.id,
329
+ operationId: input.operationId,
330
+ claimHolderId,
331
+ failureCode,
332
+ }).catch(() => undefined);
333
+ }
334
+ await this.recordAudit(
335
+ operation,
336
+ providerCallStarted && slackPostOutcomeMayBeAmbiguous(error) ? "ambiguous" : "failed",
337
+ failureCode,
338
+ input.operationId,
339
+ );
340
+ throw error;
341
+ }
342
+ }
343
+
344
+ private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
345
+ const payload = await this.call(headers, "conversations.info", { channel: channelId });
346
+ const projected = projectChannel(payload.channel);
347
+ if (!projected || projected.isMember !== true) {
348
+ throw new SlackBotProviderError("not_in_channel");
349
+ }
350
+ return projected;
351
+ }
352
+
353
+ private async call(
354
+ headers: Record<string, string>,
355
+ method: string,
356
+ params: Record<string, string>,
357
+ ): Promise<SlackPayload> {
358
+ return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
359
+ }
360
+
361
+ private async withAudit<T extends Record<string, unknown>>(
362
+ operation: SlackBotOperation,
363
+ run: (headers: Record<string, string>) => Promise<T>,
364
+ ): Promise<T & { receipt: SlackBotReceipt }> {
365
+ try {
366
+ const headers = await this.headersFor(operation);
367
+ const result = await run(headers);
368
+ await this.recordAudit(operation, "succeeded");
369
+ return { ...result, receipt: this.receipt(operation) };
370
+ } catch (error) {
371
+ await this.recordAudit(operation, "failed", safeFailureCode(error));
372
+ throw error;
373
+ }
374
+ }
375
+
376
+ private async headersFor(operation: SlackBotOperation): Promise<Record<string, string>> {
377
+ const result = await this.resolveCredential({
378
+ workspaceId: this.context.workspaceId,
379
+ serverId: "opengeni-slack-bot",
380
+ toolName: `slack_bot_${operation.replaceAll(".", "_")}`,
381
+ connectionRef: {
382
+ connectionId: this.connection.id,
383
+ providerDomain: "slack.com",
384
+ kind: "app_install",
385
+ scopes: [...OPENGENI_SLACK_BOT_REQUIRED_SCOPES],
386
+ subjectScope: "workspace",
387
+ },
388
+ destinationUrl: `${SLACK_API_BASE}${slackMethodForOperation(operation)}`,
389
+ });
390
+ if (result.status !== "ok" || result.connectionId !== this.connection.id) {
391
+ throw new Error("OpenGeni Slack bot connection needs to be reinstalled");
392
+ }
393
+ return result.headers;
394
+ }
395
+
396
+ private receipt(operation: SlackBotOperation, operationId?: string): SlackBotReceipt {
397
+ return {
398
+ credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
399
+ credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
400
+ connectionId: this.connection.id,
401
+ slackTeamId: this.metadata.slackTeamId,
402
+ operation,
403
+ ...(operationId ? { operationId, clientMessageId: operationId } : {}),
404
+ };
405
+ }
406
+
407
+ private completedPostResult(
408
+ operation: {
409
+ slackChannelId: string | null;
410
+ slackMessageTimestamp: string | null;
411
+ },
412
+ operationId: string,
413
+ ) {
414
+ if (!operation.slackChannelId || !operation.slackMessageTimestamp) {
415
+ throw new Error("completed Slack post operation is missing its provider result");
416
+ }
417
+ return {
418
+ channelId: operation.slackChannelId,
419
+ timestamp: operation.slackMessageTimestamp,
420
+ receipt: this.receipt("message.post", operationId),
421
+ };
422
+ }
423
+
424
+ private postRequestDigest(input: {
425
+ operationId: string;
426
+ targetKind: "channel" | "user";
427
+ targetId: string;
428
+ text: string;
429
+ }): string {
430
+ const key = environmentsEncryptionKeyBytes(this.settings);
431
+ if (!key) throw new Error("connection encryption is not configured");
432
+ return createHmac("sha256", key)
433
+ .update(
434
+ JSON.stringify({
435
+ operationId: input.operationId,
436
+ connectionId: this.connection.id,
437
+ targetKind: input.targetKind,
438
+ targetId: input.targetId,
439
+ text: input.text,
440
+ }),
441
+ )
442
+ .digest("hex");
443
+ }
444
+
445
+ private async recordAudit(
446
+ operation: SlackBotOperation,
447
+ outcome: "succeeded" | "failed" | "ambiguous",
448
+ failureCode?: string,
449
+ operationId?: string,
450
+ ): Promise<void> {
451
+ await recordAuditEvent(this.db, {
452
+ accountId: this.context.accountId,
453
+ workspaceId: this.context.workspaceId,
454
+ subjectId: this.context.subjectId,
455
+ action: `slack_bot.${operation}`,
456
+ targetType: "connection",
457
+ targetId: this.connection.id,
458
+ metadata: this.auditMetadata(operation, outcome, failureCode, operationId),
459
+ });
460
+ }
461
+
462
+ private auditMetadata(
463
+ operation: SlackBotOperation,
464
+ outcome: "succeeded" | "failed" | "ambiguous",
465
+ failureCode?: string,
466
+ operationId?: string,
467
+ ): Record<string, unknown> {
468
+ return {
469
+ ...this.receipt(operation, operationId),
470
+ outcome,
471
+ ...(failureCode ? { failureCode } : {}),
472
+ ...(this.context.sessionId ? { sessionId: this.context.sessionId } : {}),
473
+ ...(this.context.scheduledTaskId ? { scheduledTaskId: this.context.scheduledTaskId } : {}),
474
+ };
475
+ }
476
+ }
477
+
478
+ export function createOpenGeniSlackBotClient(
479
+ deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
480
+ resolved: Awaited<ReturnType<typeof resolveSlackBotConnectionForTool>>,
481
+ ): OpenGeniSlackBotClient {
482
+ return new OpenGeniSlackBotClient(
483
+ deps.db,
484
+ deps.settings,
485
+ resolved.connection,
486
+ resolved.metadata,
487
+ resolved.context,
488
+ deps.slackFetch,
489
+ );
490
+ }
491
+
492
+ async function slackApiFetch(
493
+ fetchImpl: FetchLike,
494
+ method: string,
495
+ token: string,
496
+ params: Record<string, string>,
497
+ ) {
498
+ return await slackApiFetchWithHeaders(
499
+ fetchImpl,
500
+ method,
501
+ { authorization: `Bearer ${token}` },
502
+ params,
503
+ );
504
+ }
505
+
506
+ async function slackApiFetchWithHeaders(
507
+ fetchImpl: FetchLike,
508
+ method: string,
509
+ credentialHeaders: Record<string, string>,
510
+ params: Record<string, string>,
511
+ ): Promise<{ response: Response; payload: SlackPayload }> {
512
+ if (!/^[a-z]+\.[a-z]+$/i.test(method)) {
513
+ throw new Error("invalid Slack API method");
514
+ }
515
+ const url = new URL(method, SLACK_API_BASE);
516
+ const body = new URLSearchParams(params);
517
+ let response: Response;
518
+ try {
519
+ response = await fetchImpl(url, {
520
+ method: "POST",
521
+ headers: {
522
+ ...credentialHeaders,
523
+ accept: "application/json",
524
+ "content-type": "application/x-www-form-urlencoded",
525
+ },
526
+ body,
527
+ signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
528
+ });
529
+ } catch {
530
+ throw new SlackBotProviderError("transport_error");
531
+ }
532
+ if (!response.ok) {
533
+ await response.body?.cancel().catch(() => undefined);
534
+ throw new SlackBotProviderError(`http_${response.status}`);
535
+ }
536
+ let payload: SlackPayload;
537
+ try {
538
+ payload = await readResponseJsonBounded<SlackPayload>(
539
+ response,
540
+ SLACK_RESPONSE_MAX_BYTES,
541
+ `Slack ${method} response`,
542
+ );
543
+ } catch {
544
+ throw new SlackBotProviderError("invalid_response");
545
+ }
546
+ if (payload.ok !== true) {
547
+ throw new SlackBotProviderError(slackString(payload.error) || "unknown_error");
548
+ }
549
+ return { response, payload };
550
+ }
551
+
552
+ function assertExactOpenGeniSlackBotScopes(grantedScopes: string[]): void {
553
+ const required = new Set<string>(OPENGENI_SLACK_BOT_REQUIRED_SCOPES);
554
+ const granted = new Set(grantedScopes);
555
+ const missing = [...required].filter((scope) => !granted.has(scope));
556
+ const forbidden = OPENGENI_SLACK_BOT_FORBIDDEN_SCOPES.filter((scope) => granted.has(scope));
557
+ const unsupported = grantedScopes.filter((scope) => !required.has(scope));
558
+ if (missing.length || forbidden.length || unsupported.length) {
559
+ const facts = [
560
+ ...(missing.length ? [`missing: ${missing.join(", ")}`] : []),
561
+ ...(forbidden.length ? [`forbidden: ${forbidden.join(", ")}`] : []),
562
+ ...(unsupported.length ? [`unsupported: ${unsupported.join(", ")}`] : []),
563
+ ];
564
+ throw new HTTPException(422, {
565
+ message: `Slack bot scopes must exactly match the OpenGeni manifest (${facts.join("; ")})`,
566
+ });
567
+ }
568
+ }
569
+
570
+ function parseGrantedScopes(header: string | null): string[] {
571
+ if (!header) {
572
+ throw new HTTPException(422, { message: "Slack did not report granted bot scopes" });
573
+ }
574
+ return [
575
+ ...new Set(
576
+ header
577
+ .split(",")
578
+ .map((scope) => scope.trim())
579
+ .filter(Boolean),
580
+ ),
581
+ ].sort();
582
+ }
583
+
584
+ function projectChannel(value: unknown) {
585
+ const channel = slackRecord(value);
586
+ const id = slackString(channel?.id);
587
+ if (!channel || !id) return null;
588
+ return {
589
+ id,
590
+ name: boundedSlackString(channel.name, 256),
591
+ isPrivate: channel.is_private === true,
592
+ isMember: channel.is_member === true,
593
+ isArchived: channel.is_archived === true,
594
+ topic: boundedSlackString(slackRecord(channel.topic)?.value, 1_024),
595
+ purpose: boundedSlackString(slackRecord(channel.purpose)?.value, 1_024),
596
+ numMembers:
597
+ typeof channel.num_members === "number" && Number.isSafeInteger(channel.num_members)
598
+ ? channel.num_members
599
+ : null,
600
+ };
601
+ }
602
+
603
+ function projectMessage(value: unknown) {
604
+ const message = slackRecord(value) ?? {};
605
+ return {
606
+ timestamp: boundedSlackString(message.ts, 64),
607
+ userId: boundedSlackString(message.user, 64),
608
+ botId: boundedSlackString(message.bot_id, 64),
609
+ threadTimestamp: boundedSlackString(message.thread_ts, 64),
610
+ text: boundedSlackString(message.text, MAX_PROJECTED_TEXT),
611
+ };
612
+ }
613
+
614
+ function projectUser(value: unknown) {
615
+ const user = slackRecord(value);
616
+ const id = slackString(user?.id);
617
+ if (!user || !id) return null;
618
+ const profile = slackRecord(user.profile);
619
+ return {
620
+ id,
621
+ name: boundedSlackString(user.name, 256),
622
+ displayName: boundedSlackString(profile?.display_name, 256),
623
+ realName: boundedSlackString(profile?.real_name, 256),
624
+ isBot: user.is_bot === true,
625
+ deleted: user.deleted === true,
626
+ };
627
+ }
628
+
629
+ function responseCursor(payload: SlackPayload): string | null {
630
+ return boundedSlackString(slackRecord(payload.response_metadata)?.next_cursor, 1_024) || null;
631
+ }
632
+
633
+ function slackMethodForOperation(operation: SlackBotOperation): string {
634
+ switch (operation) {
635
+ case "channels.list":
636
+ return "conversations.list";
637
+ case "channel_history.read":
638
+ return "conversations.history";
639
+ case "users.list":
640
+ return "users.list";
641
+ case "message.post":
642
+ return "chat.postMessage";
643
+ }
644
+ }
645
+
646
+ function boundedInt(value: number | undefined, max: number, fallback: number): number {
647
+ return typeof value === "number" && Number.isInteger(value) && value > 0
648
+ ? Math.min(value, max)
649
+ : fallback;
650
+ }
651
+
652
+ function slackArray(value: unknown): unknown[] {
653
+ return Array.isArray(value) ? value : [];
654
+ }
655
+
656
+ function slackRecord(value: unknown): Record<string, unknown> | null {
657
+ return value && typeof value === "object" && !Array.isArray(value)
658
+ ? (value as Record<string, unknown>)
659
+ : null;
660
+ }
661
+
662
+ function slackString(value: unknown): string {
663
+ return typeof value === "string" ? value : "";
664
+ }
665
+
666
+ function requiredSlackString(value: unknown, field: string): string {
667
+ const result = slackString(value);
668
+ if (!result || result.length > 256) {
669
+ throw new SlackBotProviderError(`invalid_${field.replaceAll(".", "_")}`);
670
+ }
671
+ return result;
672
+ }
673
+
674
+ function boundedSlackString(value: unknown, max: number): string {
675
+ return slackString(value).slice(0, max);
676
+ }
677
+
678
+ function safeSlackCode(value: string): string {
679
+ return /^[a-z0-9_.:-]{1,128}$/i.test(value) ? value : "unknown_error";
680
+ }
681
+
682
+ function safeFailureCode(error: unknown): string {
683
+ if (error instanceof SlackBotProviderError) return safeSlackCode(error.code);
684
+ return "local_validation_failed";
685
+ }
686
+
687
+ function slackPostOutcomeMayBeAmbiguous(error: unknown): boolean {
688
+ if (!(error instanceof SlackBotProviderError)) return true;
689
+ return (
690
+ error.code === "transport_error" ||
691
+ error.code === "invalid_response" ||
692
+ error.code.startsWith("http_")
693
+ );
694
+ }