@opengeni/api-router 0.26.1 → 0.30.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.
- package/dist/app.d.ts +1 -0
- 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 +19 -1
- package/dist/browser-network-route.d.ts +8 -0
- package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
- package/dist/chunk-2PQMSRCB.js.map +1 -0
- package/dist/controller-data-plane.d.ts +12 -0
- package/dist/http/auth.d.ts +1 -1
- package/dist/http/sse.d.ts +18 -1
- package/dist/index.js +1 -1
- package/dist/integrations/api-integrations.d.ts +2 -2
- package/dist/integrations/fiken.d.ts +233 -0
- package/dist/integrations/google-drive.d.ts +6 -6
- package/dist/integrations/provider-oauth.d.ts +0 -2
- package/dist/integrations/slack-bot.d.ts +171 -1
- package/dist/integrations/slack-interactions.d.ts +17 -5
- package/dist/interaction-holder-heartbeat.d.ts +17 -0
- package/dist/interaction-metrics.d.ts +11 -0
- package/dist/mcp/server.d.ts +3 -2
- package/dist/model-catalog.d.ts +1 -0
- package/dist/routes/canonical-human-identities.d.ts +3 -0
- package/dist/routes/channels.d.ts +3 -0
- package/dist/routes/integration-facets.d.ts +3 -0
- package/dist/routes/interaction-resources.d.ts +5 -0
- package/dist/routes/organization-memberships.d.ts +3 -0
- package/dist/routes/sessions.d.ts +2 -1
- package/dist/routes/slack-task-policy.d.ts +3 -0
- package/dist/routes/supergrok.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +8 -1
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/viewer.d.ts +3 -1
- package/dist/slack-reaction-files.d.ts +27 -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 +17 -16
- package/src/app.ts +352 -49
- 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 +50 -1
- package/src/browser-network-route.ts +34 -0
- package/src/controller-data-plane.ts +35 -0
- package/src/http/auth.ts +27 -9
- package/src/http/sse.ts +229 -5
- package/src/integrations/api-integrations.ts +57 -42
- package/src/integrations/fiken.ts +1230 -0
- package/src/integrations/google-drive.ts +31 -26
- package/src/integrations/provider-oauth.ts +70 -91
- package/src/integrations/slack-bot.ts +638 -9
- package/src/integrations/slack-interactions.ts +1583 -73
- package/src/interaction-holder-heartbeat.ts +95 -0
- package/src/interaction-metrics.ts +159 -0
- package/src/mcp/documents.ts +115 -6
- package/src/mcp/server.ts +865 -242
- package/src/model-catalog.ts +19 -6
- package/src/routes/api-integrations.ts +32 -29
- package/src/routes/browser-identities.ts +24 -0
- package/src/routes/browser-sessions.ts +2122 -183
- package/src/routes/canonical-human-identities.ts +156 -0
- package/src/routes/channels.ts +90 -0
- package/src/routes/computer-sessions.ts +441 -86
- package/src/routes/connections.ts +141 -4
- package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
- package/src/routes/interaction-resources.ts +595 -0
- package/src/routes/organization-memberships.ts +53 -0
- package/src/routes/plugins.ts +2 -1
- package/src/routes/sessions.ts +419 -43
- package/src/routes/skills.ts +92 -10
- 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 +34 -4
- package/src/routes/workspaces.ts +22 -4
- package/src/sandbox/channel-a.ts +29 -2
- package/src/sandbox/machines.ts +5 -5
- package/src/sandbox/viewer.ts +10 -3
- package/src/slack-reaction-files.ts +181 -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-JIKNR5YL.js.map +0 -1
- package/dist/routes/integration-features.d.ts +0 -3
|
@@ -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,15 +453,36 @@ export class OpenGeniSlackBotClient {
|
|
|
407
453
|
return await this.requireMemberChannel(headers, channelId);
|
|
408
454
|
}
|
|
409
455
|
|
|
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
|
+
|
|
410
475
|
async channelHistory(input: {
|
|
411
476
|
channelId: string;
|
|
412
477
|
limit?: number;
|
|
413
478
|
cursor?: string;
|
|
414
479
|
latest?: string;
|
|
415
480
|
inclusive?: boolean;
|
|
481
|
+
authorizeRead?: () => Promise<void>;
|
|
416
482
|
}) {
|
|
417
483
|
return await this.withAudit("channel_history.read", async (headers) => {
|
|
418
484
|
const info = await this.requireMemberChannel(headers, input.channelId);
|
|
485
|
+
await input.authorizeRead?.();
|
|
419
486
|
const payload = await this.call(headers, "conversations.history", {
|
|
420
487
|
channel: input.channelId,
|
|
421
488
|
limit: String(boundedInt(input.limit, MAX_HISTORY_PAGE, 50)),
|
|
@@ -436,14 +503,22 @@ export class OpenGeniSlackBotClient {
|
|
|
436
503
|
threadTimestamp: string;
|
|
437
504
|
limit?: number;
|
|
438
505
|
cursor?: string;
|
|
506
|
+
oldest?: string;
|
|
507
|
+
latest?: string;
|
|
508
|
+
inclusive?: boolean;
|
|
509
|
+
authorizeRead?: () => Promise<void>;
|
|
439
510
|
}) {
|
|
440
511
|
return await this.withAudit("thread_replies.read", async (headers) => {
|
|
441
512
|
const info = await this.requireMemberChannel(headers, input.channelId);
|
|
513
|
+
await input.authorizeRead?.();
|
|
442
514
|
const payload = await this.call(headers, "conversations.replies", {
|
|
443
515
|
channel: input.channelId,
|
|
444
516
|
ts: input.threadTimestamp,
|
|
445
517
|
limit: String(boundedInt(input.limit, MAX_THREAD_PAGE, 50)),
|
|
446
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" } : {}),
|
|
447
522
|
});
|
|
448
523
|
return {
|
|
449
524
|
channel: info,
|
|
@@ -565,6 +640,99 @@ export class OpenGeniSlackBotClient {
|
|
|
565
640
|
});
|
|
566
641
|
}
|
|
567
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
|
+
|
|
568
736
|
async listUsers(input: { limit?: number; cursor?: string } = {}) {
|
|
569
737
|
return await this.withAudit("users.list", async (headers) => {
|
|
570
738
|
const payload = await this.call(headers, "users.list", {
|
|
@@ -653,18 +821,25 @@ export class OpenGeniSlackBotClient {
|
|
|
653
821
|
});
|
|
654
822
|
}
|
|
655
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
|
+
*/
|
|
656
829
|
async postMessage(input: {
|
|
657
830
|
operationId: string;
|
|
658
831
|
channelId?: string;
|
|
659
832
|
userId?: string;
|
|
660
833
|
threadTimestamp?: string;
|
|
661
834
|
text: string;
|
|
835
|
+
blocks?: SlackMessageBlock[];
|
|
662
836
|
requireActiveNonSharedChannel?: boolean;
|
|
663
837
|
}) {
|
|
664
838
|
const operation = "message.post" as const;
|
|
665
839
|
const claimHolderId = crypto.randomUUID();
|
|
666
840
|
let claimAcquired = false;
|
|
667
841
|
let providerCallStarted = false;
|
|
842
|
+
let outcomeUnknown = false;
|
|
668
843
|
try {
|
|
669
844
|
const headers = await this.headersFor(operation);
|
|
670
845
|
let channelId = input.channelId;
|
|
@@ -681,12 +856,14 @@ export class OpenGeniSlackBotClient {
|
|
|
681
856
|
}
|
|
682
857
|
const targetKind = input.userId ? "user" : "channel";
|
|
683
858
|
const targetId = input.userId ?? input.channelId!;
|
|
859
|
+
const blocks = validateSlackMessageBlocks(input.blocks);
|
|
684
860
|
const requestDigest = this.postRequestDigest({
|
|
685
861
|
operationId: input.operationId,
|
|
686
862
|
targetKind,
|
|
687
863
|
targetId,
|
|
688
864
|
...(input.threadTimestamp ? { threadTimestamp: input.threadTimestamp } : {}),
|
|
689
865
|
text: input.text,
|
|
866
|
+
...(blocks ? { blocks } : {}),
|
|
690
867
|
});
|
|
691
868
|
const claim = await claimSlackBotPostOperation(this.db, {
|
|
692
869
|
accountId: this.context.accountId,
|
|
@@ -712,17 +889,59 @@ export class OpenGeniSlackBotClient {
|
|
|
712
889
|
return this.completedPostResult(claim.operation, input.operationId, input.threadTimestamp);
|
|
713
890
|
}
|
|
714
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
|
+
}
|
|
715
921
|
if (input.requireActiveNonSharedChannel) {
|
|
716
922
|
if (input.userId || !input.channelId) {
|
|
717
923
|
throw new Error("active non-shared channel validation requires channelId");
|
|
718
924
|
}
|
|
719
925
|
await this.requireActiveNonSharedMemberChannel(headers, channelId);
|
|
720
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
|
+
}
|
|
721
937
|
providerCallStarted = true;
|
|
722
938
|
const posted = await this.call(headers, "chat.postMessage", {
|
|
723
939
|
channel: channelId,
|
|
724
940
|
text: input.text,
|
|
941
|
+
...(blocks ? { blocks: JSON.stringify(blocks) } : {}),
|
|
725
942
|
client_msg_id: input.operationId,
|
|
943
|
+
unfurl_links: "false",
|
|
944
|
+
unfurl_media: "false",
|
|
726
945
|
...(input.threadTimestamp ? { thread_ts: input.threadTimestamp } : {}),
|
|
727
946
|
});
|
|
728
947
|
const slackChannelId = requiredSlackString(posted.channel, "channel");
|
|
@@ -749,8 +968,108 @@ export class OpenGeniSlackBotClient {
|
|
|
749
968
|
);
|
|
750
969
|
} catch (error) {
|
|
751
970
|
const failureCode = safeFailureCode(error);
|
|
971
|
+
const ambiguous = providerCallStarted && slackMutationOutcomeMayBeAmbiguous(error);
|
|
752
972
|
if (claimAcquired) {
|
|
753
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, {
|
|
754
1073
|
accountId: this.context.accountId,
|
|
755
1074
|
workspaceId: this.context.workspaceId,
|
|
756
1075
|
connectionId: this.connection.id,
|
|
@@ -910,6 +1229,61 @@ export class OpenGeniSlackBotClient {
|
|
|
910
1229
|
}
|
|
911
1230
|
}
|
|
912
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
|
+
|
|
913
1287
|
private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
|
|
914
1288
|
const payload = await this.call(headers, "conversations.info", {
|
|
915
1289
|
channel: channelId,
|
|
@@ -1093,13 +1467,17 @@ export class OpenGeniSlackBotClient {
|
|
|
1093
1467
|
let redirected: URL;
|
|
1094
1468
|
try {
|
|
1095
1469
|
redirected = new URL(location, url);
|
|
1096
|
-
assertPrivateSlackFileUrl(redirected);
|
|
1097
1470
|
} catch {
|
|
1098
1471
|
throw new SlackBotProviderError("invalid_file_redirect");
|
|
1099
1472
|
}
|
|
1100
|
-
if (
|
|
1473
|
+
if (isSlackOwnedInteractiveFileRedirect(redirected)) {
|
|
1101
1474
|
throw new SlackBotProviderError("file_requires_user_access");
|
|
1102
1475
|
}
|
|
1476
|
+
try {
|
|
1477
|
+
assertPrivateSlackFileUrl(redirected);
|
|
1478
|
+
} catch {
|
|
1479
|
+
throw new SlackBotProviderError("invalid_file_redirect");
|
|
1480
|
+
}
|
|
1103
1481
|
return await this.fetchPrivateFileOnce(redirected, operation);
|
|
1104
1482
|
}
|
|
1105
1483
|
|
|
@@ -1107,6 +1485,11 @@ export class OpenGeniSlackBotClient {
|
|
|
1107
1485
|
url: URL,
|
|
1108
1486
|
operation: "file.info" | "file.content.read",
|
|
1109
1487
|
): Promise<Response> {
|
|
1488
|
+
try {
|
|
1489
|
+
assertPrivateSlackFileUrl(url);
|
|
1490
|
+
} catch {
|
|
1491
|
+
throw new SlackBotProviderError("invalid_file_url");
|
|
1492
|
+
}
|
|
1110
1493
|
const headers = await this.headersForDestination(operation, url.toString());
|
|
1111
1494
|
let response: Response;
|
|
1112
1495
|
try {
|
|
@@ -1184,6 +1567,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1184
1567
|
targetId: string;
|
|
1185
1568
|
threadTimestamp?: string;
|
|
1186
1569
|
text: string;
|
|
1570
|
+
blocks?: SlackMessageBlock[];
|
|
1187
1571
|
}): string {
|
|
1188
1572
|
const key = environmentsEncryptionKeyBytes(this.settings);
|
|
1189
1573
|
if (!key) throw new Error("connection encryption is not configured");
|
|
@@ -1196,6 +1580,30 @@ export class OpenGeniSlackBotClient {
|
|
|
1196
1580
|
targetId: input.targetId,
|
|
1197
1581
|
threadTimestamp: input.threadTimestamp ?? null,
|
|
1198
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,
|
|
1199
1607
|
}),
|
|
1200
1608
|
)
|
|
1201
1609
|
.digest("hex");
|
|
@@ -1453,10 +1861,15 @@ function projectChannel(value: unknown) {
|
|
|
1453
1861
|
isPrivate: channel.is_private === true,
|
|
1454
1862
|
isMember: channel.is_member === true,
|
|
1455
1863
|
isDirectMessage: channel.is_im === true,
|
|
1864
|
+
isMpim: channel.is_mpim === true,
|
|
1456
1865
|
isArchived: channel.is_archived === true,
|
|
1457
1866
|
isShared: channel.is_shared === true,
|
|
1458
1867
|
isExternallyShared: channel.is_ext_shared === true,
|
|
1459
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),
|
|
1460
1873
|
topic: boundedSlackString(slackRecord(channel.topic)?.value, 1_024),
|
|
1461
1874
|
purpose: boundedSlackString(slackRecord(channel.purpose)?.value, 1_024),
|
|
1462
1875
|
numMembers:
|
|
@@ -1466,6 +1879,45 @@ function projectChannel(value: unknown) {
|
|
|
1466
1879
|
};
|
|
1467
1880
|
}
|
|
1468
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
|
+
|
|
1469
1921
|
function projectMessage(value: unknown) {
|
|
1470
1922
|
const message = slackRecord(value) ?? {};
|
|
1471
1923
|
return {
|
|
@@ -1913,8 +2365,8 @@ function privateSlackFileUrl(file: Record<string, unknown>): URL | null {
|
|
|
1913
2365
|
|
|
1914
2366
|
function assertPrivateSlackFileUrl(url: URL): void {
|
|
1915
2367
|
const hostname = url.hostname.toLowerCase();
|
|
1916
|
-
if (url.protocol !== "https:" || (hostname
|
|
1917
|
-
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");
|
|
1918
2370
|
}
|
|
1919
2371
|
if (url.username || url.password) {
|
|
1920
2372
|
throw new Error("Slack file URL must not contain credentials");
|
|
@@ -1925,10 +2377,172 @@ function isSlackInteractiveFileRedirect(url: URL): boolean {
|
|
|
1925
2377
|
return url.pathname === "/" && url.searchParams.has("redir");
|
|
1926
2378
|
}
|
|
1927
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
|
+
|
|
1928
2392
|
function normalizedContentType(value: string | null): string {
|
|
1929
2393
|
return (value ?? "application/octet-stream").split(";", 1)[0]!.trim().toLowerCase();
|
|
1930
2394
|
}
|
|
1931
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
|
+
|
|
1932
2546
|
function isSupportedSlackTextContentType(value: string): boolean {
|
|
1933
2547
|
return (
|
|
1934
2548
|
value.startsWith("text/") ||
|
|
@@ -2104,11 +2718,26 @@ function slackMethodForOperation(operation: SlackBotOperation): string {
|
|
|
2104
2718
|
return "files.info";
|
|
2105
2719
|
case "message.post":
|
|
2106
2720
|
return "chat.postMessage";
|
|
2721
|
+
case "message.update":
|
|
2722
|
+
return "chat.update";
|
|
2107
2723
|
case "message.delete":
|
|
2108
2724
|
return "chat.delete";
|
|
2109
2725
|
}
|
|
2110
2726
|
}
|
|
2111
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
|
+
|
|
2112
2741
|
function boundedInt(value: number | undefined, max: number, fallback: number): number {
|
|
2113
2742
|
return typeof value === "number" && Number.isInteger(value) && value > 0
|
|
2114
2743
|
? Math.min(value, max)
|
|
@@ -2160,9 +2789,9 @@ function safeFailureCode(error: unknown): string {
|
|
|
2160
2789
|
|
|
2161
2790
|
function slackMutationOutcomeMayBeAmbiguous(error: unknown): boolean {
|
|
2162
2791
|
if (!(error instanceof SlackBotProviderError)) return true;
|
|
2163
|
-
return
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
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;
|
|
2168
2797
|
}
|