@opengeni/api-router 0.23.1 → 0.30.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.
- package/dist/app.d.ts +3 -2
- package/dist/app.js +5 -3
- package/dist/auth/canonical-human-session-admission.d.ts +27 -0
- package/dist/browser-auth-broker.d.ts +20 -0
- package/dist/browser-controller-authority.d.ts +61 -0
- package/dist/browser-network-route.d.ts +8 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
- package/dist/chunk-2PQMSRCB.js.map +1 -0
- package/dist/codemode.d.ts +23 -0
- package/dist/controller-data-plane.d.ts +12 -0
- package/dist/editable-artifact-live-hints.d.ts +11 -0
- package/dist/editable-artifact-native-kernel.d.ts +37 -0
- package/dist/editable-artifact-office-import.d.ts +22 -0
- package/dist/editable-artifact-production.d.ts +29 -0
- package/dist/editable-artifact-websocket.d.ts +49 -0
- package/dist/editable-artifact-workspace-files.d.ts +23 -0
- package/dist/github-browser-flow.d.ts +6 -0
- package/dist/http/auth.d.ts +1 -1
- package/dist/http/cors.d.ts +1 -0
- package/dist/http/sse.d.ts +18 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1796 -22
- package/dist/index.js.map +1 -1
- package/dist/integrations/api-integrations.d.ts +24 -0
- package/dist/integrations/atlassian.d.ts +176 -0
- package/dist/integrations/fiken.d.ts +233 -0
- package/dist/integrations/github-skill-source.d.ts +5 -0
- package/dist/integrations/google-drive.d.ts +85 -0
- package/dist/integrations/oauth-client.d.ts +22 -1
- package/dist/integrations/provider-oauth.d.ts +17 -0
- package/dist/integrations/slack-bot.d.ts +175 -1
- package/dist/integrations/slack-interactions.d.ts +24 -6
- package/dist/integrations/social-api.d.ts +2 -1
- package/dist/interaction-holder-heartbeat.d.ts +17 -0
- package/dist/interaction-metrics.d.ts +11 -0
- package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
- package/dist/mcp/editable-artifacts.d.ts +13 -0
- package/dist/mcp/scheduled-task-view.d.ts +168 -0
- package/dist/mcp/server.d.ts +16 -4
- package/dist/memory-slack-delivery.d.ts +9 -0
- package/dist/model-catalog.d.ts +1 -0
- package/dist/routes/api-integrations.d.ts +8 -0
- package/dist/routes/browser-identities.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +6 -0
- package/dist/routes/canonical-human-identities.d.ts +3 -0
- package/dist/routes/channels.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +3 -0
- package/dist/routes/computer-sessions.d.ts +6 -0
- package/dist/routes/editable-artifacts.d.ts +44 -0
- package/dist/routes/integration-facets.d.ts +3 -0
- package/dist/routes/interaction-resources.d.ts +5 -0
- package/dist/routes/memory-slack-publications.d.ts +6 -0
- package/dist/routes/organization-memberships.d.ts +3 -0
- package/dist/routes/plugins.d.ts +8 -0
- package/dist/routes/sessions.d.ts +18 -2
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/slack-task-policy.d.ts +3 -0
- package/dist/routes/supergrok.d.ts +3 -0
- package/dist/routes/video-generation.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +62 -2
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +6 -1
- package/dist/sandbox/viewer.d.ts +7 -3
- package/dist/slack-reaction-files.d.ts +27 -0
- package/dist/temporal-schedule-cleanup.d.ts +26 -0
- package/dist/transcription/providers/xai-subscription.d.ts +8 -0
- package/dist/xai-realtime.d.ts +26 -0
- package/dist/xai-subscription-auth.d.ts +23 -0
- package/package.json +20 -14
- package/src/app.ts +604 -64
- package/src/auth/canonical-human-session-admission.ts +116 -0
- package/src/auth/managed-auth.ts +101 -0
- package/src/browser-auth-broker.ts +155 -0
- package/src/browser-controller-authority.ts +186 -0
- package/src/browser-network-route.ts +34 -0
- package/src/browser-state-authority.ts +236 -0
- package/src/codemode.ts +186 -0
- package/src/controller-data-plane.ts +35 -0
- package/src/editable-artifact-live-hints.ts +64 -0
- package/src/editable-artifact-native-kernel.ts +659 -0
- package/src/editable-artifact-office-import.ts +230 -0
- package/src/editable-artifact-production.ts +419 -0
- package/src/editable-artifact-websocket.ts +311 -0
- package/src/editable-artifact-workspace-files.ts +186 -0
- package/src/github-browser-flow.ts +35 -6
- package/src/http/auth.ts +29 -9
- package/src/http/cors.ts +3 -0
- package/src/http/sse.ts +229 -5
- package/src/index.ts +119 -20
- package/src/integrations/api-integrations.ts +365 -0
- package/src/integrations/atlassian.ts +1621 -0
- package/src/integrations/fiken.ts +1230 -0
- package/src/integrations/github-skill-source.ts +142 -0
- package/src/integrations/google-drive.ts +1005 -64
- package/src/integrations/oauth-client.ts +123 -33
- package/src/integrations/provider-oauth.ts +756 -0
- package/src/integrations/slack-bot.ts +669 -11
- package/src/integrations/slack-interactions.ts +2063 -93
- package/src/integrations/social-api.ts +11 -0
- package/src/interaction-holder-heartbeat.ts +95 -0
- package/src/interaction-metrics.ts +159 -0
- package/src/mcp/documents.ts +146 -6
- package/src/mcp/editable-artifact-query-schema.ts +236 -0
- package/src/mcp/editable-artifacts.ts +448 -0
- package/src/mcp/scheduled-task-view.ts +34 -0
- package/src/mcp/server.ts +1756 -355
- package/src/memory-slack-delivery.ts +209 -0
- package/src/model-catalog.ts +19 -6
- package/src/routes/api-integrations.ts +410 -0
- package/src/routes/browser-identities.ts +160 -0
- package/src/routes/browser-sessions.ts +4482 -0
- package/src/routes/canonical-human-identities.ts +156 -0
- package/src/routes/channels.ts +90 -0
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1602 -0
- package/src/routes/connections.ts +421 -99
- package/src/routes/documents.ts +22 -3
- package/src/routes/editable-artifacts.ts +1159 -0
- package/src/routes/files.ts +106 -4
- package/src/routes/github.ts +18 -2
- package/src/routes/install.ts +7 -1
- package/src/routes/integration-facets.ts +258 -0
- package/src/routes/interaction-resources.ts +595 -0
- package/src/routes/memory-slack-publications.ts +216 -0
- package/src/routes/organization-memberships.ts +53 -0
- package/src/routes/packs.ts +437 -7
- package/src/routes/plugins.ts +752 -0
- package/src/routes/rigs.ts +77 -20
- package/src/routes/scheduled-tasks.ts +67 -40
- package/src/routes/sessions.ts +830 -278
- package/src/routes/skills.ts +256 -0
- package/src/routes/slack-task-policy.ts +115 -0
- package/src/routes/supergrok.ts +717 -0
- package/src/routes/transcription-recordings.ts +9 -2
- package/src/routes/transcriptions.ts +2 -1
- package/src/routes/video-generation.ts +162 -0
- package/src/routes/workspaces.ts +68 -28
- package/src/sandbox/channel-a.ts +750 -116
- package/src/sandbox/machines.ts +5 -5
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +39 -12
- package/src/slack-reaction-files.ts +181 -0
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/src/transcription/providers/xai-subscription.ts +110 -0
- package/src/transcription/service.ts +17 -2
- package/src/xai-realtime.ts +216 -0
- package/src/xai-subscription-auth.ts +132 -0
- package/dist/chunk-T4T2PGU4.js.map +0 -1
- package/dist/mcp/toolspace.d.ts +0 -71
- package/src/mcp/toolspace.ts +0 -1190
|
@@ -20,14 +20,18 @@ import {
|
|
|
20
20
|
buildConnectionTokenResolver,
|
|
21
21
|
claimSlackBotDeleteOperation,
|
|
22
22
|
claimSlackBotPostOperation,
|
|
23
|
+
claimSlackBotUpdateOperation,
|
|
23
24
|
completeSlackBotDeleteOperation,
|
|
24
25
|
completeSlackBotPostOperation,
|
|
26
|
+
completeSlackBotUpdateOperation,
|
|
25
27
|
getSession,
|
|
26
28
|
listConnectionsMetadata,
|
|
27
29
|
markSlackBotDeleteOperationProviderStarted,
|
|
30
|
+
markSlackBotPostOperationProviderStarted,
|
|
28
31
|
recordAuditEvent,
|
|
29
32
|
releaseSlackBotDeleteOperationClaim,
|
|
30
33
|
releaseSlackBotPostOperationClaim,
|
|
34
|
+
releaseSlackBotUpdateOperationClaim,
|
|
31
35
|
setConnectionStatus,
|
|
32
36
|
type Database,
|
|
33
37
|
} from "@opengeni/db";
|
|
@@ -41,6 +45,7 @@ import { HTTPException } from "hono/http-exception";
|
|
|
41
45
|
const SLACK_API_BASE = "https://slack.com/api/";
|
|
42
46
|
const SLACK_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
43
47
|
const SLACK_FILE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
|
|
48
|
+
export const SLACK_REACTION_IMAGE_MAX_BYTES = 4 * 1024 * 1024;
|
|
44
49
|
const SLACK_FILE_CONTENT_PAGE_CHARS = 50_000;
|
|
45
50
|
const SLACK_TIMEOUT_MS = 10_000;
|
|
46
51
|
const MAX_CHANNEL_PAGE = 200;
|
|
@@ -64,7 +69,13 @@ const MAX_FILE_CURSOR_LENGTH = 1_024;
|
|
|
64
69
|
const SLACK_FILE_CURSOR_VERSION = "files-v1";
|
|
65
70
|
const MAX_PROJECTED_TEXT = 4_000;
|
|
66
71
|
const SLACK_POST_CLAIM_LEASE_MS = 30_000;
|
|
72
|
+
const MAX_SLACK_POST_RECONCILIATION_PAGES = 8;
|
|
73
|
+
const SLACK_UPDATE_CLAIM_LEASE_MS = 30_000;
|
|
67
74
|
const SLACK_DELETE_CLAIM_LEASE_MS = 30_000;
|
|
75
|
+
const MAX_SLACK_BLOCKS = 50;
|
|
76
|
+
const MAX_SLACK_BLOCK_BYTES = 32 * 1024;
|
|
77
|
+
const SLACK_PRIVATE_FILE_HOSTS = new Set(["files.slack.com", "slack.com"]);
|
|
78
|
+
const SLACK_REACTION_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
68
79
|
|
|
69
80
|
type SlackPayload = Record<string, unknown> & { ok?: unknown; error?: unknown };
|
|
70
81
|
|
|
@@ -80,6 +91,21 @@ export type VerifiedOpenGeniSlackBot = {
|
|
|
80
91
|
metadata: OpenGeniSlackBotConnectionMetadata;
|
|
81
92
|
};
|
|
82
93
|
|
|
94
|
+
export type PreparedSlackReactionImage = Readonly<{
|
|
95
|
+
fileId: string;
|
|
96
|
+
filename: string;
|
|
97
|
+
declaredMimeType: string;
|
|
98
|
+
declaredSizeBytes: number | null;
|
|
99
|
+
downloadUrl: URL | null;
|
|
100
|
+
}>;
|
|
101
|
+
|
|
102
|
+
export type DownloadedSlackReactionImage = Readonly<{
|
|
103
|
+
fileId: string;
|
|
104
|
+
filename: string;
|
|
105
|
+
contentType: "image/png" | "image/jpeg" | "image/webp";
|
|
106
|
+
bytes: Uint8Array;
|
|
107
|
+
}>;
|
|
108
|
+
|
|
83
109
|
export async function exchangeOpenGeniSlackAuthorizationCode(
|
|
84
110
|
input: {
|
|
85
111
|
code: string;
|
|
@@ -145,6 +171,25 @@ export type SlackBotReceipt = {
|
|
|
145
171
|
clientMessageId?: string;
|
|
146
172
|
};
|
|
147
173
|
|
|
174
|
+
export type SlackMessageBlock =
|
|
175
|
+
| {
|
|
176
|
+
type: "section";
|
|
177
|
+
block_id?: string;
|
|
178
|
+
text: { type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean };
|
|
179
|
+
}
|
|
180
|
+
| {
|
|
181
|
+
type: "actions";
|
|
182
|
+
block_id: string;
|
|
183
|
+
elements: Array<{
|
|
184
|
+
type: "button";
|
|
185
|
+
action_id: string;
|
|
186
|
+
value: string;
|
|
187
|
+
text: { type: "plain_text"; text: string; emoji?: boolean };
|
|
188
|
+
style?: "primary" | "danger";
|
|
189
|
+
}>;
|
|
190
|
+
}
|
|
191
|
+
| { type: "divider" };
|
|
192
|
+
|
|
148
193
|
type SlackBotOperation =
|
|
149
194
|
| "channels.list"
|
|
150
195
|
| "channel_history.read"
|
|
@@ -154,6 +199,7 @@ type SlackBotOperation =
|
|
|
154
199
|
| "file.info"
|
|
155
200
|
| "file.content.read"
|
|
156
201
|
| "message.post"
|
|
202
|
+
| "message.update"
|
|
157
203
|
| "message.delete";
|
|
158
204
|
|
|
159
205
|
type SlackBotContext = {
|
|
@@ -407,13 +453,42 @@ export class OpenGeniSlackBotClient {
|
|
|
407
453
|
return await this.requireMemberChannel(headers, channelId);
|
|
408
454
|
}
|
|
409
455
|
|
|
410
|
-
async
|
|
456
|
+
async slackTaskPolicyFacts(channelId: string, userId: string) {
|
|
457
|
+
return await this.withAudit("channel_history.read", async (headers) => {
|
|
458
|
+
const conversation = await this.requireMemberChannel(headers, channelId);
|
|
459
|
+
const governed =
|
|
460
|
+
conversation.isShared ||
|
|
461
|
+
conversation.isExternallyShared ||
|
|
462
|
+
conversation.isOrgShared ||
|
|
463
|
+
conversation.isPendingExternallyShared ||
|
|
464
|
+
conversation.isMpim;
|
|
465
|
+
if (!governed) return { conversation, initiator: null };
|
|
466
|
+
const payload = await this.call(headers, "users.info", { user: userId });
|
|
467
|
+
const initiator = projectSlackTaskPolicyUser(payload.user, this.metadata.slackTeamId);
|
|
468
|
+
if (!initiator || initiator.id !== userId) {
|
|
469
|
+
throw new SlackBotProviderError("slack_task_initiator_unavailable");
|
|
470
|
+
}
|
|
471
|
+
return { conversation, initiator };
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async channelHistory(input: {
|
|
476
|
+
channelId: string;
|
|
477
|
+
limit?: number;
|
|
478
|
+
cursor?: string;
|
|
479
|
+
latest?: string;
|
|
480
|
+
inclusive?: boolean;
|
|
481
|
+
authorizeRead?: () => Promise<void>;
|
|
482
|
+
}) {
|
|
411
483
|
return await this.withAudit("channel_history.read", async (headers) => {
|
|
412
484
|
const info = await this.requireMemberChannel(headers, input.channelId);
|
|
485
|
+
await input.authorizeRead?.();
|
|
413
486
|
const payload = await this.call(headers, "conversations.history", {
|
|
414
487
|
channel: input.channelId,
|
|
415
488
|
limit: String(boundedInt(input.limit, MAX_HISTORY_PAGE, 50)),
|
|
416
489
|
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
490
|
+
...(input.latest ? { latest: input.latest } : {}),
|
|
491
|
+
...(input.latest && input.inclusive ? { inclusive: "true" } : {}),
|
|
417
492
|
});
|
|
418
493
|
return {
|
|
419
494
|
channel: info,
|
|
@@ -428,14 +503,22 @@ export class OpenGeniSlackBotClient {
|
|
|
428
503
|
threadTimestamp: string;
|
|
429
504
|
limit?: number;
|
|
430
505
|
cursor?: string;
|
|
506
|
+
oldest?: string;
|
|
507
|
+
latest?: string;
|
|
508
|
+
inclusive?: boolean;
|
|
509
|
+
authorizeRead?: () => Promise<void>;
|
|
431
510
|
}) {
|
|
432
511
|
return await this.withAudit("thread_replies.read", async (headers) => {
|
|
433
512
|
const info = await this.requireMemberChannel(headers, input.channelId);
|
|
513
|
+
await input.authorizeRead?.();
|
|
434
514
|
const payload = await this.call(headers, "conversations.replies", {
|
|
435
515
|
channel: input.channelId,
|
|
436
516
|
ts: input.threadTimestamp,
|
|
437
517
|
limit: String(boundedInt(input.limit, MAX_THREAD_PAGE, 50)),
|
|
438
518
|
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
519
|
+
...(input.oldest ? { oldest: input.oldest } : {}),
|
|
520
|
+
...(input.latest ? { latest: input.latest } : {}),
|
|
521
|
+
...((input.oldest || input.latest) && input.inclusive ? { inclusive: "true" } : {}),
|
|
439
522
|
});
|
|
440
523
|
return {
|
|
441
524
|
channel: info,
|
|
@@ -557,6 +640,99 @@ export class OpenGeniSlackBotClient {
|
|
|
557
640
|
});
|
|
558
641
|
}
|
|
559
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Re-fetch and authorize the exact reacted-message files before any byte or
|
|
645
|
+
* workspace-storage mutation. The returned private URLs are process-local
|
|
646
|
+
* capabilities only and must never be persisted, logged, or projected into
|
|
647
|
+
* session input.
|
|
648
|
+
*/
|
|
649
|
+
async prepareReactionImageDownloads(input: {
|
|
650
|
+
channelId: string;
|
|
651
|
+
files: readonly { id: string; name: string; title: string }[];
|
|
652
|
+
}): Promise<PreparedSlackReactionImage[]> {
|
|
653
|
+
const result = await this.withAudit("file.content.read", async (headers) => {
|
|
654
|
+
await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
|
|
655
|
+
const prepared: PreparedSlackReactionImage[] = [];
|
|
656
|
+
for (const candidate of input.files) {
|
|
657
|
+
const payload = await this.call(headers, "files.info", { file: candidate.id });
|
|
658
|
+
const fileRecord = slackRecord(payload.file);
|
|
659
|
+
const file = projectFile(fileRecord);
|
|
660
|
+
if (!fileRecord || !file || file.id !== candidate.id) {
|
|
661
|
+
throw new SlackBotProviderError("file_not_found");
|
|
662
|
+
}
|
|
663
|
+
if (!fileIsSharedToChannel(fileRecord, input.channelId)) {
|
|
664
|
+
throw new SlackBotProviderError("file_not_shared_to_channel");
|
|
665
|
+
}
|
|
666
|
+
let downloadUrl: URL | null = null;
|
|
667
|
+
try {
|
|
668
|
+
downloadUrl = privateSlackFileUrl(fileRecord);
|
|
669
|
+
} catch {
|
|
670
|
+
// A malformed provider URL is a per-file omission, not permission to
|
|
671
|
+
// send a credential to a different destination.
|
|
672
|
+
}
|
|
673
|
+
prepared.push(
|
|
674
|
+
Object.freeze({
|
|
675
|
+
fileId: file.id,
|
|
676
|
+
filename: file.name || file.title || candidate.name || candidate.title || file.id,
|
|
677
|
+
declaredMimeType: normalizedContentType(file.mimetype),
|
|
678
|
+
declaredSizeBytes: file.size,
|
|
679
|
+
downloadUrl,
|
|
680
|
+
}),
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
return { files: prepared };
|
|
684
|
+
});
|
|
685
|
+
return result.files;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Download and fully validate one previously authorized Slack image. */
|
|
689
|
+
async downloadReactionImage(
|
|
690
|
+
input: PreparedSlackReactionImage,
|
|
691
|
+
): Promise<DownloadedSlackReactionImage> {
|
|
692
|
+
return await this.withAudit("file.content.read", async () => {
|
|
693
|
+
if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(input.declaredMimeType)) {
|
|
694
|
+
throw new SlackBotProviderError("unsupported_file_type");
|
|
695
|
+
}
|
|
696
|
+
if (!input.downloadUrl) throw new SlackBotProviderError("file_content_unavailable");
|
|
697
|
+
if (
|
|
698
|
+
input.declaredSizeBytes !== null &&
|
|
699
|
+
(input.declaredSizeBytes < 1 || input.declaredSizeBytes > SLACK_REACTION_IMAGE_MAX_BYTES)
|
|
700
|
+
) {
|
|
701
|
+
throw new SlackBotProviderError("invalid_file_size");
|
|
702
|
+
}
|
|
703
|
+
const response = await this.fetchPrivateFile(input.downloadUrl, "file.content.read");
|
|
704
|
+
const responseContentType = normalizedContentType(response.headers.get("content-type"));
|
|
705
|
+
if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(responseContentType)) {
|
|
706
|
+
await response.body?.cancel().catch(() => undefined);
|
|
707
|
+
throw new SlackBotProviderError("unsupported_file_type");
|
|
708
|
+
}
|
|
709
|
+
let bytes: Uint8Array;
|
|
710
|
+
try {
|
|
711
|
+
bytes = await readResponseBodyBounded(
|
|
712
|
+
response,
|
|
713
|
+
SLACK_REACTION_IMAGE_MAX_BYTES,
|
|
714
|
+
"Slack image content",
|
|
715
|
+
);
|
|
716
|
+
} catch {
|
|
717
|
+
throw new SlackBotProviderError("invalid_file_content");
|
|
718
|
+
}
|
|
719
|
+
const sniffed = sniffSlackReactionImageMime(bytes);
|
|
720
|
+
if (!sniffed) throw new SlackBotProviderError("invalid_file_content");
|
|
721
|
+
if (input.declaredMimeType !== sniffed || responseContentType !== sniffed) {
|
|
722
|
+
throw new SlackBotProviderError("file_content_type_mismatch");
|
|
723
|
+
}
|
|
724
|
+
if (input.declaredSizeBytes !== null && input.declaredSizeBytes !== bytes.byteLength) {
|
|
725
|
+
throw new SlackBotProviderError("file_size_mismatch");
|
|
726
|
+
}
|
|
727
|
+
return Object.freeze({
|
|
728
|
+
fileId: input.fileId,
|
|
729
|
+
filename: input.filename,
|
|
730
|
+
contentType: sniffed,
|
|
731
|
+
bytes,
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
560
736
|
async listUsers(input: { limit?: number; cursor?: string } = {}) {
|
|
561
737
|
return await this.withAudit("users.list", async (headers) => {
|
|
562
738
|
const payload = await this.call(headers, "users.list", {
|
|
@@ -645,17 +821,25 @@ export class OpenGeniSlackBotClient {
|
|
|
645
821
|
});
|
|
646
822
|
}
|
|
647
823
|
|
|
824
|
+
/**
|
|
825
|
+
* Internal server-owned delivery only. Generic model-facing MCP callers do
|
|
826
|
+
* not have a trustworthy durable logical-delivery identity and must never
|
|
827
|
+
* reach this method with a caller-generated operation ID.
|
|
828
|
+
*/
|
|
648
829
|
async postMessage(input: {
|
|
649
830
|
operationId: string;
|
|
650
831
|
channelId?: string;
|
|
651
832
|
userId?: string;
|
|
652
833
|
threadTimestamp?: string;
|
|
653
834
|
text: string;
|
|
835
|
+
blocks?: SlackMessageBlock[];
|
|
836
|
+
requireActiveNonSharedChannel?: boolean;
|
|
654
837
|
}) {
|
|
655
838
|
const operation = "message.post" as const;
|
|
656
839
|
const claimHolderId = crypto.randomUUID();
|
|
657
840
|
let claimAcquired = false;
|
|
658
841
|
let providerCallStarted = false;
|
|
842
|
+
let outcomeUnknown = false;
|
|
659
843
|
try {
|
|
660
844
|
const headers = await this.headersFor(operation);
|
|
661
845
|
let channelId = input.channelId;
|
|
@@ -664,7 +848,7 @@ export class OpenGeniSlackBotClient {
|
|
|
664
848
|
users: input.userId,
|
|
665
849
|
});
|
|
666
850
|
channelId = requiredSlackString(slackRecord(opened.channel)?.id, "channel.id");
|
|
667
|
-
} else if (channelId) {
|
|
851
|
+
} else if (channelId && !input.requireActiveNonSharedChannel) {
|
|
668
852
|
await this.requireMemberChannel(headers, channelId);
|
|
669
853
|
}
|
|
670
854
|
if (!channelId) {
|
|
@@ -672,12 +856,14 @@ export class OpenGeniSlackBotClient {
|
|
|
672
856
|
}
|
|
673
857
|
const targetKind = input.userId ? "user" : "channel";
|
|
674
858
|
const targetId = input.userId ?? input.channelId!;
|
|
859
|
+
const blocks = validateSlackMessageBlocks(input.blocks);
|
|
675
860
|
const requestDigest = this.postRequestDigest({
|
|
676
861
|
operationId: input.operationId,
|
|
677
862
|
targetKind,
|
|
678
863
|
targetId,
|
|
679
864
|
...(input.threadTimestamp ? { threadTimestamp: input.threadTimestamp } : {}),
|
|
680
865
|
text: input.text,
|
|
866
|
+
...(blocks ? { blocks } : {}),
|
|
681
867
|
});
|
|
682
868
|
const claim = await claimSlackBotPostOperation(this.db, {
|
|
683
869
|
accountId: this.context.accountId,
|
|
@@ -703,11 +889,59 @@ export class OpenGeniSlackBotClient {
|
|
|
703
889
|
return this.completedPostResult(claim.operation, input.operationId, input.threadTimestamp);
|
|
704
890
|
}
|
|
705
891
|
claimAcquired = true;
|
|
892
|
+
outcomeUnknown = claim.kind === "reconcile";
|
|
893
|
+
if (claim.kind === "reconcile") {
|
|
894
|
+
const reconciled = await this.reconcilePostMessage({
|
|
895
|
+
operationId: input.operationId,
|
|
896
|
+
channelId,
|
|
897
|
+
...(input.threadTimestamp ? { threadTimestamp: input.threadTimestamp } : {}),
|
|
898
|
+
text: input.text,
|
|
899
|
+
});
|
|
900
|
+
const completed = await completeSlackBotPostOperation(this.db, {
|
|
901
|
+
accountId: this.context.accountId,
|
|
902
|
+
workspaceId: this.context.workspaceId,
|
|
903
|
+
connectionId: this.connection.id,
|
|
904
|
+
operationId: input.operationId,
|
|
905
|
+
claimHolderId,
|
|
906
|
+
slackChannelId: channelId,
|
|
907
|
+
slackMessageTimestamp: reconciled.timestamp,
|
|
908
|
+
subjectId: this.context.subjectId,
|
|
909
|
+
auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId),
|
|
910
|
+
});
|
|
911
|
+
if (completed.kind !== "completed") {
|
|
912
|
+
throw new Error("Slack post reconciliation lost its durable operation claim");
|
|
913
|
+
}
|
|
914
|
+
claimAcquired = false;
|
|
915
|
+
return this.completedPostResult(
|
|
916
|
+
completed.operation,
|
|
917
|
+
input.operationId,
|
|
918
|
+
input.threadTimestamp,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
if (input.requireActiveNonSharedChannel) {
|
|
922
|
+
if (input.userId || !input.channelId) {
|
|
923
|
+
throw new Error("active non-shared channel validation requires channelId");
|
|
924
|
+
}
|
|
925
|
+
await this.requireActiveNonSharedMemberChannel(headers, channelId);
|
|
926
|
+
}
|
|
927
|
+
const providerStarted = await markSlackBotPostOperationProviderStarted(this.db, {
|
|
928
|
+
accountId: this.context.accountId,
|
|
929
|
+
workspaceId: this.context.workspaceId,
|
|
930
|
+
connectionId: this.connection.id,
|
|
931
|
+
operationId: input.operationId,
|
|
932
|
+
claimHolderId,
|
|
933
|
+
});
|
|
934
|
+
if (!providerStarted) {
|
|
935
|
+
throw new Error("Slack post operation lost its durable claim before provider call");
|
|
936
|
+
}
|
|
706
937
|
providerCallStarted = true;
|
|
707
938
|
const posted = await this.call(headers, "chat.postMessage", {
|
|
708
939
|
channel: channelId,
|
|
709
940
|
text: input.text,
|
|
941
|
+
...(blocks ? { blocks: JSON.stringify(blocks) } : {}),
|
|
710
942
|
client_msg_id: input.operationId,
|
|
943
|
+
unfurl_links: "false",
|
|
944
|
+
unfurl_media: "false",
|
|
711
945
|
...(input.threadTimestamp ? { thread_ts: input.threadTimestamp } : {}),
|
|
712
946
|
});
|
|
713
947
|
const slackChannelId = requiredSlackString(posted.channel, "channel");
|
|
@@ -734,8 +968,108 @@ export class OpenGeniSlackBotClient {
|
|
|
734
968
|
);
|
|
735
969
|
} catch (error) {
|
|
736
970
|
const failureCode = safeFailureCode(error);
|
|
971
|
+
const ambiguous = providerCallStarted && slackMutationOutcomeMayBeAmbiguous(error);
|
|
737
972
|
if (claimAcquired) {
|
|
738
973
|
await releaseSlackBotPostOperationClaim(this.db, {
|
|
974
|
+
accountId: this.context.accountId,
|
|
975
|
+
workspaceId: this.context.workspaceId,
|
|
976
|
+
connectionId: this.connection.id,
|
|
977
|
+
operationId: input.operationId,
|
|
978
|
+
claimHolderId,
|
|
979
|
+
outcomeUnknown: outcomeUnknown || ambiguous,
|
|
980
|
+
failureCode,
|
|
981
|
+
}).catch(() => undefined);
|
|
982
|
+
}
|
|
983
|
+
await this.recordAudit(
|
|
984
|
+
operation,
|
|
985
|
+
outcomeUnknown || ambiguous ? "ambiguous" : "failed",
|
|
986
|
+
failureCode,
|
|
987
|
+
input.operationId,
|
|
988
|
+
);
|
|
989
|
+
throw error;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async updateMessage(input: {
|
|
994
|
+
operationId: string;
|
|
995
|
+
channelId: string;
|
|
996
|
+
timestamp: string;
|
|
997
|
+
text: string;
|
|
998
|
+
blocks?: SlackMessageBlock[];
|
|
999
|
+
}): Promise<{ channelId: string; timestamp: string; receipt: SlackBotReceipt }> {
|
|
1000
|
+
const operation = "message.update" as const;
|
|
1001
|
+
const claimHolderId = crypto.randomUUID();
|
|
1002
|
+
let claimAcquired = false;
|
|
1003
|
+
let providerCallStarted = false;
|
|
1004
|
+
const blocks = validateSlackMessageBlocks(input.blocks);
|
|
1005
|
+
try {
|
|
1006
|
+
const headers = await this.headersFor(operation);
|
|
1007
|
+
await this.requireMemberChannel(headers, input.channelId);
|
|
1008
|
+
const requestDigest = this.updateRequestDigest({ ...input, ...(blocks ? { blocks } : {}) });
|
|
1009
|
+
const claim = await claimSlackBotUpdateOperation(this.db, {
|
|
1010
|
+
accountId: this.context.accountId,
|
|
1011
|
+
workspaceId: this.context.workspaceId,
|
|
1012
|
+
connectionId: this.connection.id,
|
|
1013
|
+
operationId: input.operationId,
|
|
1014
|
+
slackChannelId: input.channelId,
|
|
1015
|
+
slackMessageTimestamp: input.timestamp,
|
|
1016
|
+
requestDigest,
|
|
1017
|
+
claimHolderId,
|
|
1018
|
+
claimLeaseMs: SLACK_UPDATE_CLAIM_LEASE_MS,
|
|
1019
|
+
});
|
|
1020
|
+
if (claim.kind === "connection_not_found") {
|
|
1021
|
+
throw new Error("OpenGeni Slack bot connection no longer exists");
|
|
1022
|
+
}
|
|
1023
|
+
if (claim.kind === "conflict") {
|
|
1024
|
+
throw new Error("operationId is already bound to a different Slack update request");
|
|
1025
|
+
}
|
|
1026
|
+
if (claim.kind === "in_progress") {
|
|
1027
|
+
throw new Error(
|
|
1028
|
+
"Slack update operation is already in progress; retry the same operationId",
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
if (claim.kind === "completed") {
|
|
1032
|
+
return {
|
|
1033
|
+
channelId: claim.operation.slackChannelId,
|
|
1034
|
+
timestamp: claim.operation.slackMessageTimestamp,
|
|
1035
|
+
receipt: this.receipt(operation, input.operationId),
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
claimAcquired = true;
|
|
1039
|
+
providerCallStarted = true;
|
|
1040
|
+
const updated = await this.call(headers, "chat.update", {
|
|
1041
|
+
channel: input.channelId,
|
|
1042
|
+
ts: input.timestamp,
|
|
1043
|
+
text: input.text,
|
|
1044
|
+
...(blocks ? { blocks: JSON.stringify(blocks) } : {}),
|
|
1045
|
+
});
|
|
1046
|
+
const slackChannelId = requiredSlackString(updated.channel, "channel");
|
|
1047
|
+
const slackMessageTimestamp = requiredSlackString(updated.ts, "ts");
|
|
1048
|
+
if (slackChannelId !== input.channelId || slackMessageTimestamp !== input.timestamp) {
|
|
1049
|
+
throw new SlackBotProviderError("message_update_identity_mismatch");
|
|
1050
|
+
}
|
|
1051
|
+
const completed = await completeSlackBotUpdateOperation(this.db, {
|
|
1052
|
+
accountId: this.context.accountId,
|
|
1053
|
+
workspaceId: this.context.workspaceId,
|
|
1054
|
+
connectionId: this.connection.id,
|
|
1055
|
+
operationId: input.operationId,
|
|
1056
|
+
claimHolderId,
|
|
1057
|
+
subjectId: this.context.subjectId,
|
|
1058
|
+
auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId),
|
|
1059
|
+
});
|
|
1060
|
+
if (completed !== "completed") {
|
|
1061
|
+
throw new Error("Slack update completion lost its durable operation claim");
|
|
1062
|
+
}
|
|
1063
|
+
claimAcquired = false;
|
|
1064
|
+
return {
|
|
1065
|
+
channelId: slackChannelId,
|
|
1066
|
+
timestamp: slackMessageTimestamp,
|
|
1067
|
+
receipt: this.receipt(operation, input.operationId),
|
|
1068
|
+
};
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
const failureCode = safeFailureCode(error);
|
|
1071
|
+
if (claimAcquired) {
|
|
1072
|
+
await releaseSlackBotUpdateOperationClaim(this.db, {
|
|
739
1073
|
accountId: this.context.accountId,
|
|
740
1074
|
workspaceId: this.context.workspaceId,
|
|
741
1075
|
connectionId: this.connection.id,
|
|
@@ -895,6 +1229,61 @@ export class OpenGeniSlackBotClient {
|
|
|
895
1229
|
}
|
|
896
1230
|
}
|
|
897
1231
|
|
|
1232
|
+
private async reconcilePostMessage(input: {
|
|
1233
|
+
operationId: string;
|
|
1234
|
+
channelId: string;
|
|
1235
|
+
threadTimestamp?: string;
|
|
1236
|
+
text: string;
|
|
1237
|
+
}): Promise<{ timestamp: string }> {
|
|
1238
|
+
const method = input.threadTimestamp ? "conversations.replies" : "conversations.history";
|
|
1239
|
+
const headers = await this.headersForDestination("message.post", `${SLACK_API_BASE}${method}`);
|
|
1240
|
+
const seenCursors = new Set<string>();
|
|
1241
|
+
let cursor: string | null = null;
|
|
1242
|
+
let matchedTimestamp: string | null = null;
|
|
1243
|
+
let exhausted = false;
|
|
1244
|
+
for (let page = 0; page < MAX_SLACK_POST_RECONCILIATION_PAGES; page += 1) {
|
|
1245
|
+
const payload = await this.call(headers, method, {
|
|
1246
|
+
channel: input.channelId,
|
|
1247
|
+
limit: String(input.threadTimestamp ? MAX_THREAD_PAGE : MAX_HISTORY_PAGE),
|
|
1248
|
+
...(input.threadTimestamp ? { ts: input.threadTimestamp } : {}),
|
|
1249
|
+
...(cursor ? { cursor } : {}),
|
|
1250
|
+
});
|
|
1251
|
+
for (const value of slackArray(payload.messages)) {
|
|
1252
|
+
const message = slackRecord(value);
|
|
1253
|
+
if (!message || slackString(message.client_msg_id) !== input.operationId) continue;
|
|
1254
|
+
const timestamp = requiredSlackString(message.ts, "message.ts");
|
|
1255
|
+
const threadTimestamp = slackString(message.thread_ts);
|
|
1256
|
+
const threadMatches = input.threadTimestamp
|
|
1257
|
+
? threadTimestamp === input.threadTimestamp
|
|
1258
|
+
: !threadTimestamp || threadTimestamp === timestamp;
|
|
1259
|
+
if (slackString(message.text) !== input.text || !threadMatches) {
|
|
1260
|
+
throw new SlackBotProviderError("post_reconciliation_mismatch");
|
|
1261
|
+
}
|
|
1262
|
+
if (matchedTimestamp && matchedTimestamp !== timestamp) {
|
|
1263
|
+
throw new SlackBotProviderError("post_reconciliation_duplicate");
|
|
1264
|
+
}
|
|
1265
|
+
matchedTimestamp = timestamp;
|
|
1266
|
+
}
|
|
1267
|
+
const nextCursor = responseCursor(payload);
|
|
1268
|
+
if (!nextCursor) {
|
|
1269
|
+
exhausted = true;
|
|
1270
|
+
break;
|
|
1271
|
+
}
|
|
1272
|
+
if (seenCursors.has(nextCursor)) {
|
|
1273
|
+
throw new SlackBotProviderError("post_reconciliation_invalid_cursor");
|
|
1274
|
+
}
|
|
1275
|
+
seenCursors.add(nextCursor);
|
|
1276
|
+
cursor = nextCursor;
|
|
1277
|
+
}
|
|
1278
|
+
if (!exhausted) {
|
|
1279
|
+
throw new SlackBotProviderError("post_reconciliation_truncated");
|
|
1280
|
+
}
|
|
1281
|
+
if (!matchedTimestamp) {
|
|
1282
|
+
throw new SlackBotProviderError("post_outcome_unknown");
|
|
1283
|
+
}
|
|
1284
|
+
return { timestamp: matchedTimestamp };
|
|
1285
|
+
}
|
|
1286
|
+
|
|
898
1287
|
private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
|
|
899
1288
|
const payload = await this.call(headers, "conversations.info", {
|
|
900
1289
|
channel: channelId,
|
|
@@ -909,6 +1298,20 @@ export class OpenGeniSlackBotClient {
|
|
|
909
1298
|
return projected;
|
|
910
1299
|
}
|
|
911
1300
|
|
|
1301
|
+
private async requireActiveNonSharedMemberChannel(
|
|
1302
|
+
headers: Record<string, string>,
|
|
1303
|
+
channelId: string,
|
|
1304
|
+
) {
|
|
1305
|
+
const projected = await this.requireMemberChannel(headers, channelId);
|
|
1306
|
+
if (projected.isArchived) {
|
|
1307
|
+
throw new SlackBotProviderError("is_archived");
|
|
1308
|
+
}
|
|
1309
|
+
if (projected.isShared || projected.isExternallyShared || projected.isOrgShared) {
|
|
1310
|
+
throw new SlackBotProviderError("slack_connect_unsupported");
|
|
1311
|
+
}
|
|
1312
|
+
return projected;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
912
1315
|
private async requireFileForChannel(
|
|
913
1316
|
headers: Record<string, string>,
|
|
914
1317
|
operation: "file.info" | "file.content.read",
|
|
@@ -1064,13 +1467,17 @@ export class OpenGeniSlackBotClient {
|
|
|
1064
1467
|
let redirected: URL;
|
|
1065
1468
|
try {
|
|
1066
1469
|
redirected = new URL(location, url);
|
|
1067
|
-
assertPrivateSlackFileUrl(redirected);
|
|
1068
1470
|
} catch {
|
|
1069
1471
|
throw new SlackBotProviderError("invalid_file_redirect");
|
|
1070
1472
|
}
|
|
1071
|
-
if (
|
|
1473
|
+
if (isSlackOwnedInteractiveFileRedirect(redirected)) {
|
|
1072
1474
|
throw new SlackBotProviderError("file_requires_user_access");
|
|
1073
1475
|
}
|
|
1476
|
+
try {
|
|
1477
|
+
assertPrivateSlackFileUrl(redirected);
|
|
1478
|
+
} catch {
|
|
1479
|
+
throw new SlackBotProviderError("invalid_file_redirect");
|
|
1480
|
+
}
|
|
1074
1481
|
return await this.fetchPrivateFileOnce(redirected, operation);
|
|
1075
1482
|
}
|
|
1076
1483
|
|
|
@@ -1078,6 +1485,11 @@ export class OpenGeniSlackBotClient {
|
|
|
1078
1485
|
url: URL,
|
|
1079
1486
|
operation: "file.info" | "file.content.read",
|
|
1080
1487
|
): Promise<Response> {
|
|
1488
|
+
try {
|
|
1489
|
+
assertPrivateSlackFileUrl(url);
|
|
1490
|
+
} catch {
|
|
1491
|
+
throw new SlackBotProviderError("invalid_file_url");
|
|
1492
|
+
}
|
|
1081
1493
|
const headers = await this.headersForDestination(operation, url.toString());
|
|
1082
1494
|
let response: Response;
|
|
1083
1495
|
try {
|
|
@@ -1155,6 +1567,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1155
1567
|
targetId: string;
|
|
1156
1568
|
threadTimestamp?: string;
|
|
1157
1569
|
text: string;
|
|
1570
|
+
blocks?: SlackMessageBlock[];
|
|
1158
1571
|
}): string {
|
|
1159
1572
|
const key = environmentsEncryptionKeyBytes(this.settings);
|
|
1160
1573
|
if (!key) throw new Error("connection encryption is not configured");
|
|
@@ -1167,6 +1580,30 @@ export class OpenGeniSlackBotClient {
|
|
|
1167
1580
|
targetId: input.targetId,
|
|
1168
1581
|
threadTimestamp: input.threadTimestamp ?? null,
|
|
1169
1582
|
text: input.text,
|
|
1583
|
+
blocks: input.blocks ?? null,
|
|
1584
|
+
}),
|
|
1585
|
+
)
|
|
1586
|
+
.digest("hex");
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
private updateRequestDigest(input: {
|
|
1590
|
+
operationId: string;
|
|
1591
|
+
channelId: string;
|
|
1592
|
+
timestamp: string;
|
|
1593
|
+
text: string;
|
|
1594
|
+
blocks?: SlackMessageBlock[];
|
|
1595
|
+
}): string {
|
|
1596
|
+
const key = environmentsEncryptionKeyBytes(this.settings);
|
|
1597
|
+
if (!key) throw new Error("connection encryption is not configured");
|
|
1598
|
+
return createHmac("sha256", key)
|
|
1599
|
+
.update(
|
|
1600
|
+
JSON.stringify({
|
|
1601
|
+
operationId: input.operationId,
|
|
1602
|
+
connectionId: this.connection.id,
|
|
1603
|
+
channelId: input.channelId,
|
|
1604
|
+
timestamp: input.timestamp,
|
|
1605
|
+
text: input.text,
|
|
1606
|
+
blocks: input.blocks ?? null,
|
|
1170
1607
|
}),
|
|
1171
1608
|
)
|
|
1172
1609
|
.digest("hex");
|
|
@@ -1424,10 +1861,15 @@ function projectChannel(value: unknown) {
|
|
|
1424
1861
|
isPrivate: channel.is_private === true,
|
|
1425
1862
|
isMember: channel.is_member === true,
|
|
1426
1863
|
isDirectMessage: channel.is_im === true,
|
|
1864
|
+
isMpim: channel.is_mpim === true,
|
|
1427
1865
|
isArchived: channel.is_archived === true,
|
|
1428
1866
|
isShared: channel.is_shared === true,
|
|
1429
1867
|
isExternallyShared: channel.is_ext_shared === true,
|
|
1430
1868
|
isOrgShared: channel.is_org_shared === true,
|
|
1869
|
+
isPendingExternallyShared: channel.is_pending_ext_shared === true,
|
|
1870
|
+
contextTeamId: nullableBoundedSlackString(channel.context_team_id, 128),
|
|
1871
|
+
connectedTeamIds: slackStringArrayOrNull(channel.connected_team_ids, 128),
|
|
1872
|
+
sharedTeamIds: slackStringArrayOrNull(channel.shared_team_ids, 128),
|
|
1431
1873
|
topic: boundedSlackString(slackRecord(channel.topic)?.value, 1_024),
|
|
1432
1874
|
purpose: boundedSlackString(slackRecord(channel.purpose)?.value, 1_024),
|
|
1433
1875
|
numMembers:
|
|
@@ -1437,6 +1879,45 @@ function projectChannel(value: unknown) {
|
|
|
1437
1879
|
};
|
|
1438
1880
|
}
|
|
1439
1881
|
|
|
1882
|
+
function projectSlackTaskPolicyUser(value: unknown, installationTeamId: string) {
|
|
1883
|
+
const user = slackRecord(value);
|
|
1884
|
+
const id = slackString(user?.id);
|
|
1885
|
+
const teamId = nullableBoundedSlackString(user?.team_id ?? user?.team, 128);
|
|
1886
|
+
if (!user || !id) return null;
|
|
1887
|
+
const guestFactsPresent =
|
|
1888
|
+
typeof user.is_restricted === "boolean" && typeof user.is_ultra_restricted === "boolean";
|
|
1889
|
+
const explicitExternal = typeof user.is_external === "boolean" ? user.is_external : null;
|
|
1890
|
+
return {
|
|
1891
|
+
id,
|
|
1892
|
+
teamId,
|
|
1893
|
+
isGuest: guestFactsPresent
|
|
1894
|
+
? user.is_restricted === true || user.is_ultra_restricted === true
|
|
1895
|
+
: null,
|
|
1896
|
+
isExternal:
|
|
1897
|
+
explicitExternal !== null
|
|
1898
|
+
? explicitExternal
|
|
1899
|
+
: teamId === null
|
|
1900
|
+
? null
|
|
1901
|
+
: teamId !== installationTeamId,
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function nullableBoundedSlackString(value: unknown, maxLength: number): string | null {
|
|
1906
|
+
const bounded = boundedSlackString(value, maxLength);
|
|
1907
|
+
return bounded || null;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function slackStringArrayOrNull(value: unknown, maxLength: number): string[] | null {
|
|
1911
|
+
if (!Array.isArray(value)) return null;
|
|
1912
|
+
const result: string[] = [];
|
|
1913
|
+
for (const item of value) {
|
|
1914
|
+
const bounded = boundedSlackString(item, maxLength);
|
|
1915
|
+
if (!bounded) return null;
|
|
1916
|
+
result.push(bounded);
|
|
1917
|
+
}
|
|
1918
|
+
return [...new Set(result)].sort();
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1440
1921
|
function projectMessage(value: unknown) {
|
|
1441
1922
|
const message = slackRecord(value) ?? {};
|
|
1442
1923
|
return {
|
|
@@ -1884,8 +2365,8 @@ function privateSlackFileUrl(file: Record<string, unknown>): URL | null {
|
|
|
1884
2365
|
|
|
1885
2366
|
function assertPrivateSlackFileUrl(url: URL): void {
|
|
1886
2367
|
const hostname = url.hostname.toLowerCase();
|
|
1887
|
-
if (url.protocol !== "https:" || (hostname
|
|
1888
|
-
throw new Error("Slack file URL must use HTTPS
|
|
2368
|
+
if (url.protocol !== "https:" || !SLACK_PRIVATE_FILE_HOSTS.has(hostname) || url.port) {
|
|
2369
|
+
throw new Error("Slack file URL must use an allowed HTTPS Slack host");
|
|
1889
2370
|
}
|
|
1890
2371
|
if (url.username || url.password) {
|
|
1891
2372
|
throw new Error("Slack file URL must not contain credentials");
|
|
@@ -1896,10 +2377,172 @@ function isSlackInteractiveFileRedirect(url: URL): boolean {
|
|
|
1896
2377
|
return url.pathname === "/" && url.searchParams.has("redir");
|
|
1897
2378
|
}
|
|
1898
2379
|
|
|
2380
|
+
function isSlackOwnedInteractiveFileRedirect(url: URL): boolean {
|
|
2381
|
+
const hostname = url.hostname.toLowerCase();
|
|
2382
|
+
return (
|
|
2383
|
+
url.protocol === "https:" &&
|
|
2384
|
+
!url.port &&
|
|
2385
|
+
!url.username &&
|
|
2386
|
+
!url.password &&
|
|
2387
|
+
(hostname === "slack.com" || hostname.endsWith(".slack.com")) &&
|
|
2388
|
+
isSlackInteractiveFileRedirect(url)
|
|
2389
|
+
);
|
|
2390
|
+
}
|
|
2391
|
+
|
|
1899
2392
|
function normalizedContentType(value: string | null): string {
|
|
1900
2393
|
return (value ?? "application/octet-stream").split(";", 1)[0]!.trim().toLowerCase();
|
|
1901
2394
|
}
|
|
1902
2395
|
|
|
2396
|
+
export function sniffSlackReactionImageMime(
|
|
2397
|
+
bytes: Uint8Array,
|
|
2398
|
+
): "image/png" | "image/jpeg" | "image/webp" | null {
|
|
2399
|
+
if (bytes.byteLength < 12 || hasMarkupPrefix(bytes)) return null;
|
|
2400
|
+
if (isCompletePng(bytes)) return "image/png";
|
|
2401
|
+
if (isCompleteJpeg(bytes)) return "image/jpeg";
|
|
2402
|
+
if (isCompleteWebp(bytes)) return "image/webp";
|
|
2403
|
+
return null;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
function hasMarkupPrefix(bytes: Uint8Array): boolean {
|
|
2407
|
+
const prefix = new TextDecoder("utf-8", { fatal: false })
|
|
2408
|
+
.decode(bytes.subarray(0, Math.min(bytes.byteLength, 256)))
|
|
2409
|
+
.replace(/^\uFEFF/u, "")
|
|
2410
|
+
.trimStart()
|
|
2411
|
+
.toLowerCase();
|
|
2412
|
+
return (
|
|
2413
|
+
prefix.startsWith("<svg") ||
|
|
2414
|
+
prefix.startsWith("<html") ||
|
|
2415
|
+
prefix.startsWith("<!doctype") ||
|
|
2416
|
+
prefix.startsWith("<?xml")
|
|
2417
|
+
);
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
function isCompletePng(bytes: Uint8Array): boolean {
|
|
2421
|
+
const signature = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
2422
|
+
if (!signature.every((value, index) => bytes[index] === value)) return false;
|
|
2423
|
+
let offset = 8;
|
|
2424
|
+
let sawHeader = false;
|
|
2425
|
+
while (offset + 12 <= bytes.byteLength) {
|
|
2426
|
+
const length = readUint32Be(bytes, offset);
|
|
2427
|
+
const typeOffset = offset + 4;
|
|
2428
|
+
const dataOffset = offset + 8;
|
|
2429
|
+
const next = dataOffset + length + 4;
|
|
2430
|
+
if (!Number.isSafeInteger(next) || next > bytes.byteLength) return false;
|
|
2431
|
+
const type = String.fromCharCode(
|
|
2432
|
+
bytes[typeOffset]!,
|
|
2433
|
+
bytes[typeOffset + 1]!,
|
|
2434
|
+
bytes[typeOffset + 2]!,
|
|
2435
|
+
bytes[typeOffset + 3]!,
|
|
2436
|
+
);
|
|
2437
|
+
if (!sawHeader) {
|
|
2438
|
+
if (type !== "IHDR" || length !== 13) return false;
|
|
2439
|
+
sawHeader = true;
|
|
2440
|
+
}
|
|
2441
|
+
if (type === "IEND") return length === 0 && next === bytes.byteLength;
|
|
2442
|
+
offset = next;
|
|
2443
|
+
}
|
|
2444
|
+
return false;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
function isCompleteJpeg(bytes: Uint8Array): boolean {
|
|
2448
|
+
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
|
|
2449
|
+
let offset = 2;
|
|
2450
|
+
let sawFrame = false;
|
|
2451
|
+
let inScan = false;
|
|
2452
|
+
while (offset < bytes.byteLength) {
|
|
2453
|
+
if (inScan) {
|
|
2454
|
+
if (bytes[offset] !== 0xff) {
|
|
2455
|
+
offset += 1;
|
|
2456
|
+
continue;
|
|
2457
|
+
}
|
|
2458
|
+
while (bytes[offset] === 0xff) offset += 1;
|
|
2459
|
+
const marker = bytes[offset];
|
|
2460
|
+
if (marker === undefined) return false;
|
|
2461
|
+
if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
2462
|
+
offset += 1;
|
|
2463
|
+
continue;
|
|
2464
|
+
}
|
|
2465
|
+
if (marker === 0xd9) return sawFrame && offset + 1 === bytes.byteLength;
|
|
2466
|
+
inScan = false;
|
|
2467
|
+
offset -= 1;
|
|
2468
|
+
continue;
|
|
2469
|
+
}
|
|
2470
|
+
if (bytes[offset] !== 0xff) return false;
|
|
2471
|
+
while (bytes[offset] === 0xff) offset += 1;
|
|
2472
|
+
const marker = bytes[offset++];
|
|
2473
|
+
if (marker === undefined || marker === 0x00) return false;
|
|
2474
|
+
if (marker === 0xd9) return sawFrame && offset === bytes.byteLength;
|
|
2475
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
2476
|
+
if (offset + 2 > bytes.byteLength) return false;
|
|
2477
|
+
const length = (bytes[offset]! << 8) | bytes[offset + 1]!;
|
|
2478
|
+
if (length < 2 || offset + length > bytes.byteLength) return false;
|
|
2479
|
+
if (
|
|
2480
|
+
marker === 0xc0 ||
|
|
2481
|
+
marker === 0xc1 ||
|
|
2482
|
+
marker === 0xc2 ||
|
|
2483
|
+
marker === 0xc3 ||
|
|
2484
|
+
marker === 0xc5 ||
|
|
2485
|
+
marker === 0xc6 ||
|
|
2486
|
+
marker === 0xc7 ||
|
|
2487
|
+
marker === 0xc9 ||
|
|
2488
|
+
marker === 0xca ||
|
|
2489
|
+
marker === 0xcb ||
|
|
2490
|
+
marker === 0xcd ||
|
|
2491
|
+
marker === 0xce ||
|
|
2492
|
+
marker === 0xcf
|
|
2493
|
+
) {
|
|
2494
|
+
sawFrame = true;
|
|
2495
|
+
}
|
|
2496
|
+
if (marker === 0xda) inScan = true;
|
|
2497
|
+
offset += length;
|
|
2498
|
+
}
|
|
2499
|
+
return false;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
function isCompleteWebp(bytes: Uint8Array): boolean {
|
|
2503
|
+
if (
|
|
2504
|
+
ascii(bytes, 0, 4) !== "RIFF" ||
|
|
2505
|
+
ascii(bytes, 8, 12) !== "WEBP" ||
|
|
2506
|
+
readUint32Le(bytes, 4) + 8 !== bytes.byteLength
|
|
2507
|
+
) {
|
|
2508
|
+
return false;
|
|
2509
|
+
}
|
|
2510
|
+
let offset = 12;
|
|
2511
|
+
let sawImageChunk = false;
|
|
2512
|
+
while (offset + 8 <= bytes.byteLength) {
|
|
2513
|
+
const type = ascii(bytes, offset, offset + 4);
|
|
2514
|
+
const length = readUint32Le(bytes, offset + 4);
|
|
2515
|
+
const paddedLength = length + (length % 2);
|
|
2516
|
+
const next = offset + 8 + paddedLength;
|
|
2517
|
+
if (!Number.isSafeInteger(next) || next > bytes.byteLength) return false;
|
|
2518
|
+
if (type === "VP8 " || type === "VP8L" || type === "VP8X") sawImageChunk = true;
|
|
2519
|
+
offset = next;
|
|
2520
|
+
}
|
|
2521
|
+
return sawImageChunk && offset === bytes.byteLength;
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
function readUint32Be(bytes: Uint8Array, offset: number): number {
|
|
2525
|
+
return (
|
|
2526
|
+
bytes[offset]! * 0x1_000000 +
|
|
2527
|
+
bytes[offset + 1]! * 0x1_0000 +
|
|
2528
|
+
bytes[offset + 2]! * 0x100 +
|
|
2529
|
+
bytes[offset + 3]!
|
|
2530
|
+
);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function readUint32Le(bytes: Uint8Array, offset: number): number {
|
|
2534
|
+
return (
|
|
2535
|
+
bytes[offset]! +
|
|
2536
|
+
bytes[offset + 1]! * 0x100 +
|
|
2537
|
+
bytes[offset + 2]! * 0x1_0000 +
|
|
2538
|
+
bytes[offset + 3]! * 0x1_000000
|
|
2539
|
+
);
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
function ascii(bytes: Uint8Array, start: number, end: number): string {
|
|
2543
|
+
return String.fromCharCode(...bytes.subarray(start, end));
|
|
2544
|
+
}
|
|
2545
|
+
|
|
1903
2546
|
function isSupportedSlackTextContentType(value: string): boolean {
|
|
1904
2547
|
return (
|
|
1905
2548
|
value.startsWith("text/") ||
|
|
@@ -2075,11 +2718,26 @@ function slackMethodForOperation(operation: SlackBotOperation): string {
|
|
|
2075
2718
|
return "files.info";
|
|
2076
2719
|
case "message.post":
|
|
2077
2720
|
return "chat.postMessage";
|
|
2721
|
+
case "message.update":
|
|
2722
|
+
return "chat.update";
|
|
2078
2723
|
case "message.delete":
|
|
2079
2724
|
return "chat.delete";
|
|
2080
2725
|
}
|
|
2081
2726
|
}
|
|
2082
2727
|
|
|
2728
|
+
function validateSlackMessageBlocks(
|
|
2729
|
+
blocks: SlackMessageBlock[] | undefined,
|
|
2730
|
+
): SlackMessageBlock[] | undefined {
|
|
2731
|
+
if (blocks === undefined) return undefined;
|
|
2732
|
+
if (blocks.length < 1 || blocks.length > MAX_SLACK_BLOCKS) {
|
|
2733
|
+
throw new RangeError("Slack message blocks exceed the supported count");
|
|
2734
|
+
}
|
|
2735
|
+
if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > MAX_SLACK_BLOCK_BYTES) {
|
|
2736
|
+
throw new RangeError("Slack message blocks exceed the supported byte size");
|
|
2737
|
+
}
|
|
2738
|
+
return blocks;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2083
2741
|
function boundedInt(value: number | undefined, max: number, fallback: number): number {
|
|
2084
2742
|
return typeof value === "number" && Number.isInteger(value) && value > 0
|
|
2085
2743
|
? Math.min(value, max)
|
|
@@ -2131,9 +2789,9 @@ function safeFailureCode(error: unknown): string {
|
|
|
2131
2789
|
|
|
2132
2790
|
function slackMutationOutcomeMayBeAmbiguous(error: unknown): boolean {
|
|
2133
2791
|
if (!(error instanceof SlackBotProviderError)) return true;
|
|
2134
|
-
return
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2792
|
+
if (error.code === "transport_error" || error.code === "invalid_response") return true;
|
|
2793
|
+
const httpStatus = /^http_(\d{3})$/u.exec(error.code)?.[1];
|
|
2794
|
+
if (!httpStatus) return false;
|
|
2795
|
+
const status = Number(httpStatus);
|
|
2796
|
+
return status === 408 || status >= 500;
|
|
2139
2797
|
}
|