@opengeni/api-router 0.30.3 → 1.0.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/access-grant-rls.d.ts +3 -0
- package/dist/admission-trace.d.ts +8 -0
- package/dist/api-websocket.d.ts +18 -0
- package/dist/app.d.ts +50 -2
- package/dist/app.js +12 -1
- package/dist/artifact-catalog-cursor.d.ts +27 -0
- package/dist/auth/managed-auth-attempt-context.d.ts +10 -0
- package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
- package/dist/auth/managed-auth-session-adapter.d.ts +6 -0
- package/dist/auth/managed-auth.d.ts +102 -3
- package/dist/auth/managed-email.d.ts +29 -0
- package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
- package/dist/auth/new-signups-gate.d.ts +23 -0
- package/dist/auth/organization-user-setup.d.ts +80 -0
- package/dist/auth/signup-funnel-metrics.d.ts +44 -0
- package/dist/browser-controller-authority.d.ts +1 -61
- package/dist/browser-ephemeral.d.ts +5 -0
- package/dist/browser-state-authority.d.ts +1 -27
- package/dist/chunk-6TZ5LG3O.js +123948 -0
- package/dist/chunk-6TZ5LG3O.js.map +1 -0
- package/dist/chunk-OGQDXVXY.js +18 -0
- package/dist/chunk-OGQDXVXY.js.map +1 -0
- package/dist/claude-subscription-account-conflicts.d.ts +1 -0
- package/dist/claude-subscription-account-usage.d.ts +39 -0
- package/dist/claude-subscription-oauth.d.ts +25 -0
- package/dist/claude-subscription-profile.d.ts +9 -0
- package/dist/claude-subscription-usage.d.ts +44 -0
- package/dist/claude-workspace-connection.d.ts +15 -0
- package/dist/codemode.d.ts +32 -1
- package/dist/company-brain-okf.d.ts +24 -0
- package/dist/connected-machine-computer-access.d.ts +11 -0
- package/dist/connection-ownership.d.ts +54 -0
- package/dist/controller-data-plane.d.ts +32 -0
- package/dist/editable-artifact-source-session.d.ts +3 -0
- package/dist/editable-artifact-websocket.d.ts +10 -18
- package/dist/editable-artifact-workspace-files.d.ts +1 -1
- package/dist/fatal-process-boundary.d.ts +25 -0
- package/dist/github-access.d.ts +31 -2
- package/dist/http/acting-person.d.ts +24 -0
- package/dist/http/api-contract-compat.d.ts +19 -0
- package/dist/http/api-error.d.ts +27 -1
- package/dist/http/cors.d.ts +17 -0
- package/dist/http/deprecation.d.ts +42 -0
- package/dist/http/interaction-control-error.d.ts +13 -0
- package/dist/http/keyed-admission.d.ts +12 -0
- package/dist/http/local-browser-boundary.d.ts +72 -0
- package/dist/http/metrics-listener.d.ts +22 -0
- package/dist/http/path-identifier.d.ts +17 -0
- package/dist/http/registered-route-label.d.ts +16 -0
- package/dist/http/request-body.d.ts +27 -0
- package/dist/http/request-source.d.ts +52 -0
- package/dist/http/sse.d.ts +17 -6
- package/dist/http/unmatched-route.d.ts +18 -0
- package/dist/http/user-content.d.ts +36 -0
- package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +1091 -162
- package/dist/index.js.map +1 -1
- package/dist/integration-endpoint-test.d.ts +20 -0
- package/dist/integrations/api-integrations.d.ts +2 -2
- package/dist/integrations/atlassian.d.ts +29 -3
- package/dist/integrations/connect-authority.d.ts +1 -0
- package/dist/integrations/connect-callback-return.d.ts +6 -0
- package/dist/integrations/fiken.d.ts +16 -2
- package/dist/integrations/github-app-connect.d.ts +47 -0
- package/dist/integrations/github-installation-proof.d.ts +3 -0
- package/dist/integrations/github-lens-connect.d.ts +116 -0
- package/dist/integrations/github-skill-source.d.ts +1 -5
- package/dist/integrations/google-drive.d.ts +24 -0
- package/dist/integrations/integration-commit-authority.d.ts +13 -0
- package/dist/integrations/marketplace-plugin.d.ts +2691 -0
- package/dist/integrations/oauth-client.d.ts +178 -18
- package/dist/integrations/oauth-profiles.d.ts +222 -0
- package/dist/integrations/oauth-return-path.d.ts +1 -0
- package/dist/integrations/personal-github-repositories.d.ts +84 -0
- package/dist/integrations/personal-github.d.ts +42 -0
- package/dist/integrations/pr-review-provider.d.ts +22 -0
- package/dist/integrations/provider-oauth.d.ts +19 -1
- package/dist/integrations/slack-app-home.d.ts +24 -0
- package/dist/integrations/slack-bot.d.ts +367 -12
- package/dist/integrations/slack-file-upload-flow.d.ts +62 -0
- package/dist/integrations/slack-install.d.ts +20 -0
- package/dist/integrations/slack-interactions.d.ts +107 -9
- package/dist/integrations/slack-mrkdwn.d.ts +41 -0
- package/dist/integrations/slack-routing.d.ts +120 -0
- package/dist/integrations/slack-session-defaults.d.ts +58 -0
- package/dist/integrations/slack-task-file-upload.d.ts +36 -0
- package/dist/integrations/social-oauth.d.ts +7 -0
- package/dist/interaction-agent-access.d.ts +15 -0
- package/dist/interaction-frame-proxy.d.ts +68 -0
- package/dist/interaction-metrics.d.ts +2 -0
- package/dist/mcp/action-catalog-types.d.ts +9 -0
- package/dist/mcp/action-catalog.gen.d.ts +2 -0
- package/dist/mcp/capability-account-readiness.d.ts +7 -0
- package/dist/mcp/child-read-evidence.d.ts +9 -0
- package/dist/mcp/command-read.d.ts +24 -0
- package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
- package/dist/mcp/contract-input.d.ts +11 -0
- package/dist/mcp/documents.d.ts +7 -5
- package/dist/mcp/files.d.ts +2 -2
- package/dist/mcp/first-party-tool-permissions.d.ts +650 -0
- package/dist/mcp/knowledge-entries.d.ts +5 -0
- package/dist/mcp/orchestration-failure-diagnostic.d.ts +43 -0
- package/dist/mcp/receipts.d.ts +9 -0
- package/dist/mcp/request-abort.d.ts +19 -0
- package/dist/mcp/scheduled-task-input.d.ts +1189 -0
- package/dist/mcp/scheduled-task-view.d.ts +22 -4
- package/dist/mcp/server.d.ts +255 -3
- package/dist/mcp/session-event-view.d.ts +94 -0
- package/dist/mcp/session-view.d.ts +66 -2
- package/dist/mcp/session-wait.d.ts +174 -0
- package/dist/mcp-oauth.d.ts +52 -0
- package/dist/model-catalog.d.ts +11 -31
- package/dist/observability.d.ts +3 -1
- package/dist/organization-mcp.d.ts +52 -0
- package/dist/organization-recovery-notifications.d.ts +41 -0
- package/dist/routes/analytics-consent.d.ts +26 -0
- package/dist/routes/api-integrations.d.ts +25 -0
- package/dist/routes/api-keys.d.ts +17 -0
- package/dist/routes/artifact-catalog.d.ts +6 -0
- package/dist/routes/automations.d.ts +19 -0
- package/dist/routes/billing.d.ts +49 -0
- package/dist/routes/browser-sessions.d.ts +59 -2
- package/dist/routes/claude-subscription-accounts.d.ts +3 -0
- package/dist/routes/claude-subscription-oauth.d.ts +3 -0
- package/dist/routes/client-errors.d.ts +146 -0
- package/dist/routes/codex.d.ts +76 -2
- package/dist/routes/company-brain.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +2 -1
- package/dist/routes/computer-sessions.d.ts +14 -0
- package/dist/routes/connect.d.ts +5 -0
- package/dist/routes/editable-artifacts.d.ts +2 -0
- package/dist/routes/external-identity-links.d.ts +6 -0
- package/dist/routes/feedback.d.ts +3 -0
- package/dist/routes/github-browser-pages.d.ts +12 -0
- package/dist/routes/github.d.ts +2 -0
- package/dist/routes/insights-response-cache.d.ts +10 -0
- package/dist/routes/insights-usage.d.ts +12 -0
- package/dist/routes/insights.d.ts +30 -1
- package/dist/routes/knowledge.d.ts +3 -0
- package/dist/routes/managed-auth-session-sets.d.ts +26 -0
- package/dist/routes/managed-onboarding.d.ts +29 -0
- package/dist/routes/managed-sign-in-methods.d.ts +6 -0
- package/dist/routes/model-connection-access.d.ts +3 -0
- package/dist/routes/organization-integration-policy.d.ts +3 -0
- package/dist/routes/organization-integrations.d.ts +6 -0
- package/dist/routes/organization-mcp-connections.d.ts +3 -0
- package/dist/routes/organization-model-providers.d.ts +3 -0
- package/dist/routes/organization-recovery.d.ts +19 -0
- package/dist/routes/organization-service-accounts.d.ts +3 -0
- package/dist/routes/organization-sessions.d.ts +26 -0
- package/dist/routes/personal-github-git-broker.d.ts +29 -0
- package/dist/routes/personal-github.d.ts +3 -0
- package/dist/routes/pr-review-github.d.ts +3 -0
- package/dist/routes/pr-review.d.ts +3 -0
- package/dist/routes/session-artifact-associations.d.ts +4 -0
- package/dist/routes/session-history-imports.d.ts +9 -0
- package/dist/routes/sessions.d.ts +44 -6
- package/dist/routes/skill-content.d.ts +4 -0
- package/dist/routes/skill-install-authority.d.ts +6 -0
- package/dist/routes/subscription-account-pools.d.ts +34 -0
- package/dist/routes/subscription-pool-access.d.ts +16 -0
- package/dist/routes/supergrok.d.ts +6 -1
- package/dist/routes/usage-allowances.d.ts +12 -0
- package/dist/routes/user-resource-authorities.d.ts +3 -0
- package/dist/routes/workspace-artifacts.d.ts +2 -1
- package/dist/routes/workspace-capture.d.ts +10 -2
- package/dist/routes/workspace-integrations.d.ts +35 -0
- package/dist/routes/workspace-learning.d.ts +3 -0
- package/dist/routes/workspace-model-providers.d.ts +4 -0
- package/dist/routes/workspaces.d.ts +29 -0
- package/dist/sandbox/auth-callout.d.ts +8 -0
- package/dist/sandbox/channel-a.d.ts +19 -1
- package/dist/sandbox/connection-authority.d.ts +3 -0
- package/dist/sandbox/enrollment.d.ts +9 -1
- package/dist/sandbox/machines.d.ts +5 -2
- package/dist/sandbox/metrics-ingestion.d.ts +28 -20
- package/dist/sandbox/rematerialize.d.ts +2 -0
- package/dist/sandbox/session-attach-variable-sets.d.ts +30 -0
- package/dist/sandbox/viewer.d.ts +26 -1
- package/dist/sandbox-file-artifacts.d.ts +50 -0
- package/dist/scheduled-task-deletion.d.ts +13 -0
- package/dist/session-create-observability.d.ts +5 -0
- package/dist/session-event-view-MPS2OZD3.js +292 -0
- package/dist/session-event-view-MPS2OZD3.js.map +1 -0
- package/dist/site-session-origin.d.ts +13 -0
- package/dist/site-uploads.d.ts +41 -0
- package/dist/slack-reaction-files.d.ts +1 -1
- package/dist/temporal-schedule-cleanup.d.ts +1 -0
- package/dist/temporal-schedule-sync.d.ts +26 -0
- package/dist/transcription/providers/xai-subscription.d.ts +1 -0
- package/dist/transcription/service.d.ts +2 -1
- package/dist/work-discovery-observability.d.ts +33 -0
- package/dist/workspace-artifact-content.d.ts +28 -0
- package/dist/workspace-artifact-provenance.d.ts +7 -0
- package/dist/workspace-delete-observability.d.ts +10 -0
- package/dist/workspace-deletion.d.ts +6 -0
- package/dist/workspace-state-projection.d.ts +3 -5
- package/dist/workspace-tool-gateway-observability.d.ts +17 -0
- package/dist/workspace-tool-gateway.d.ts +123 -0
- package/dist/workspace-webhook-dispatch.d.ts +26 -0
- package/package.json +23 -19
- package/src/access-grant-rls.ts +75 -0
- package/src/admission-trace.ts +14 -0
- package/src/api-websocket.ts +23 -0
- package/src/app.ts +1967 -252
- package/src/artifact-catalog-cursor.ts +60 -0
- package/src/auth/managed-auth-attempt-context.ts +70 -0
- package/src/auth/managed-auth-rate-limits.ts +252 -0
- package/src/auth/managed-auth-session-adapter.ts +222 -0
- package/src/auth/managed-auth.ts +536 -60
- package/src/auth/managed-email.ts +174 -0
- package/src/auth/managed-sign-in-notifications.ts +81 -0
- package/src/auth/new-signups-gate.ts +49 -0
- package/src/auth/organization-user-setup.ts +307 -0
- package/src/auth/signup-funnel-metrics.ts +164 -0
- package/src/browser-controller-authority.ts +1 -186
- package/src/browser-ephemeral.ts +39 -0
- package/src/browser-state-authority.ts +1 -236
- package/src/claude-subscription-account-conflicts.ts +12 -0
- package/src/claude-subscription-account-usage.ts +48 -0
- package/src/claude-subscription-oauth.ts +362 -0
- package/src/claude-subscription-profile.ts +73 -0
- package/src/claude-subscription-usage.ts +97 -0
- package/src/claude-workspace-connection.ts +82 -0
- package/src/codemode.ts +194 -30
- package/src/codex-realtime.ts +8 -2
- package/src/company-brain-okf.ts +340 -0
- package/src/connected-machine-computer-access.ts +33 -0
- package/src/connection-ownership.ts +199 -0
- package/src/controller-data-plane.ts +81 -0
- package/src/editable-artifact-native-kernel.ts +26 -15
- package/src/editable-artifact-office-import.ts +28 -3
- package/src/editable-artifact-production.ts +23 -7
- package/src/editable-artifact-source-session.ts +123 -0
- package/src/editable-artifact-websocket.ts +27 -21
- package/src/editable-artifact-workspace-files.ts +31 -6
- package/src/fatal-process-boundary.ts +246 -0
- package/src/gateway-realtime.ts +1 -1
- package/src/github-access.ts +147 -5
- package/src/http/acting-person.ts +71 -0
- package/src/http/api-contract-compat.ts +49 -0
- package/src/http/api-error.ts +115 -1
- package/src/http/auth.ts +48 -0
- package/src/http/cors.ts +67 -0
- package/src/http/deprecation.ts +118 -0
- package/src/http/interaction-control-error.ts +164 -0
- package/src/http/keyed-admission.ts +29 -0
- package/src/http/local-browser-boundary.ts +343 -0
- package/src/http/metrics-listener.ts +57 -0
- package/src/http/path-identifier.ts +54 -0
- package/src/http/registered-route-label.ts +34 -0
- package/src/http/request-body.ts +127 -0
- package/src/http/request-source.ts +178 -0
- package/src/http/sse.ts +486 -132
- package/src/http/unmatched-route.ts +60 -0
- package/src/http/user-content.ts +158 -0
- package/src/http/websocket-upgrade-dispatch.ts +29 -0
- package/src/index.ts +157 -52
- package/src/integration-endpoint-test.ts +291 -0
- package/src/integrations/api-integrations.ts +98 -11
- package/src/integrations/atlassian.ts +474 -136
- package/src/integrations/connect-authority.ts +2 -0
- package/src/integrations/connect-callback-return.ts +86 -0
- package/src/integrations/fiken.ts +354 -64
- package/src/integrations/github-app-connect.ts +435 -0
- package/src/integrations/github-installation-proof.ts +60 -0
- package/src/integrations/github-lens-connect.ts +76 -0
- package/src/integrations/github-skill-source.ts +2 -142
- package/src/integrations/google-drive.ts +717 -225
- package/src/integrations/integration-commit-authority.ts +69 -0
- package/src/integrations/marketplace-plugin.ts +48 -0
- package/src/integrations/oauth-client.ts +1356 -459
- package/src/integrations/oauth-profiles.ts +537 -0
- package/src/integrations/oauth-return-path.ts +18 -0
- package/src/integrations/personal-github-repositories.ts +674 -0
- package/src/integrations/personal-github.ts +825 -0
- package/src/integrations/pr-review-provider.ts +246 -0
- package/src/integrations/provider-oauth.ts +250 -22
- package/src/integrations/slack-app-home.ts +300 -0
- package/src/integrations/slack-bot.ts +963 -239
- package/src/integrations/slack-file-upload-flow.ts +147 -0
- package/src/integrations/slack-install.ts +83 -0
- package/src/integrations/slack-interactions.ts +3167 -474
- package/src/integrations/slack-mrkdwn.ts +193 -0
- package/src/integrations/slack-routing.ts +337 -0
- package/src/integrations/slack-session-defaults.ts +224 -0
- package/src/integrations/slack-task-file-upload.ts +323 -0
- package/src/integrations/social-oauth.ts +213 -15
- package/src/interaction-agent-access.ts +48 -0
- package/src/interaction-frame-proxy.ts +409 -0
- package/src/interaction-metrics.ts +30 -0
- package/src/mcp/action-catalog-types.ts +9 -0
- package/src/mcp/action-catalog.gen.ts +6696 -0
- package/src/mcp/capability-account-readiness.ts +17 -0
- package/src/mcp/child-read-evidence.ts +19 -0
- package/src/mcp/command-read.ts +109 -0
- package/src/mcp/company-profile-agent-admin.ts +198 -0
- package/src/mcp/contract-input.ts +141 -0
- package/src/mcp/documents.ts +65 -377
- package/src/mcp/editable-artifacts.ts +12 -6
- package/src/mcp/files.ts +80 -43
- package/src/mcp/first-party-tool-permissions.ts +305 -0
- package/src/mcp/knowledge-entries.ts +322 -0
- package/src/mcp/orchestration-failure-diagnostic.ts +43 -0
- package/src/mcp/receipts.ts +34 -0
- package/src/mcp/request-abort.ts +44 -0
- package/src/mcp/scheduled-task-input.ts +76 -0
- package/src/mcp/scheduled-task-view.ts +44 -3
- package/src/mcp/server.ts +3030 -1267
- package/src/mcp/session-event-view.ts +383 -0
- package/src/mcp/session-view.ts +84 -3
- package/src/mcp/session-wait.ts +665 -0
- package/src/mcp-oauth.ts +1123 -0
- package/src/model-catalog.ts +91 -333
- package/src/observability.ts +15 -1
- package/src/organization-mcp.ts +397 -0
- package/src/organization-recovery-notifications.ts +103 -0
- package/src/routes/analytics-consent.ts +123 -0
- package/src/routes/api-integrations.ts +364 -74
- package/src/routes/api-keys.ts +336 -10
- package/src/routes/artifact-catalog.ts +279 -0
- package/src/routes/automations.ts +768 -0
- package/src/routes/billing.ts +461 -46
- package/src/routes/browser-sessions.ts +780 -112
- package/src/routes/canonical-human-identities.ts +90 -14
- package/src/routes/capabilities.ts +107 -6
- package/src/routes/channels.ts +20 -3
- package/src/routes/claude-subscription-accounts.ts +334 -0
- package/src/routes/claude-subscription-oauth.ts +142 -0
- package/src/routes/client-errors.ts +362 -0
- package/src/routes/codex.ts +833 -116
- package/src/routes/company-brain.ts +322 -0
- package/src/routes/company-profile.ts +65 -1
- package/src/routes/computer-sessions.ts +557 -106
- package/src/routes/connect.ts +1938 -0
- package/src/routes/connections.ts +1039 -349
- package/src/routes/documents.ts +488 -287
- package/src/routes/editable-artifacts.ts +79 -9
- package/src/routes/enrollments.ts +135 -39
- package/src/routes/environments.ts +154 -58
- package/src/routes/external-identity-links.ts +201 -0
- package/src/routes/feedback.ts +113 -0
- package/src/routes/files.ts +267 -33
- package/src/routes/github-browser-pages.ts +162 -0
- package/src/routes/github.ts +446 -144
- package/src/routes/insights-response-cache.ts +83 -0
- package/src/routes/insights-usage.ts +345 -0
- package/src/routes/insights.ts +153 -19
- package/src/routes/install.ts +133 -38
- package/src/routes/integration-facets.ts +107 -33
- package/src/routes/interaction-resources.ts +30 -3
- package/src/routes/knowledge.ts +488 -0
- package/src/routes/machines.ts +313 -15
- package/src/routes/managed-auth-session-sets.ts +1218 -0
- package/src/routes/managed-onboarding.ts +337 -0
- package/src/routes/managed-sign-in-methods.ts +433 -0
- package/src/routes/memory-slack-publications.ts +3 -2
- package/src/routes/model-connection-access.ts +208 -0
- package/src/routes/organization-integration-policy.ts +116 -0
- package/src/routes/organization-integrations.ts +326 -0
- package/src/routes/organization-mcp-connections.ts +178 -0
- package/src/routes/organization-memberships.ts +1019 -27
- package/src/routes/organization-model-providers.ts +293 -0
- package/src/routes/organization-recovery.ts +325 -0
- package/src/routes/organization-service-accounts.ts +128 -0
- package/src/routes/organization-sessions.ts +350 -0
- package/src/routes/personal-github-git-broker.ts +785 -0
- package/src/routes/personal-github.ts +370 -0
- package/src/routes/plugins.ts +251 -22
- package/src/routes/pr-review-github.ts +881 -0
- package/src/routes/pr-review.ts +622 -0
- package/src/routes/preference-registry.ts +12 -93
- package/src/routes/rigs.ts +167 -50
- package/src/routes/scheduled-tasks.ts +346 -41
- package/src/routes/session-artifact-associations.ts +87 -0
- package/src/routes/session-history-imports.ts +280 -0
- package/src/routes/sessions.ts +2306 -264
- package/src/routes/skill-content.ts +297 -0
- package/src/routes/skill-install-authority.ts +64 -0
- package/src/routes/skills.ts +62 -9
- package/src/routes/social.ts +46 -18
- package/src/routes/subscription-account-pools.ts +436 -0
- package/src/routes/subscription-pool-access.ts +126 -0
- package/src/routes/supergrok.ts +219 -303
- package/src/routes/transcription-recordings.ts +38 -3
- package/src/routes/transcriptions.ts +4 -0
- package/src/routes/usage-allowances.ts +378 -0
- package/src/routes/user-resource-authorities.ts +138 -0
- package/src/routes/video-generation.ts +14 -10
- package/src/routes/workspace-artifacts.ts +275 -69
- package/src/routes/workspace-capture.ts +21 -0
- package/src/routes/workspace-integrations.ts +576 -0
- package/src/routes/workspace-learning.ts +159 -0
- package/src/routes/workspace-model-providers.ts +148 -0
- package/src/routes/workspace-state.ts +38 -25
- package/src/routes/workspaces.ts +1004 -127
- package/src/sandbox/auth-callout.ts +96 -19
- package/src/sandbox/channel-a.ts +323 -24
- package/src/sandbox/connection-authority.ts +3 -0
- package/src/sandbox/enrollment.ts +81 -6
- package/src/sandbox/machines.ts +218 -76
- package/src/sandbox/metrics-ingestion.ts +411 -62
- package/src/sandbox/rematerialize.ts +121 -19
- package/src/sandbox/session-attach-variable-sets.ts +131 -0
- package/src/sandbox/viewer.ts +280 -25
- package/src/sandbox-file-artifacts.ts +413 -0
- package/src/scheduled-task-deletion.ts +138 -0
- package/src/session-create-observability.ts +46 -0
- package/src/session-realtime-context.ts +6 -3
- package/src/site-session-origin.ts +48 -0
- package/src/site-uploads.ts +224 -0
- package/src/slack-reaction-files.ts +16 -5
- package/src/temporal-schedule-cleanup.ts +13 -0
- package/src/temporal-schedule-sync.ts +74 -0
- package/src/transcription/providers/codex-subscription.ts +2 -0
- package/src/transcription/providers/openai.ts +1 -0
- package/src/transcription/providers/xai-subscription.ts +49 -3
- package/src/transcription/segmenter.ts +8 -2
- package/src/transcription/service.ts +43 -6
- package/src/work-discovery-observability.ts +121 -0
- package/src/workspace-artifact-content.ts +144 -0
- package/src/workspace-artifact-provenance.ts +104 -0
- package/src/workspace-delete-observability.ts +75 -0
- package/src/workspace-deletion.ts +73 -0
- package/src/workspace-state-projection.ts +18 -148
- package/src/workspace-tool-gateway-observability.ts +100 -0
- package/src/workspace-tool-gateway.ts +870 -0
- package/src/workspace-webhook-dispatch.ts +190 -0
- package/src/xai-subscription-auth.ts +9 -2
- package/dist/chunk-WCXHI3FF.js +0 -49920
- package/dist/chunk-WCXHI3FF.js.map +0 -1
- package/dist/routes/packs.d.ts +0 -3
- package/src/routes/packs.ts +0 -729
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
|
|
3
3
|
import {
|
|
4
4
|
OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
5
5
|
OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
6
6
|
OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
|
|
7
|
+
OPENGENI_SLACK_FILE_UPLOAD_REQUIRED_SCOPE,
|
|
7
8
|
evaluateOpenGeniSlackBotScopes,
|
|
9
|
+
hasOpenGeniSlackFileUploadScope,
|
|
10
|
+
hasOpenGeniSlackBotSearchScopes,
|
|
11
|
+
hasOpenGeniSlackReactionScope,
|
|
8
12
|
type AccessGrant,
|
|
9
13
|
type ConnectionMetadata,
|
|
14
|
+
type OpenGeniSlackBotDisplayName,
|
|
10
15
|
type OpenGeniSlackBotConnectionMetadata,
|
|
11
16
|
} from "@opengeni/contracts";
|
|
12
17
|
import {
|
|
@@ -18,14 +23,20 @@ import {
|
|
|
18
23
|
} from "@opengeni/core";
|
|
19
24
|
import {
|
|
20
25
|
buildConnectionTokenResolver,
|
|
26
|
+
buildSlackApiRateLimiter,
|
|
21
27
|
claimSlackBotDeleteOperation,
|
|
22
28
|
claimSlackBotPostOperation,
|
|
23
29
|
claimSlackBotUpdateOperation,
|
|
24
30
|
completeSlackBotDeleteOperation,
|
|
25
31
|
completeSlackBotPostOperation,
|
|
26
32
|
completeSlackBotUpdateOperation,
|
|
33
|
+
getScheduledTask,
|
|
27
34
|
getSession,
|
|
35
|
+
getSlackBotPostOperation,
|
|
28
36
|
listConnectionsMetadata,
|
|
37
|
+
prepareScheduledSlackBotMessage,
|
|
38
|
+
readScheduledSlackBotMessage,
|
|
39
|
+
ScheduledSlackBotMessageRefusedError,
|
|
29
40
|
markSlackBotDeleteOperationProviderStarted,
|
|
30
41
|
markSlackBotPostOperationProviderStarted,
|
|
31
42
|
recordAuditEvent,
|
|
@@ -41,11 +52,14 @@ import {
|
|
|
41
52
|
type FetchLike,
|
|
42
53
|
} from "@opengeni/network";
|
|
43
54
|
import { HTTPException } from "hono/http-exception";
|
|
55
|
+
import sharp from "sharp";
|
|
44
56
|
|
|
45
57
|
const SLACK_API_BASE = "https://slack.com/api/";
|
|
46
58
|
const SLACK_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
47
59
|
const SLACK_FILE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
|
|
48
60
|
export const SLACK_REACTION_IMAGE_MAX_BYTES = 4 * 1024 * 1024;
|
|
61
|
+
// Base64 plus MCP metadata must stay below the runtime's 1 MiB model-result cap.
|
|
62
|
+
export const SLACK_MCP_IMAGE_MAX_BYTES = 640 * 1024;
|
|
49
63
|
const SLACK_FILE_CONTENT_PAGE_CHARS = 50_000;
|
|
50
64
|
const SLACK_TIMEOUT_MS = 10_000;
|
|
51
65
|
const MAX_CHANNEL_PAGE = 200;
|
|
@@ -64,6 +78,12 @@ const MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS = 1_500;
|
|
|
64
78
|
const MAX_REACTION_CONTEXT_CHECKPOINT_FILES = 16;
|
|
65
79
|
const SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION = 1;
|
|
66
80
|
const MAX_USER_PAGE = 200;
|
|
81
|
+
/** Slack caps `assistant.search.context` at 20 results per request. */
|
|
82
|
+
const MAX_SEARCH_PAGE = 20;
|
|
83
|
+
/** Bounded projection cap for one search result's content excerpt. */
|
|
84
|
+
const MAX_SEARCH_CONTENT_CHARS = 2_000;
|
|
85
|
+
/** Matches the MCP input schema's cap; Slack signals its own via query_too_long. */
|
|
86
|
+
const MAX_SEARCH_QUERY_CHARS = 500;
|
|
67
87
|
const MAX_FILE_PAGE = 200;
|
|
68
88
|
const MAX_FILE_CURSOR_LENGTH = 1_024;
|
|
69
89
|
const SLACK_FILE_CURSOR_VERSION = "files-v1";
|
|
@@ -106,6 +126,11 @@ export type DownloadedSlackReactionImage = Readonly<{
|
|
|
106
126
|
bytes: Uint8Array;
|
|
107
127
|
}>;
|
|
108
128
|
|
|
129
|
+
export type ExchangedOpenGeniSlackAuthorization = Readonly<{
|
|
130
|
+
accessToken: string;
|
|
131
|
+
appId: string;
|
|
132
|
+
}>;
|
|
133
|
+
|
|
109
134
|
export async function exchangeOpenGeniSlackAuthorizationCode(
|
|
110
135
|
input: {
|
|
111
136
|
code: string;
|
|
@@ -114,7 +139,7 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
|
|
|
114
139
|
redirectUri: string;
|
|
115
140
|
},
|
|
116
141
|
fetchImpl: FetchLike = fetch,
|
|
117
|
-
): Promise<
|
|
142
|
+
): Promise<ExchangedOpenGeniSlackAuthorization> {
|
|
118
143
|
const body = new URLSearchParams({
|
|
119
144
|
code: input.code,
|
|
120
145
|
client_id: input.clientId,
|
|
@@ -158,7 +183,13 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
|
|
|
158
183
|
message: "Slack installation did not return a bot token",
|
|
159
184
|
});
|
|
160
185
|
}
|
|
161
|
-
|
|
186
|
+
const appId = slackString(record.app_id);
|
|
187
|
+
if (!appId) {
|
|
188
|
+
throw new HTTPException(502, {
|
|
189
|
+
message: "Slack installation did not return an app identity",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return { accessToken, appId };
|
|
162
193
|
}
|
|
163
194
|
|
|
164
195
|
export type SlackBotReceipt = {
|
|
@@ -188,16 +219,59 @@ export type SlackMessageBlock =
|
|
|
188
219
|
style?: "primary" | "danger";
|
|
189
220
|
}>;
|
|
190
221
|
}
|
|
222
|
+
| {
|
|
223
|
+
type: "context";
|
|
224
|
+
block_id?: string;
|
|
225
|
+
elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>;
|
|
226
|
+
}
|
|
227
|
+
| { type: "divider" };
|
|
228
|
+
|
|
229
|
+
export type SlackHomeBlock =
|
|
230
|
+
| {
|
|
231
|
+
type: "header";
|
|
232
|
+
block_id?: string;
|
|
233
|
+
text: { type: "plain_text"; text: string; emoji?: boolean };
|
|
234
|
+
}
|
|
235
|
+
| {
|
|
236
|
+
type: "section";
|
|
237
|
+
block_id?: string;
|
|
238
|
+
text: { type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean };
|
|
239
|
+
accessory?: {
|
|
240
|
+
type: "button";
|
|
241
|
+
action_id: string;
|
|
242
|
+
text: { type: "plain_text"; text: string; emoji?: boolean };
|
|
243
|
+
url: string;
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
| {
|
|
247
|
+
type: "context";
|
|
248
|
+
block_id?: string;
|
|
249
|
+
elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>;
|
|
250
|
+
}
|
|
251
|
+
| {
|
|
252
|
+
type: "actions";
|
|
253
|
+
block_id: string;
|
|
254
|
+
elements: Array<{
|
|
255
|
+
type: "button";
|
|
256
|
+
action_id: string;
|
|
257
|
+
text: { type: "plain_text"; text: string; emoji?: boolean };
|
|
258
|
+
url: string;
|
|
259
|
+
style?: "primary" | "danger";
|
|
260
|
+
}>;
|
|
261
|
+
}
|
|
191
262
|
| { type: "divider" };
|
|
192
263
|
|
|
193
264
|
type SlackBotOperation =
|
|
194
265
|
| "channels.list"
|
|
266
|
+
| "search.context"
|
|
195
267
|
| "channel_history.read"
|
|
196
268
|
| "thread_replies.read"
|
|
197
269
|
| "users.list"
|
|
198
270
|
| "files.list"
|
|
199
271
|
| "file.info"
|
|
200
272
|
| "file.content.read"
|
|
273
|
+
| "file.upload"
|
|
274
|
+
| "home.publish"
|
|
201
275
|
| "message.post"
|
|
202
276
|
| "message.update"
|
|
203
277
|
| "message.delete";
|
|
@@ -210,6 +284,9 @@ type SlackBotContext = {
|
|
|
210
284
|
scheduledTaskId?: string | null;
|
|
211
285
|
};
|
|
212
286
|
|
|
287
|
+
type SlackCallAuthority = Readonly<{ operation: SlackBotOperation }>;
|
|
288
|
+
type SlackProviderAuthorization = () => Promise<boolean | void>;
|
|
289
|
+
|
|
213
290
|
export type SlackReactionContextCheckpointBinding = {
|
|
214
291
|
inboxId: string;
|
|
215
292
|
accountId: string;
|
|
@@ -259,6 +336,95 @@ export class SlackBotProviderError extends Error {
|
|
|
259
336
|
}
|
|
260
337
|
}
|
|
261
338
|
|
|
339
|
+
export function slackHistoryRateLimited(error: unknown): error is SlackBotProviderError {
|
|
340
|
+
return (
|
|
341
|
+
error instanceof SlackBotProviderError &&
|
|
342
|
+
(error.code === "http_429" || error.code === "rate_limited" || error.code === "ratelimited")
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The post or update ledger already binds this operation id to different
|
|
348
|
+
* request bytes. Raised from the durable claim, before any Slack write, so a
|
|
349
|
+
* caller that knows an earlier release wrote other bytes for the same operation
|
|
350
|
+
* may retry once with those bytes.
|
|
351
|
+
*/
|
|
352
|
+
export class SlackBotOperationConflictError extends Error {
|
|
353
|
+
constructor(kind: "post" | "update" | "delete") {
|
|
354
|
+
super(`operationId is already bound to a different Slack ${kind} request`);
|
|
355
|
+
this.name = "SlackBotOperationConflictError";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Only Slack's one-time HTTPS upload endpoint may receive retained bytes. */
|
|
360
|
+
export function slackFileUploadUrl(value: string): URL {
|
|
361
|
+
let url: URL;
|
|
362
|
+
try {
|
|
363
|
+
url = new URL(value);
|
|
364
|
+
} catch {
|
|
365
|
+
throw new SlackBotProviderError("invalid_upload_url");
|
|
366
|
+
}
|
|
367
|
+
if (
|
|
368
|
+
value.length > 4096 ||
|
|
369
|
+
url.protocol !== "https:" ||
|
|
370
|
+
url.hostname !== "files.slack.com" ||
|
|
371
|
+
url.port !== "" ||
|
|
372
|
+
url.username ||
|
|
373
|
+
url.password ||
|
|
374
|
+
url.hash ||
|
|
375
|
+
!url.pathname.startsWith("/upload/v1/")
|
|
376
|
+
) {
|
|
377
|
+
throw new SlackBotProviderError("invalid_upload_url");
|
|
378
|
+
}
|
|
379
|
+
return url;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** A channel listing alone is not proof of delivery to the requested thread. */
|
|
383
|
+
export function slackFileSharedToThread(
|
|
384
|
+
value: unknown,
|
|
385
|
+
input: { fileId: string; channelId: string; threadTimestamp: string; botUserId: string },
|
|
386
|
+
): boolean {
|
|
387
|
+
const file = slackRecord(value);
|
|
388
|
+
if (file?.id !== input.fileId || file.user !== input.botUserId) return false;
|
|
389
|
+
const shares = slackRecord(file.shares);
|
|
390
|
+
return ["public", "private"].some((kind) => {
|
|
391
|
+
const entries = slackRecord(shares?.[kind])?.[input.channelId];
|
|
392
|
+
return (
|
|
393
|
+
Array.isArray(entries) &&
|
|
394
|
+
entries.some((entry) => slackRecord(entry)?.thread_ts === input.threadTimestamp)
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function authorizeSlackSharedImageRead(
|
|
400
|
+
channel: {
|
|
401
|
+
isArchived: boolean;
|
|
402
|
+
isShared: boolean;
|
|
403
|
+
isExternallyShared: boolean;
|
|
404
|
+
isOrgShared: boolean;
|
|
405
|
+
isPendingExternallyShared: boolean;
|
|
406
|
+
isMpim: boolean;
|
|
407
|
+
},
|
|
408
|
+
authorizeSharedRead: (() => Promise<void>) | undefined,
|
|
409
|
+
): Promise<void> {
|
|
410
|
+
if (channel.isArchived) {
|
|
411
|
+
throw new SlackBotProviderError("is_archived");
|
|
412
|
+
}
|
|
413
|
+
if (
|
|
414
|
+
!channel.isShared &&
|
|
415
|
+
!channel.isExternallyShared &&
|
|
416
|
+
!channel.isOrgShared &&
|
|
417
|
+
!channel.isPendingExternallyShared &&
|
|
418
|
+
!channel.isMpim
|
|
419
|
+
) {
|
|
420
|
+
throw new SlackBotProviderError("slack_connect_unsupported");
|
|
421
|
+
}
|
|
422
|
+
if (!authorizeSharedRead) {
|
|
423
|
+
throw new SlackBotProviderError("slack_connect_unsupported");
|
|
424
|
+
}
|
|
425
|
+
await authorizeSharedRead();
|
|
426
|
+
}
|
|
427
|
+
|
|
262
428
|
const SLACK_CREDENTIAL_REJECTION_CODES = new Set([
|
|
263
429
|
"account_inactive",
|
|
264
430
|
"invalid_auth",
|
|
@@ -285,12 +451,16 @@ export class SlackBotCredentialVerificationError extends HTTPException {
|
|
|
285
451
|
|
|
286
452
|
/**
|
|
287
453
|
* Validates a write-only xoxb credential before it can enter encrypted storage.
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
454
|
+
* The OAuth exchange's app_id is the immutable app authority. Slack bot profile
|
|
455
|
+
* names are mutable presentation data and may lag manifest/App Home changes, so
|
|
456
|
+
* they must not decide whether a credential belongs to this installation.
|
|
291
457
|
*/
|
|
292
458
|
export async function verifyOpenGeniSlackBotCredential(
|
|
293
459
|
token: string,
|
|
460
|
+
expected: Readonly<{
|
|
461
|
+
appId: string;
|
|
462
|
+
displayName: OpenGeniSlackBotDisplayName;
|
|
463
|
+
}>,
|
|
294
464
|
fetchImpl: FetchLike = fetch,
|
|
295
465
|
now: Date = new Date(),
|
|
296
466
|
): Promise<VerifiedOpenGeniSlackBot> {
|
|
@@ -314,11 +484,11 @@ export async function verifyOpenGeniSlackBotCredential(
|
|
|
314
484
|
);
|
|
315
485
|
}
|
|
316
486
|
const profile = slackRecord(user.profile);
|
|
317
|
-
const
|
|
318
|
-
if (
|
|
487
|
+
const installedAppId = slackString(profile?.api_app_id);
|
|
488
|
+
if (installedAppId !== expected.appId) {
|
|
319
489
|
throw new SlackBotCredentialVerificationError(
|
|
320
490
|
"identity_mismatch",
|
|
321
|
-
|
|
491
|
+
"Slack credential does not belong to the authorized Slack app",
|
|
322
492
|
);
|
|
323
493
|
}
|
|
324
494
|
|
|
@@ -331,7 +501,7 @@ export async function verifyOpenGeniSlackBotCredential(
|
|
|
331
501
|
slackTeamName,
|
|
332
502
|
botUserId,
|
|
333
503
|
botId,
|
|
334
|
-
botDisplayName:
|
|
504
|
+
botDisplayName: expected.displayName,
|
|
335
505
|
verifiedAt: now.toISOString(),
|
|
336
506
|
},
|
|
337
507
|
};
|
|
@@ -355,17 +525,17 @@ export async function resolveSlackBotConnectionForTool(input: {
|
|
|
355
525
|
}
|
|
356
526
|
const boundConnectionId = scheduledSlackBotConnectionId(session?.metadata);
|
|
357
527
|
if (boundConnectionId && (!session || !isTrustedScheduledSlackBotSession(session))) {
|
|
358
|
-
throw new Error("
|
|
528
|
+
throw new Error("Opengeni Slack bot routing metadata is not scheduler-authorized");
|
|
359
529
|
}
|
|
360
530
|
if (
|
|
361
531
|
boundConnectionId &&
|
|
362
532
|
input.requestedConnectionId &&
|
|
363
533
|
input.requestedConnectionId !== boundConnectionId
|
|
364
534
|
) {
|
|
365
|
-
throw new Error("this scheduled session is bound to a different
|
|
535
|
+
throw new Error("this scheduled session is bound to a different Opengeni Slack bot connection");
|
|
366
536
|
}
|
|
367
537
|
if (!boundConnectionId && !input.grant.permissions.includes("connections:read")) {
|
|
368
|
-
throw new Error("connections:read is required to select an
|
|
538
|
+
throw new Error("connections:read is required to select an Opengeni Slack bot connection");
|
|
369
539
|
}
|
|
370
540
|
let connectionId = boundConnectionId ?? input.requestedConnectionId;
|
|
371
541
|
if (!connectionId) {
|
|
@@ -375,7 +545,7 @@ export async function resolveSlackBotConnectionForTool(input: {
|
|
|
375
545
|
(connection) => connection.status === "active" && isOpenGeniSlackBotConnection(connection),
|
|
376
546
|
);
|
|
377
547
|
if (activeConnections.length === 0) {
|
|
378
|
-
throw new Error("no active
|
|
548
|
+
throw new Error("no active Opengeni Slack bot connection is installed in this workspace");
|
|
379
549
|
}
|
|
380
550
|
if (activeConnections.length > 1) {
|
|
381
551
|
const principals = new Set(
|
|
@@ -386,7 +556,7 @@ export async function resolveSlackBotConnectionForTool(input: {
|
|
|
386
556
|
);
|
|
387
557
|
if (principals.size > 1) {
|
|
388
558
|
throw new Error(
|
|
389
|
-
"connectionId is required because this workspace has multiple active
|
|
559
|
+
"connectionId is required because this workspace has multiple active Opengeni Slack bot connections",
|
|
390
560
|
);
|
|
391
561
|
}
|
|
392
562
|
}
|
|
@@ -399,7 +569,7 @@ export async function resolveSlackBotConnectionForTool(input: {
|
|
|
399
569
|
);
|
|
400
570
|
const metadata = openGeniSlackBotMetadata(connection.metadata);
|
|
401
571
|
if (!metadata) {
|
|
402
|
-
throw new Error("
|
|
572
|
+
throw new Error("Opengeni Slack bot connection metadata is invalid");
|
|
403
573
|
}
|
|
404
574
|
return {
|
|
405
575
|
connection,
|
|
@@ -417,8 +587,209 @@ export async function resolveSlackBotConnectionForTool(input: {
|
|
|
417
587
|
};
|
|
418
588
|
}
|
|
419
589
|
|
|
590
|
+
/**
|
|
591
|
+
* The one destination a scheduled run may post to as the OpenGeni bot: the
|
|
592
|
+
* channel a person chose on the task, through the bot connection frozen on the
|
|
593
|
+
* run's session. The agent never supplies a channel. Every call re-reads the
|
|
594
|
+
* task, so a person clearing or changing the channel takes effect at once.
|
|
595
|
+
*/
|
|
596
|
+
export async function resolveScheduledSlackBotPostTarget(input: {
|
|
597
|
+
db: Database;
|
|
598
|
+
grant: AccessGrant;
|
|
599
|
+
sessionId: string | null;
|
|
600
|
+
}): Promise<
|
|
601
|
+
Awaited<ReturnType<typeof resolveSlackBotConnectionForTool>> & {
|
|
602
|
+
scheduledTaskId: string;
|
|
603
|
+
channelId: string;
|
|
604
|
+
}
|
|
605
|
+
> {
|
|
606
|
+
const refuse = (reason: string): never => {
|
|
607
|
+
throw new Error(`Posting to the task's Slack channel is unavailable: ${reason}`);
|
|
608
|
+
};
|
|
609
|
+
if (!input.sessionId) refuse("this is not a scheduled task run");
|
|
610
|
+
const session = await getSession(input.db, input.grant.workspaceId, input.sessionId!);
|
|
611
|
+
if (!session || !isTrustedScheduledSlackBotSession(session)) {
|
|
612
|
+
refuse("this is not a scheduled task run with an Opengeni Slack bot");
|
|
613
|
+
}
|
|
614
|
+
const connectionId = scheduledSlackBotConnectionId(session!.metadata)!;
|
|
615
|
+
const scheduledTaskId = String(session!.metadata.scheduledTaskId);
|
|
616
|
+
const task = await getScheduledTask(input.db, input.grant.workspaceId, scheduledTaskId);
|
|
617
|
+
if (!task) refuse("the scheduled task was deleted");
|
|
618
|
+
if (task!.runMode === "existing_session") refuse("the task continues an existing chat");
|
|
619
|
+
if (task!.agentConfig.slackBotConnectionId !== connectionId) {
|
|
620
|
+
refuse("the task no longer uses this Opengeni Slack bot");
|
|
621
|
+
}
|
|
622
|
+
const channelId = task!.agentConfig.slackBotChannelId;
|
|
623
|
+
if (!channelId) refuse("no one has chosen a Slack channel for this task");
|
|
624
|
+
const resolved = await resolveSlackBotConnectionForTool({
|
|
625
|
+
db: input.db,
|
|
626
|
+
grant: input.grant,
|
|
627
|
+
sessionId: input.sessionId,
|
|
628
|
+
requestedConnectionId: connectionId,
|
|
629
|
+
});
|
|
630
|
+
return { ...resolved, scheduledTaskId, channelId: channelId! };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Save one message for the task's channel without sending it. The returned id
|
|
635
|
+
* is server-owned and becomes the Slack post operation id, so retrying the
|
|
636
|
+
* send can never post the same message twice.
|
|
637
|
+
*/
|
|
638
|
+
export async function prepareScheduledSlackBotPost(input: {
|
|
639
|
+
db: Database;
|
|
640
|
+
grant: AccessGrant;
|
|
641
|
+
sessionId: string | null;
|
|
642
|
+
text: string;
|
|
643
|
+
threadTimestamp?: string | undefined;
|
|
644
|
+
}) {
|
|
645
|
+
const target = await resolveScheduledSlackBotPostTarget(input);
|
|
646
|
+
const message = await prepareScheduledSlackBotMessage(input.db, {
|
|
647
|
+
accountId: input.grant.accountId,
|
|
648
|
+
workspaceId: input.grant.workspaceId,
|
|
649
|
+
sessionId: input.sessionId!,
|
|
650
|
+
scheduledTaskId: target.scheduledTaskId,
|
|
651
|
+
connectionId: target.connection.id,
|
|
652
|
+
connectionVersion: target.connection.version,
|
|
653
|
+
channelId: target.channelId,
|
|
654
|
+
threadTimestamp: input.threadTimestamp ?? null,
|
|
655
|
+
text: input.text,
|
|
656
|
+
}).catch((error: unknown) => {
|
|
657
|
+
if (error instanceof ScheduledSlackBotMessageRefusedError) {
|
|
658
|
+
throw new Error(`Posting to the task's Slack channel is unavailable: ${error.message}`);
|
|
659
|
+
}
|
|
660
|
+
throw error;
|
|
661
|
+
});
|
|
662
|
+
return {
|
|
663
|
+
messageId: message.id,
|
|
664
|
+
identity: "workspace_bot" as const,
|
|
665
|
+
channelId: message.channelId,
|
|
666
|
+
threadTimestamp: message.threadTimestamp,
|
|
667
|
+
text: message.text,
|
|
668
|
+
sent: false,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Send a message this run prepared, exactly as saved. The destination must
|
|
674
|
+
* still be the task's channel and the bot connection unchanged; otherwise the
|
|
675
|
+
* send is refused rather than redirected.
|
|
676
|
+
*/
|
|
677
|
+
export async function sendScheduledSlackBotPost(input: {
|
|
678
|
+
db: Database;
|
|
679
|
+
settings: Settings;
|
|
680
|
+
grant: AccessGrant;
|
|
681
|
+
sessionId: string | null;
|
|
682
|
+
messageId: string;
|
|
683
|
+
slackFetch?: typeof fetch;
|
|
684
|
+
authorizeProviderRequest?: SlackProviderAuthorization;
|
|
685
|
+
}) {
|
|
686
|
+
const target = await resolveScheduledSlackBotPostTarget(input);
|
|
687
|
+
const message = await readScheduledSlackBotMessage(input.db, {
|
|
688
|
+
accountId: input.grant.accountId,
|
|
689
|
+
workspaceId: input.grant.workspaceId,
|
|
690
|
+
sessionId: input.sessionId!,
|
|
691
|
+
id: input.messageId,
|
|
692
|
+
});
|
|
693
|
+
if (!message) {
|
|
694
|
+
throw new Error("This prepared Slack message does not exist in this chat");
|
|
695
|
+
}
|
|
696
|
+
const channelChanged =
|
|
697
|
+
message.channelId !== target.channelId || message.scheduledTaskId !== target.scheduledTaskId;
|
|
698
|
+
const botChanged =
|
|
699
|
+
message.connectionId !== target.connection.id ||
|
|
700
|
+
message.connectionVersion !== target.connection.version;
|
|
701
|
+
const client = createOpenGeniSlackBotClient(
|
|
702
|
+
{
|
|
703
|
+
db: input.db,
|
|
704
|
+
settings: input.settings,
|
|
705
|
+
...(input.slackFetch ? { slackFetch: input.slackFetch } : {}),
|
|
706
|
+
...(input.authorizeProviderRequest
|
|
707
|
+
? { authorizeProviderRequest: input.authorizeProviderRequest }
|
|
708
|
+
: {}),
|
|
709
|
+
},
|
|
710
|
+
target,
|
|
711
|
+
);
|
|
712
|
+
const post = () =>
|
|
713
|
+
client.postMessage({
|
|
714
|
+
operationId: message.id,
|
|
715
|
+
channelId: message.channelId,
|
|
716
|
+
...(message.threadTimestamp ? { threadTimestamp: message.threadTimestamp } : {}),
|
|
717
|
+
text: message.text,
|
|
718
|
+
requireActiveNonSharedChannel: true,
|
|
719
|
+
});
|
|
720
|
+
if (!channelChanged && !botChanged) return await post();
|
|
721
|
+
// The destination moved after this message was prepared, so it is never sent
|
|
722
|
+
// now. Say truthfully whether an earlier send already reached Slack, so the
|
|
723
|
+
// agent does not post the same content again believing nothing was sent.
|
|
724
|
+
const earlier = await getSlackBotPostOperation(
|
|
725
|
+
input.db,
|
|
726
|
+
input.grant.workspaceId,
|
|
727
|
+
message.connectionId,
|
|
728
|
+
message.id,
|
|
729
|
+
);
|
|
730
|
+
if (earlier?.status === "completed" && message.connectionId === target.connection.id) {
|
|
731
|
+
// Replays the recorded result from the post ledger; no Slack call is made.
|
|
732
|
+
return await post();
|
|
733
|
+
}
|
|
734
|
+
const reason = channelChanged
|
|
735
|
+
? "The task's Slack channel changed after this message was prepared"
|
|
736
|
+
: "The Opengeni Slack bot changed after this message was prepared";
|
|
737
|
+
if (earlier?.status === "completed") {
|
|
738
|
+
throw new Error(`${reason}. It had already been posted, so it was not sent again.`);
|
|
739
|
+
}
|
|
740
|
+
if (earlier && earlier.status !== "pending") {
|
|
741
|
+
throw new Error(
|
|
742
|
+
`${reason}. An earlier send was interrupted, so it may already have been posted to the previous channel; it was not sent again.`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
throw new Error(`${reason}, so it was not sent. Prepare a new message for the current channel.`);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Check, with the bot's token, that a person's chosen task channel is one the
|
|
750
|
+
* bot is a member of, active, and not shared with another organization.
|
|
751
|
+
*/
|
|
752
|
+
export async function verifyScheduledTaskSlackChannel(
|
|
753
|
+
deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
|
|
754
|
+
input: {
|
|
755
|
+
accountId: string;
|
|
756
|
+
workspaceId: string;
|
|
757
|
+
subjectId: string;
|
|
758
|
+
connectionId: string;
|
|
759
|
+
channelId: string;
|
|
760
|
+
},
|
|
761
|
+
): Promise<void> {
|
|
762
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
763
|
+
accountId: input.accountId,
|
|
764
|
+
workspaceId: input.workspaceId,
|
|
765
|
+
connectionId: input.connectionId,
|
|
766
|
+
subjectId: input.subjectId,
|
|
767
|
+
});
|
|
768
|
+
let channel: Awaited<ReturnType<OpenGeniSlackBotClient["verifyChannelAccess"]>>;
|
|
769
|
+
try {
|
|
770
|
+
channel = await client.verifyChannelAccess(input.channelId);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
throw new HTTPException(422, {
|
|
773
|
+
message: `The Opengeni bot cannot post in that Slack channel. Invite it to the channel first. (${safeFailureCode(error)})`,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
if (
|
|
777
|
+
channel.isDirectMessage ||
|
|
778
|
+
channel.isArchived ||
|
|
779
|
+
channel.isShared ||
|
|
780
|
+
channel.isExternallyShared ||
|
|
781
|
+
channel.isOrgShared
|
|
782
|
+
) {
|
|
783
|
+
throw new HTTPException(422, {
|
|
784
|
+
message:
|
|
785
|
+
"Scheduled posts need an active Slack channel that is not shared with another organization",
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
420
790
|
export class OpenGeniSlackBotClient {
|
|
421
791
|
private readonly resolveCredential: ReturnType<typeof buildConnectionTokenResolver>;
|
|
792
|
+
private readonly rateLimit: ReturnType<typeof buildSlackApiRateLimiter>;
|
|
422
793
|
|
|
423
794
|
constructor(
|
|
424
795
|
private readonly db: Database,
|
|
@@ -427,8 +798,20 @@ export class OpenGeniSlackBotClient {
|
|
|
427
798
|
private readonly metadata: OpenGeniSlackBotConnectionMetadata,
|
|
428
799
|
private readonly context: SlackBotContext,
|
|
429
800
|
private readonly fetchImpl: FetchLike = fetch,
|
|
801
|
+
private readonly authorizeProviderRequest?: SlackProviderAuthorization,
|
|
430
802
|
) {
|
|
431
803
|
this.resolveCredential = buildConnectionTokenResolver(db, settings);
|
|
804
|
+
this.rateLimit = buildSlackApiRateLimiter(db, settings);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private historyPageLimit(approvedLimit: number): number {
|
|
808
|
+
return this.settings.slackAccessMode === "full" ? approvedLimit : 15;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private postReconciliationPageLimit(): number {
|
|
812
|
+
// Ambiguous writes stay unknown when the one bounded pilot page cannot
|
|
813
|
+
// prove the provider identity. Never crawl or resend an unknown message.
|
|
814
|
+
return this.settings.slackAccessMode === "full" ? MAX_SLACK_POST_RECONCILIATION_PAGES : 1;
|
|
432
815
|
}
|
|
433
816
|
|
|
434
817
|
async listChannels(input: { limit?: number; cursor?: string } = {}) {
|
|
@@ -453,6 +836,159 @@ export class OpenGeniSlackBotClient {
|
|
|
453
836
|
return await this.requireMemberChannel(headers, channelId);
|
|
454
837
|
}
|
|
455
838
|
|
|
839
|
+
/** Server-only upload preparation; the temporary URL never enters a tool result. */
|
|
840
|
+
async allocateFileUpload(input: {
|
|
841
|
+
channelId: string;
|
|
842
|
+
privateRecipientSlackUserId?: string;
|
|
843
|
+
filename: string;
|
|
844
|
+
sizeBytes: number;
|
|
845
|
+
}) {
|
|
846
|
+
this.requireFileUploadScope();
|
|
847
|
+
return this.withAudit("file.upload", async (authority) => {
|
|
848
|
+
await this.requireActiveNonSharedMemberChannel(
|
|
849
|
+
authority,
|
|
850
|
+
input.channelId,
|
|
851
|
+
input.privateRecipientSlackUserId,
|
|
852
|
+
);
|
|
853
|
+
const payload = await this.call(authority, "files.getUploadURLExternal", {
|
|
854
|
+
filename: input.filename,
|
|
855
|
+
length: String(input.sizeBytes),
|
|
856
|
+
});
|
|
857
|
+
const fileId = slackString(payload.file_id);
|
|
858
|
+
const uploadUrl = slackString(payload.upload_url);
|
|
859
|
+
if (!fileId || !/^F[A-Z0-9]{1,63}$/.test(fileId) || !uploadUrl) {
|
|
860
|
+
throw new SlackBotProviderError("invalid_upload_response");
|
|
861
|
+
}
|
|
862
|
+
return { fileId, uploadUrl: slackFileUploadUrl(uploadUrl) };
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async transferFileUpload(input: {
|
|
867
|
+
channelId: string;
|
|
868
|
+
privateRecipientSlackUserId?: string;
|
|
869
|
+
uploadUrl: URL;
|
|
870
|
+
bytes: Uint8Array;
|
|
871
|
+
}) {
|
|
872
|
+
this.requireFileUploadScope();
|
|
873
|
+
const uploadUrl = slackFileUploadUrl(input.uploadUrl.toString());
|
|
874
|
+
return this.withAudit("file.upload", async (authority) => {
|
|
875
|
+
await this.requireActiveNonSharedMemberChannel(
|
|
876
|
+
authority,
|
|
877
|
+
input.channelId,
|
|
878
|
+
input.privateRecipientSlackUserId,
|
|
879
|
+
);
|
|
880
|
+
// Reauthorize this exact destination and live attempt immediately before
|
|
881
|
+
// I/O, but do NOT forward the bot token to Slack's temporary upload URL.
|
|
882
|
+
await this.headersForDestination("file.upload", uploadUrl.toString());
|
|
883
|
+
let response: Response;
|
|
884
|
+
try {
|
|
885
|
+
response = await this.fetchImpl(uploadUrl, {
|
|
886
|
+
method: "POST",
|
|
887
|
+
headers: { "content-type": "application/octet-stream" },
|
|
888
|
+
body: Buffer.from(input.bytes),
|
|
889
|
+
redirect: "error",
|
|
890
|
+
signal: AbortSignal.timeout(30_000),
|
|
891
|
+
});
|
|
892
|
+
} catch {
|
|
893
|
+
throw new SlackBotProviderError("upload_transport_error");
|
|
894
|
+
}
|
|
895
|
+
await response.body?.cancel().catch(() => undefined);
|
|
896
|
+
if (!response.ok) {
|
|
897
|
+
throw new SlackBotProviderError(
|
|
898
|
+
`upload_http_${response.status}`,
|
|
899
|
+
slackRetryAfterMs(response),
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
return { uploaded: true };
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async completeFileUpload(input: {
|
|
907
|
+
channelId: string;
|
|
908
|
+
privateRecipientSlackUserId?: string;
|
|
909
|
+
threadTimestamp: string;
|
|
910
|
+
fileId: string;
|
|
911
|
+
title: string;
|
|
912
|
+
}) {
|
|
913
|
+
this.requireFileUploadScope();
|
|
914
|
+
return this.withAudit("file.upload", async (authority) => {
|
|
915
|
+
await this.requireActiveNonSharedMemberChannel(
|
|
916
|
+
authority,
|
|
917
|
+
input.channelId,
|
|
918
|
+
input.privateRecipientSlackUserId,
|
|
919
|
+
);
|
|
920
|
+
const payload = await this.call(authority, "files.completeUploadExternal", {
|
|
921
|
+
files: JSON.stringify([{ id: input.fileId, title: input.title }]),
|
|
922
|
+
channel_id: input.channelId,
|
|
923
|
+
thread_ts: input.threadTimestamp,
|
|
924
|
+
});
|
|
925
|
+
if (
|
|
926
|
+
!Array.isArray(payload.files) ||
|
|
927
|
+
!payload.files.some((file) => slackRecord(file)?.id === input.fileId)
|
|
928
|
+
) {
|
|
929
|
+
throw new SlackBotProviderError("invalid_upload_completion_response");
|
|
930
|
+
}
|
|
931
|
+
return { fileId: input.fileId };
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async reconcileFileUpload(input: {
|
|
936
|
+
channelId: string;
|
|
937
|
+
privateRecipientSlackUserId?: string;
|
|
938
|
+
threadTimestamp: string;
|
|
939
|
+
fileId: string;
|
|
940
|
+
}) {
|
|
941
|
+
this.requireFileUploadScope();
|
|
942
|
+
return this.withAudit("file.upload", async (authority) => {
|
|
943
|
+
await this.requireActiveNonSharedMemberChannel(
|
|
944
|
+
authority,
|
|
945
|
+
input.channelId,
|
|
946
|
+
input.privateRecipientSlackUserId,
|
|
947
|
+
);
|
|
948
|
+
let payload: SlackPayload;
|
|
949
|
+
try {
|
|
950
|
+
payload = await this.call(authority, "files.info", { file: input.fileId });
|
|
951
|
+
} catch (error) {
|
|
952
|
+
if (error instanceof SlackBotProviderError && error.code === "file_not_found")
|
|
953
|
+
return { shared: false };
|
|
954
|
+
throw error;
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
shared: slackFileSharedToThread(payload.file, {
|
|
958
|
+
...input,
|
|
959
|
+
botUserId: this.metadata.botUserId,
|
|
960
|
+
}),
|
|
961
|
+
};
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
private requireFileUploadScope() {
|
|
966
|
+
if (!hasOpenGeniSlackFileUploadScope(this.connection.grantedScopes)) {
|
|
967
|
+
throw new SlackBotProviderError("slack_bot_file_upload_scope_missing");
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Replace one Slack user's private App Home view. `views.publish` is a
|
|
973
|
+
* naturally convergent replace operation: Slack event retries may safely
|
|
974
|
+
* repeat the exact bounded view without creating duplicate provider state.
|
|
975
|
+
*/
|
|
976
|
+
async publishHomeView(input: { userId: string; blocks: SlackHomeBlock[]; hash?: string | null }) {
|
|
977
|
+
const userId = requiredSlackString(input.userId, "user_id");
|
|
978
|
+
const blocks = validateSlackHomeBlocks(input.blocks);
|
|
979
|
+
const hash = input.hash ? requiredSlackString(input.hash, "hash") : null;
|
|
980
|
+
return await this.withAudit("home.publish", async (headers) => {
|
|
981
|
+
const payload = await this.call(headers, "views.publish", {
|
|
982
|
+
user_id: userId,
|
|
983
|
+
view: JSON.stringify({ type: "home", blocks }),
|
|
984
|
+
...(hash ? { hash } : {}),
|
|
985
|
+
});
|
|
986
|
+
return {
|
|
987
|
+
viewId: requiredSlackString(slackRecord(payload.view)?.id, "view.id"),
|
|
988
|
+
};
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
456
992
|
async slackTaskPolicyFacts(channelId: string, userId: string) {
|
|
457
993
|
return await this.withAudit("channel_history.read", async (headers) => {
|
|
458
994
|
const conversation = await this.requireMemberChannel(headers, channelId);
|
|
@@ -485,7 +1021,13 @@ export class OpenGeniSlackBotClient {
|
|
|
485
1021
|
await input.authorizeRead?.();
|
|
486
1022
|
const payload = await this.call(headers, "conversations.history", {
|
|
487
1023
|
channel: input.channelId,
|
|
488
|
-
limit: String(
|
|
1024
|
+
limit: String(
|
|
1025
|
+
boundedInt(
|
|
1026
|
+
input.limit,
|
|
1027
|
+
this.historyPageLimit(MAX_HISTORY_PAGE),
|
|
1028
|
+
this.historyPageLimit(50),
|
|
1029
|
+
),
|
|
1030
|
+
),
|
|
489
1031
|
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
490
1032
|
...(input.latest ? { latest: input.latest } : {}),
|
|
491
1033
|
...(input.latest && input.inclusive ? { inclusive: "true" } : {}),
|
|
@@ -514,7 +1056,13 @@ export class OpenGeniSlackBotClient {
|
|
|
514
1056
|
const payload = await this.call(headers, "conversations.replies", {
|
|
515
1057
|
channel: input.channelId,
|
|
516
1058
|
ts: input.threadTimestamp,
|
|
517
|
-
limit: String(
|
|
1059
|
+
limit: String(
|
|
1060
|
+
boundedInt(
|
|
1061
|
+
input.limit,
|
|
1062
|
+
this.historyPageLimit(MAX_THREAD_PAGE),
|
|
1063
|
+
this.historyPageLimit(50),
|
|
1064
|
+
),
|
|
1065
|
+
),
|
|
518
1066
|
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
519
1067
|
...(input.oldest ? { oldest: input.oldest } : {}),
|
|
520
1068
|
...(input.latest ? { latest: input.latest } : {}),
|
|
@@ -534,7 +1082,6 @@ export class OpenGeniSlackBotClient {
|
|
|
534
1082
|
messageTimestamp: string;
|
|
535
1083
|
checkpoint: unknown | null;
|
|
536
1084
|
checkpointBinding: SlackReactionContextCheckpointBinding;
|
|
537
|
-
saveCheckpoint: (checkpoint: SlackReactionContextCheckpoint) => Promise<void>;
|
|
538
1085
|
}) {
|
|
539
1086
|
return await this.withAudit("thread_replies.read", async (headers) => {
|
|
540
1087
|
const checkpointKey = environmentsEncryptionKeyBytes(this.settings);
|
|
@@ -547,84 +1094,61 @@ export class OpenGeniSlackBotClient {
|
|
|
547
1094
|
input.channelId,
|
|
548
1095
|
input.messageTimestamp,
|
|
549
1096
|
);
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
1097
|
+
// Validate historical inbox checkpoints, but never use retained context as
|
|
1098
|
+
// current provider truth or continue the old full-thread scan.
|
|
1099
|
+
if (input.checkpoint) {
|
|
1100
|
+
parseSlackReactionContextCheckpoint(
|
|
1101
|
+
input.checkpoint,
|
|
1102
|
+
input.checkpointBinding,
|
|
1103
|
+
checkpointKey,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
557
1106
|
const info = await this.requireMemberChannel(headers, input.channelId);
|
|
558
1107
|
if (info.isShared || info.isExternallyShared || info.isOrgShared) {
|
|
559
1108
|
throw new SlackBotProviderError("slack_connect_unsupported");
|
|
560
1109
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
1110
|
+
if (!hasOpenGeniSlackReactionScope(this.connection.grantedScopes)) {
|
|
1111
|
+
throw new SlackBotProviderError("slack_bot_reaction_scope_missing");
|
|
1112
|
+
}
|
|
1113
|
+
// This method is used only for a validated reaction summon. reactions.get
|
|
1114
|
+
// supplies that exact reacted message even when it is far into a thread;
|
|
1115
|
+
// it is not an alternate transport for arbitrary conversation history.
|
|
1116
|
+
const reacted = await this.call(headers, "reactions.get", {
|
|
1117
|
+
channel: input.channelId,
|
|
1118
|
+
timestamp: input.messageTimestamp,
|
|
1119
|
+
full: "true",
|
|
1120
|
+
});
|
|
1121
|
+
if (reacted.type !== "message" || reacted.channel !== input.channelId) {
|
|
1122
|
+
throw new SlackBotProviderError("message_not_found");
|
|
1123
|
+
}
|
|
1124
|
+
const reactedMessage = projectMessage(reacted.message);
|
|
1125
|
+
if (reactedMessage.timestamp !== input.messageTimestamp) {
|
|
1126
|
+
throw new SlackBotProviderError("message_not_found");
|
|
1127
|
+
}
|
|
1128
|
+
const threadTimestamp = reactedMessage.threadTimestamp || reactedMessage.timestamp;
|
|
1129
|
+
let messages = [reactedMessage];
|
|
1130
|
+
let nextCursor: string | null = null;
|
|
1131
|
+
let contextUnavailable = false;
|
|
1132
|
+
try {
|
|
574
1133
|
const payload = await this.call(headers, "conversations.replies", {
|
|
575
1134
|
channel: input.channelId,
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1135
|
+
ts: threadTimestamp,
|
|
1136
|
+
latest: reactedMessage.timestamp,
|
|
1137
|
+
inclusive: "true",
|
|
579
1138
|
limit: String(MAX_REACTION_CONTEXT_MESSAGES),
|
|
580
|
-
...(cursor ? { cursor } : {}),
|
|
581
1139
|
});
|
|
582
1140
|
const pageMessages = slackArray(payload.messages)
|
|
583
1141
|
.map(projectMessage)
|
|
584
1142
|
.filter((message) => message.timestamp.length > 0);
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
messages.push(message);
|
|
591
|
-
}
|
|
592
|
-
reactedMessage =
|
|
593
|
-
reactedMessage ??
|
|
594
|
-
pageMessages.find((message) => message.timestamp === input.messageTimestamp) ??
|
|
595
|
-
null;
|
|
1143
|
+
// Fresh exact message content wins over any duplicate context entry.
|
|
1144
|
+
messages = [
|
|
1145
|
+
...pageMessages.filter((message) => message.timestamp !== reactedMessage.timestamp),
|
|
1146
|
+
reactedMessage,
|
|
1147
|
+
];
|
|
596
1148
|
nextCursor = responseCursor(payload);
|
|
597
|
-
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
}
|
|
601
|
-
seenCursors.add(nextCursor);
|
|
602
|
-
const pageCount = page + 1;
|
|
603
|
-
if (pageCount >= MAX_REACTION_CONTEXT_PAGES) {
|
|
604
|
-
throw new SlackBotProviderError("reaction_pagination_exhausted");
|
|
605
|
-
}
|
|
606
|
-
const retainedMessages = selectSlackReactionCheckpointMessages(messages);
|
|
607
|
-
messages.splice(0, messages.length, ...retainedMessages);
|
|
608
|
-
await input.saveCheckpoint(
|
|
609
|
-
createSlackReactionContextCheckpoint(
|
|
610
|
-
input.checkpointBinding,
|
|
611
|
-
{
|
|
612
|
-
createdAtMs: checkpointCreatedAtMs,
|
|
613
|
-
pageCount,
|
|
614
|
-
nextCursor,
|
|
615
|
-
seenCursors: [...seenCursors],
|
|
616
|
-
seenMessageTimestamps: [...seenMessageTimestamps],
|
|
617
|
-
threadTimestamp,
|
|
618
|
-
messages: retainedMessages.map(slackReactionCheckpointMessage),
|
|
619
|
-
},
|
|
620
|
-
checkpointKey,
|
|
621
|
-
),
|
|
622
|
-
);
|
|
623
|
-
cursor = nextCursor;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
if (!reactedMessage || !threadTimestamp) {
|
|
627
|
-
throw new SlackBotProviderError("message_not_found");
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
if (!slackHistoryRateLimited(error)) throw error;
|
|
1151
|
+
contextUnavailable = true;
|
|
628
1152
|
}
|
|
629
1153
|
const boundedMessages = selectSlackReactionContextMessages(
|
|
630
1154
|
messages,
|
|
@@ -635,7 +1159,9 @@ export class OpenGeniSlackBotClient {
|
|
|
635
1159
|
threadTimestamp,
|
|
636
1160
|
reactedMessage,
|
|
637
1161
|
messages: boundedMessages,
|
|
638
|
-
truncated:
|
|
1162
|
+
truncated:
|
|
1163
|
+
contextUnavailable || nextCursor !== null || messages.length > boundedMessages.length,
|
|
1164
|
+
...(contextUnavailable ? { contextUnavailable: true } : {}),
|
|
639
1165
|
};
|
|
640
1166
|
});
|
|
641
1167
|
}
|
|
@@ -649,11 +1175,18 @@ export class OpenGeniSlackBotClient {
|
|
|
649
1175
|
async prepareReactionImageDownloads(input: {
|
|
650
1176
|
channelId: string;
|
|
651
1177
|
files: readonly { id: string; name: string; title: string }[];
|
|
1178
|
+
authorizeSharedRead?: () => Promise<void>;
|
|
652
1179
|
}): Promise<PreparedSlackReactionImage[]> {
|
|
653
1180
|
const result = await this.withAudit("file.content.read", async (headers) => {
|
|
654
|
-
|
|
1181
|
+
if (input.authorizeSharedRead) {
|
|
1182
|
+
const channel = await this.requireMemberChannel(headers, input.channelId);
|
|
1183
|
+
await authorizeSlackSharedImageRead(channel, input.authorizeSharedRead);
|
|
1184
|
+
} else {
|
|
1185
|
+
await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
|
|
1186
|
+
}
|
|
655
1187
|
const prepared: PreparedSlackReactionImage[] = [];
|
|
656
1188
|
for (const candidate of input.files) {
|
|
1189
|
+
await input.authorizeSharedRead?.();
|
|
657
1190
|
const payload = await this.call(headers, "files.info", { file: candidate.id });
|
|
658
1191
|
const fileRecord = slackRecord(payload.file);
|
|
659
1192
|
const file = projectFile(fileRecord);
|
|
@@ -688,6 +1221,7 @@ export class OpenGeniSlackBotClient {
|
|
|
688
1221
|
/** Download and fully validate one previously authorized Slack image. */
|
|
689
1222
|
async downloadReactionImage(
|
|
690
1223
|
input: PreparedSlackReactionImage,
|
|
1224
|
+
authorizeSharedRead?: () => Promise<void>,
|
|
691
1225
|
): Promise<DownloadedSlackReactionImage> {
|
|
692
1226
|
return await this.withAudit("file.content.read", async () => {
|
|
693
1227
|
if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(input.declaredMimeType)) {
|
|
@@ -700,7 +1234,11 @@ export class OpenGeniSlackBotClient {
|
|
|
700
1234
|
) {
|
|
701
1235
|
throw new SlackBotProviderError("invalid_file_size");
|
|
702
1236
|
}
|
|
703
|
-
const response = await this.fetchPrivateFile(
|
|
1237
|
+
const response = await this.fetchPrivateFile(
|
|
1238
|
+
input.downloadUrl,
|
|
1239
|
+
"file.content.read",
|
|
1240
|
+
authorizeSharedRead,
|
|
1241
|
+
);
|
|
704
1242
|
const responseContentType = normalizedContentType(response.headers.get("content-type"));
|
|
705
1243
|
if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(responseContentType)) {
|
|
706
1244
|
await response.body?.cancel().catch(() => undefined);
|
|
@@ -748,6 +1286,79 @@ export class OpenGeniSlackBotClient {
|
|
|
748
1286
|
});
|
|
749
1287
|
}
|
|
750
1288
|
|
|
1289
|
+
/**
|
|
1290
|
+
* Public search in a trusted Slack interaction through Real-time Search
|
|
1291
|
+
* (`assistant.search.context`) under the bot identity. Unlisted deployments
|
|
1292
|
+
* cannot use this API; approved deployments also need Slack's action token.
|
|
1293
|
+
*
|
|
1294
|
+
* The bot never carries private-search authority: `channel_types` is pinned
|
|
1295
|
+
* to `public_channel` server-side regardless of caller input, so private
|
|
1296
|
+
* channels, DMs, and MPIMs remain reachable only through a member's personal
|
|
1297
|
+
* hosted-MCP grant. An install predating the search scopes fails closed with
|
|
1298
|
+
* a reinstall hint instead of leaking Slack's `missing_scope` error.
|
|
1299
|
+
*/
|
|
1300
|
+
async searchContext(input: {
|
|
1301
|
+
query: string;
|
|
1302
|
+
/** Server-owned Slack interaction token, never an agent tool argument. */
|
|
1303
|
+
actionToken?: string;
|
|
1304
|
+
contentTypes?: readonly ("messages" | "files" | "channels")[];
|
|
1305
|
+
includeBots?: boolean;
|
|
1306
|
+
/** UNIX seconds bounds on message timestamps. */
|
|
1307
|
+
before?: number;
|
|
1308
|
+
after?: number;
|
|
1309
|
+
sort?: "score" | "timestamp";
|
|
1310
|
+
sortDir?: "asc" | "desc";
|
|
1311
|
+
cursor?: string;
|
|
1312
|
+
limit?: number;
|
|
1313
|
+
}) {
|
|
1314
|
+
const query = typeof input.query === "string" ? input.query.trim() : "";
|
|
1315
|
+
if (!query || query.length > MAX_SEARCH_QUERY_CHARS) {
|
|
1316
|
+
throw new SlackBotProviderError("invalid_query");
|
|
1317
|
+
}
|
|
1318
|
+
const contentTypes = input.contentTypes?.length
|
|
1319
|
+
? [...new Set(input.contentTypes)]
|
|
1320
|
+
: ["messages"];
|
|
1321
|
+
return await this.withAudit("search.context", async (headers) => {
|
|
1322
|
+
if (this.settings.slackAccessMode !== "full") {
|
|
1323
|
+
throw new SlackBotProviderError("slack_bot_search_unavailable");
|
|
1324
|
+
}
|
|
1325
|
+
if (!input.actionToken) {
|
|
1326
|
+
throw new SlackBotProviderError("slack_bot_search_action_required");
|
|
1327
|
+
}
|
|
1328
|
+
// Inside the audit boundary so a fail-closed denial on a legacy install
|
|
1329
|
+
// leaves the same `failed` evidence as a provider rejection.
|
|
1330
|
+
if (!hasOpenGeniSlackBotSearchScopes(this.connection.grantedScopes)) {
|
|
1331
|
+
throw new SlackBotProviderError("slack_bot_search_scopes_missing");
|
|
1332
|
+
}
|
|
1333
|
+
const payload = await this.call(headers, "assistant.search.context", {
|
|
1334
|
+
query,
|
|
1335
|
+
action_token: input.actionToken,
|
|
1336
|
+
channel_types: "public_channel",
|
|
1337
|
+
content_types: contentTypes.join(","),
|
|
1338
|
+
...(input.includeBots ? { include_bots: "true" } : {}),
|
|
1339
|
+
...(input.before !== undefined ? { before: String(Math.trunc(input.before)) } : {}),
|
|
1340
|
+
...(input.after !== undefined ? { after: String(Math.trunc(input.after)) } : {}),
|
|
1341
|
+
...(input.sort ? { sort: input.sort } : {}),
|
|
1342
|
+
...(input.sortDir ? { sort_dir: input.sortDir } : {}),
|
|
1343
|
+
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
1344
|
+
limit: String(boundedInt(input.limit, MAX_SEARCH_PAGE, MAX_SEARCH_PAGE)),
|
|
1345
|
+
});
|
|
1346
|
+
const results = slackRecord(payload.results);
|
|
1347
|
+
return {
|
|
1348
|
+
messages: slackArray(results?.messages)
|
|
1349
|
+
.map(projectSearchMessage)
|
|
1350
|
+
.filter((message): message is NonNullable<typeof message> => message !== null),
|
|
1351
|
+
files: slackArray(results?.files)
|
|
1352
|
+
.map(projectSearchFile)
|
|
1353
|
+
.filter((file): file is NonNullable<typeof file> => file !== null),
|
|
1354
|
+
channels: slackArray(results?.channels)
|
|
1355
|
+
.map(projectSearchChannel)
|
|
1356
|
+
.filter((channel): channel is NonNullable<typeof channel> => channel !== null),
|
|
1357
|
+
nextCursor: responseCursor(payload),
|
|
1358
|
+
};
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
|
|
751
1362
|
async listFiles(input: { channelId: string; limit?: number; cursor?: string }) {
|
|
752
1363
|
return await this.withAudit("files.list", async (headers) => {
|
|
753
1364
|
const requestedPage = this.fileListPage(input);
|
|
@@ -797,6 +1408,35 @@ export class OpenGeniSlackBotClient {
|
|
|
797
1408
|
"file.content.read",
|
|
798
1409
|
input,
|
|
799
1410
|
);
|
|
1411
|
+
if (SLACK_REACTION_IMAGE_MIME_TYPES.has(normalizedContentType(file.mimetype))) {
|
|
1412
|
+
if (input.offset !== undefined && input.offset !== 0) {
|
|
1413
|
+
throw new SlackBotProviderError("invalid_file_offset");
|
|
1414
|
+
}
|
|
1415
|
+
// A context file is read only on explicit request. Keep the same
|
|
1416
|
+
// non-shared channel boundary and byte validation as invocation images.
|
|
1417
|
+
await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
|
|
1418
|
+
if (!fileIsSharedToChannel(fileRecord, input.channelId)) {
|
|
1419
|
+
throw new SlackBotProviderError("file_not_shared_to_channel");
|
|
1420
|
+
}
|
|
1421
|
+
if (file.size !== null && (file.size < 1 || file.size > SLACK_REACTION_IMAGE_MAX_BYTES)) {
|
|
1422
|
+
throw new SlackBotProviderError("invalid_file_size");
|
|
1423
|
+
}
|
|
1424
|
+
if (file.size !== null && file.size > SLACK_MCP_IMAGE_MAX_BYTES) {
|
|
1425
|
+
throw new SlackBotProviderError("image_result_too_large");
|
|
1426
|
+
}
|
|
1427
|
+
const image = await this.downloadReactionImage({
|
|
1428
|
+
fileId: file.id,
|
|
1429
|
+
filename: file.name || file.title || file.id,
|
|
1430
|
+
declaredMimeType: normalizedContentType(file.mimetype),
|
|
1431
|
+
declaredSizeBytes: file.size,
|
|
1432
|
+
downloadUrl: privateSlackFileUrl(fileRecord),
|
|
1433
|
+
});
|
|
1434
|
+
if (image.bytes.byteLength > SLACK_MCP_IMAGE_MAX_BYTES) {
|
|
1435
|
+
throw new SlackBotProviderError("image_result_too_large");
|
|
1436
|
+
}
|
|
1437
|
+
await validateSlackMcpImage(image.bytes);
|
|
1438
|
+
return { channel: info, file, image };
|
|
1439
|
+
}
|
|
800
1440
|
const embeddedTranscript = embeddedHuddleTranscription(fileRecord, parentFileRecord);
|
|
801
1441
|
const { contentType, content } =
|
|
802
1442
|
embeddedTranscript ?? (await this.readPrivateFileText(fileRecord, "file.content.read"));
|
|
@@ -857,6 +1497,7 @@ export class OpenGeniSlackBotClient {
|
|
|
857
1497
|
const targetKind = input.userId ? "user" : "channel";
|
|
858
1498
|
const targetId = input.userId ?? input.channelId!;
|
|
859
1499
|
const blocks = validateSlackMessageBlocks(input.blocks);
|
|
1500
|
+
const wireBlocks = slackMessageWireBlocks(blocks);
|
|
860
1501
|
const requestDigest = this.postRequestDigest({
|
|
861
1502
|
operationId: input.operationId,
|
|
862
1503
|
targetKind,
|
|
@@ -877,10 +1518,10 @@ export class OpenGeniSlackBotClient {
|
|
|
877
1518
|
claimLeaseMs: SLACK_POST_CLAIM_LEASE_MS,
|
|
878
1519
|
});
|
|
879
1520
|
if (claim.kind === "connection_not_found") {
|
|
880
|
-
throw new Error("
|
|
1521
|
+
throw new Error("Opengeni Slack bot connection no longer exists");
|
|
881
1522
|
}
|
|
882
1523
|
if (claim.kind === "conflict") {
|
|
883
|
-
throw new
|
|
1524
|
+
throw new SlackBotOperationConflictError("post");
|
|
884
1525
|
}
|
|
885
1526
|
if (claim.kind === "in_progress") {
|
|
886
1527
|
throw new Error("Slack post operation is already in progress; retry the same operationId");
|
|
@@ -938,7 +1579,7 @@ export class OpenGeniSlackBotClient {
|
|
|
938
1579
|
const posted = await this.call(headers, "chat.postMessage", {
|
|
939
1580
|
channel: channelId,
|
|
940
1581
|
text: input.text,
|
|
941
|
-
...(
|
|
1582
|
+
...(wireBlocks ? { blocks: JSON.stringify(wireBlocks) } : {}),
|
|
942
1583
|
client_msg_id: input.operationId,
|
|
943
1584
|
unfurl_links: "false",
|
|
944
1585
|
unfurl_media: "false",
|
|
@@ -1003,6 +1644,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1003
1644
|
let providerCallStarted = false;
|
|
1004
1645
|
const blocks = validateSlackMessageBlocks(input.blocks);
|
|
1005
1646
|
try {
|
|
1647
|
+
const wireBlocks = slackMessageWireBlocks(blocks);
|
|
1006
1648
|
const headers = await this.headersFor(operation);
|
|
1007
1649
|
await this.requireMemberChannel(headers, input.channelId);
|
|
1008
1650
|
const requestDigest = this.updateRequestDigest({ ...input, ...(blocks ? { blocks } : {}) });
|
|
@@ -1018,10 +1660,10 @@ export class OpenGeniSlackBotClient {
|
|
|
1018
1660
|
claimLeaseMs: SLACK_UPDATE_CLAIM_LEASE_MS,
|
|
1019
1661
|
});
|
|
1020
1662
|
if (claim.kind === "connection_not_found") {
|
|
1021
|
-
throw new Error("
|
|
1663
|
+
throw new Error("Opengeni Slack bot connection no longer exists");
|
|
1022
1664
|
}
|
|
1023
1665
|
if (claim.kind === "conflict") {
|
|
1024
|
-
throw new
|
|
1666
|
+
throw new SlackBotOperationConflictError("update");
|
|
1025
1667
|
}
|
|
1026
1668
|
if (claim.kind === "in_progress") {
|
|
1027
1669
|
throw new Error(
|
|
@@ -1041,7 +1683,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1041
1683
|
channel: input.channelId,
|
|
1042
1684
|
ts: input.timestamp,
|
|
1043
1685
|
text: input.text,
|
|
1044
|
-
...(
|
|
1686
|
+
...(wireBlocks ? { blocks: JSON.stringify(wireBlocks) } : {}),
|
|
1045
1687
|
});
|
|
1046
1688
|
const slackChannelId = requiredSlackString(updated.channel, "channel");
|
|
1047
1689
|
const slackMessageTimestamp = requiredSlackString(updated.ts, "ts");
|
|
@@ -1112,10 +1754,10 @@ export class OpenGeniSlackBotClient {
|
|
|
1112
1754
|
claimLeaseMs: SLACK_DELETE_CLAIM_LEASE_MS,
|
|
1113
1755
|
});
|
|
1114
1756
|
if (claim.kind === "connection_not_found") {
|
|
1115
|
-
throw new Error("
|
|
1757
|
+
throw new Error("Opengeni Slack bot connection no longer exists");
|
|
1116
1758
|
}
|
|
1117
1759
|
if (claim.kind === "conflict") {
|
|
1118
|
-
throw new
|
|
1760
|
+
throw new SlackBotOperationConflictError("delete");
|
|
1119
1761
|
}
|
|
1120
1762
|
if (claim.kind === "in_progress") {
|
|
1121
1763
|
throw new Error(
|
|
@@ -1211,10 +1853,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1211
1853
|
}
|
|
1212
1854
|
|
|
1213
1855
|
private async slackMessageExists(channelId: string, timestamp: string): Promise<boolean> {
|
|
1214
|
-
const headers = await this.
|
|
1215
|
-
"message.delete",
|
|
1216
|
-
`${SLACK_API_BASE}chat.getPermalink`,
|
|
1217
|
-
);
|
|
1856
|
+
const headers = await this.headersFor("message.delete");
|
|
1218
1857
|
try {
|
|
1219
1858
|
await this.call(headers, "chat.getPermalink", {
|
|
1220
1859
|
channel: channelId,
|
|
@@ -1236,15 +1875,17 @@ export class OpenGeniSlackBotClient {
|
|
|
1236
1875
|
text: string;
|
|
1237
1876
|
}): Promise<{ timestamp: string }> {
|
|
1238
1877
|
const method = input.threadTimestamp ? "conversations.replies" : "conversations.history";
|
|
1239
|
-
const headers = await this.
|
|
1878
|
+
const headers = await this.headersFor("message.post");
|
|
1240
1879
|
const seenCursors = new Set<string>();
|
|
1241
1880
|
let cursor: string | null = null;
|
|
1242
1881
|
let matchedTimestamp: string | null = null;
|
|
1243
1882
|
let exhausted = false;
|
|
1244
|
-
for (let page = 0; page <
|
|
1883
|
+
for (let page = 0; page < this.postReconciliationPageLimit(); page += 1) {
|
|
1245
1884
|
const payload = await this.call(headers, method, {
|
|
1246
1885
|
channel: input.channelId,
|
|
1247
|
-
limit: String(
|
|
1886
|
+
limit: String(
|
|
1887
|
+
this.historyPageLimit(input.threadTimestamp ? MAX_THREAD_PAGE : MAX_HISTORY_PAGE),
|
|
1888
|
+
),
|
|
1248
1889
|
...(input.threadTimestamp ? { ts: input.threadTimestamp } : {}),
|
|
1249
1890
|
...(cursor ? { cursor } : {}),
|
|
1250
1891
|
});
|
|
@@ -1284,7 +1925,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1284
1925
|
return { timestamp: matchedTimestamp };
|
|
1285
1926
|
}
|
|
1286
1927
|
|
|
1287
|
-
private async requireMemberChannel(headers:
|
|
1928
|
+
private async requireMemberChannel(headers: SlackCallAuthority, channelId: string) {
|
|
1288
1929
|
const payload = await this.call(headers, "conversations.info", {
|
|
1289
1930
|
channel: channelId,
|
|
1290
1931
|
});
|
|
@@ -1299,8 +1940,9 @@ export class OpenGeniSlackBotClient {
|
|
|
1299
1940
|
}
|
|
1300
1941
|
|
|
1301
1942
|
private async requireActiveNonSharedMemberChannel(
|
|
1302
|
-
headers:
|
|
1943
|
+
headers: SlackCallAuthority,
|
|
1303
1944
|
channelId: string,
|
|
1945
|
+
privateRecipientSlackUserId?: string,
|
|
1304
1946
|
) {
|
|
1305
1947
|
const projected = await this.requireMemberChannel(headers, channelId);
|
|
1306
1948
|
if (projected.isArchived) {
|
|
@@ -1309,11 +1951,14 @@ export class OpenGeniSlackBotClient {
|
|
|
1309
1951
|
if (projected.isShared || projected.isExternallyShared || projected.isOrgShared) {
|
|
1310
1952
|
throw new SlackBotProviderError("slack_connect_unsupported");
|
|
1311
1953
|
}
|
|
1954
|
+
if (privateRecipientSlackUserId) {
|
|
1955
|
+
assertSlackPrivateTaskRecipient(projected, privateRecipientSlackUserId);
|
|
1956
|
+
}
|
|
1312
1957
|
return projected;
|
|
1313
1958
|
}
|
|
1314
1959
|
|
|
1315
1960
|
private async requireFileForChannel(
|
|
1316
|
-
headers:
|
|
1961
|
+
headers: SlackCallAuthority,
|
|
1317
1962
|
operation: "file.info" | "file.content.read",
|
|
1318
1963
|
input: { channelId: string; fileId: string; parentFileId?: string },
|
|
1319
1964
|
) {
|
|
@@ -1390,13 +2035,32 @@ export class OpenGeniSlackBotClient {
|
|
|
1390
2035
|
}
|
|
1391
2036
|
|
|
1392
2037
|
private async call(
|
|
1393
|
-
|
|
2038
|
+
authority: SlackCallAuthority,
|
|
1394
2039
|
method: string,
|
|
1395
2040
|
params: Record<string, string>,
|
|
1396
2041
|
): Promise<SlackPayload> {
|
|
2042
|
+
let providerRequestStarted = false;
|
|
1397
2043
|
try {
|
|
2044
|
+
const destination = `${SLACK_API_BASE}${method}`;
|
|
2045
|
+
// Refused or stale authority cannot consume another installation's quota.
|
|
2046
|
+
await this.headersForDestination(authority.operation, destination, "preflight");
|
|
2047
|
+
const retryAfterSeconds = await this.rateLimit(this.metadata.slackTeamId, method);
|
|
2048
|
+
if (retryAfterSeconds > 0) {
|
|
2049
|
+
throw new SlackBotProviderError("rate_limited", retryAfterSeconds * 1_000);
|
|
2050
|
+
}
|
|
2051
|
+
// Quota reservation is an await: recheck live authority after it, as the
|
|
2052
|
+
// final await before each physical provider request.
|
|
2053
|
+
const headers = await this.headersForDestination(authority.operation, destination);
|
|
2054
|
+
providerRequestStarted = true;
|
|
1398
2055
|
return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
|
|
1399
2056
|
} catch (error) {
|
|
2057
|
+
if (providerRequestStarted && slackHistoryRateLimited(error)) {
|
|
2058
|
+
await this.rateLimit(
|
|
2059
|
+
this.metadata.slackTeamId,
|
|
2060
|
+
method,
|
|
2061
|
+
Math.max(1, Math.ceil((error.retryAfterMs ?? 60_000) / 1_000)),
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
1400
2064
|
if (slackCredentialRejected(error)) {
|
|
1401
2065
|
await setConnectionStatus(this.db, this.context.workspaceId, "needs_reauth", error.code, {
|
|
1402
2066
|
id: this.connection.id,
|
|
@@ -1410,11 +2074,10 @@ export class OpenGeniSlackBotClient {
|
|
|
1410
2074
|
|
|
1411
2075
|
private async withAudit<T extends Record<string, unknown>>(
|
|
1412
2076
|
operation: SlackBotOperation,
|
|
1413
|
-
run: (
|
|
2077
|
+
run: (authority: SlackCallAuthority) => Promise<T>,
|
|
1414
2078
|
): Promise<T & { receipt: SlackBotReceipt }> {
|
|
1415
2079
|
try {
|
|
1416
|
-
const
|
|
1417
|
-
const result = await run(headers);
|
|
2080
|
+
const result = await run(await this.headersFor(operation));
|
|
1418
2081
|
await this.recordAudit(operation, "succeeded");
|
|
1419
2082
|
return { ...result, receipt: this.receipt(operation) };
|
|
1420
2083
|
} catch (error) {
|
|
@@ -1423,16 +2086,14 @@ export class OpenGeniSlackBotClient {
|
|
|
1423
2086
|
}
|
|
1424
2087
|
}
|
|
1425
2088
|
|
|
1426
|
-
private async headersFor(operation: SlackBotOperation): Promise<
|
|
1427
|
-
return
|
|
1428
|
-
operation,
|
|
1429
|
-
`${SLACK_API_BASE}${slackMethodForOperation(operation)}`,
|
|
1430
|
-
);
|
|
2089
|
+
private async headersFor(operation: SlackBotOperation): Promise<SlackCallAuthority> {
|
|
2090
|
+
return Object.freeze({ operation });
|
|
1431
2091
|
}
|
|
1432
2092
|
|
|
1433
2093
|
private async headersForDestination(
|
|
1434
2094
|
operation: SlackBotOperation,
|
|
1435
2095
|
destinationUrl: string,
|
|
2096
|
+
credentialResolutionMode: "execution" | "preflight" = "execution",
|
|
1436
2097
|
): Promise<Record<string, string>> {
|
|
1437
2098
|
const result = await this.resolveCredential({
|
|
1438
2099
|
workspaceId: this.context.workspaceId,
|
|
@@ -1442,13 +2103,49 @@ export class OpenGeniSlackBotClient {
|
|
|
1442
2103
|
connectionId: this.connection.id,
|
|
1443
2104
|
providerDomain: "slack.com",
|
|
1444
2105
|
kind: "app_install",
|
|
1445
|
-
scopes: [
|
|
2106
|
+
scopes: [
|
|
2107
|
+
...OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
|
|
2108
|
+
...(operation === "file.upload" ? [OPENGENI_SLACK_FILE_UPLOAD_REQUIRED_SCOPE] : []),
|
|
2109
|
+
],
|
|
1446
2110
|
subjectScope: "workspace",
|
|
1447
2111
|
},
|
|
1448
2112
|
destinationUrl,
|
|
2113
|
+
credentialResolutionMode,
|
|
1449
2114
|
});
|
|
1450
2115
|
if (result.status !== "ok" || result.connectionId !== this.connection.id) {
|
|
1451
|
-
throw new Error("
|
|
2116
|
+
throw new Error("Opengeni Slack bot connection needs to be reinstalled");
|
|
2117
|
+
}
|
|
2118
|
+
const current = await requireOpenGeniSlackBotConnection(
|
|
2119
|
+
this.db,
|
|
2120
|
+
this.context.workspaceId,
|
|
2121
|
+
this.connection.id,
|
|
2122
|
+
);
|
|
2123
|
+
const currentMetadata = openGeniSlackBotMetadata(current.metadata);
|
|
2124
|
+
if (operation === "file.upload" && !hasOpenGeniSlackFileUploadScope(current.grantedScopes)) {
|
|
2125
|
+
throw new SlackBotProviderError("slack_bot_file_upload_scope_missing");
|
|
2126
|
+
}
|
|
2127
|
+
if (
|
|
2128
|
+
current.accountId !== this.context.accountId ||
|
|
2129
|
+
current.version !== this.connection.version ||
|
|
2130
|
+
result.connectionVersion !== this.connection.version ||
|
|
2131
|
+
!currentMetadata ||
|
|
2132
|
+
currentMetadata.slackTeamId !== this.metadata.slackTeamId ||
|
|
2133
|
+
currentMetadata.botId !== this.metadata.botId ||
|
|
2134
|
+
currentMetadata.botUserId !== this.metadata.botUserId
|
|
2135
|
+
) {
|
|
2136
|
+
throw new Error("Opengeni Slack bot connection authority changed");
|
|
2137
|
+
}
|
|
2138
|
+
// The adapter callback is a fallible preflight. The canonical credential
|
|
2139
|
+
// callback must remain the final await before the physical fetch.
|
|
2140
|
+
if (this.authorizeProviderRequest && (await this.authorizeProviderRequest()) === false) {
|
|
2141
|
+
throw new Error("Opengeni Slack bot provider request is no longer authorized");
|
|
2142
|
+
}
|
|
2143
|
+
if (
|
|
2144
|
+
credentialResolutionMode === "execution" &&
|
|
2145
|
+
result.authorizeProviderRequest &&
|
|
2146
|
+
!(await result.authorizeProviderRequest())
|
|
2147
|
+
) {
|
|
2148
|
+
throw new Error("Opengeni Slack bot provider request is no longer authorized");
|
|
1452
2149
|
}
|
|
1453
2150
|
return result.headers;
|
|
1454
2151
|
}
|
|
@@ -1456,8 +2153,9 @@ export class OpenGeniSlackBotClient {
|
|
|
1456
2153
|
private async fetchPrivateFile(
|
|
1457
2154
|
url: URL,
|
|
1458
2155
|
operation: "file.info" | "file.content.read",
|
|
2156
|
+
authorizeBeforeFetch?: () => Promise<void>,
|
|
1459
2157
|
): Promise<Response> {
|
|
1460
|
-
const response = await this.fetchPrivateFileOnce(url, operation);
|
|
2158
|
+
const response = await this.fetchPrivateFileOnce(url, operation, authorizeBeforeFetch);
|
|
1461
2159
|
if (response.status < 300 || response.status >= 400) {
|
|
1462
2160
|
return response;
|
|
1463
2161
|
}
|
|
@@ -1478,18 +2176,20 @@ export class OpenGeniSlackBotClient {
|
|
|
1478
2176
|
} catch {
|
|
1479
2177
|
throw new SlackBotProviderError("invalid_file_redirect");
|
|
1480
2178
|
}
|
|
1481
|
-
return await this.fetchPrivateFileOnce(redirected, operation);
|
|
2179
|
+
return await this.fetchPrivateFileOnce(redirected, operation, authorizeBeforeFetch);
|
|
1482
2180
|
}
|
|
1483
2181
|
|
|
1484
2182
|
private async fetchPrivateFileOnce(
|
|
1485
2183
|
url: URL,
|
|
1486
2184
|
operation: "file.info" | "file.content.read",
|
|
2185
|
+
authorizeBeforeFetch?: () => Promise<void>,
|
|
1487
2186
|
): Promise<Response> {
|
|
1488
2187
|
try {
|
|
1489
2188
|
assertPrivateSlackFileUrl(url);
|
|
1490
2189
|
} catch {
|
|
1491
2190
|
throw new SlackBotProviderError("invalid_file_url");
|
|
1492
2191
|
}
|
|
2192
|
+
await authorizeBeforeFetch?.();
|
|
1493
2193
|
const headers = await this.headersForDestination(operation, url.toString());
|
|
1494
2194
|
let response: Response;
|
|
1495
2195
|
try {
|
|
@@ -1497,7 +2197,8 @@ export class OpenGeniSlackBotClient {
|
|
|
1497
2197
|
method: "GET",
|
|
1498
2198
|
headers: {
|
|
1499
2199
|
...headers,
|
|
1500
|
-
accept:
|
|
2200
|
+
accept:
|
|
2201
|
+
"image/png, image/jpeg, image/webp, text/*, application/json, application/xml, application/xhtml+xml",
|
|
1501
2202
|
},
|
|
1502
2203
|
redirect: "manual",
|
|
1503
2204
|
signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
|
|
@@ -1700,8 +2401,22 @@ export class OpenGeniSlackBotClient {
|
|
|
1700
2401
|
}
|
|
1701
2402
|
}
|
|
1702
2403
|
|
|
2404
|
+
/** Full decode guards the model image block against header-only or corrupt files. */
|
|
2405
|
+
export async function validateSlackMcpImage(bytes: Uint8Array): Promise<void> {
|
|
2406
|
+
try {
|
|
2407
|
+
await sharp(bytes, { limitInputPixels: 16_000_000, failOn: "error" }).stats();
|
|
2408
|
+
} catch {
|
|
2409
|
+
throw new SlackBotProviderError("invalid_file_content");
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
|
|
1703
2413
|
export function createOpenGeniSlackBotClient(
|
|
1704
|
-
deps: {
|
|
2414
|
+
deps: {
|
|
2415
|
+
db: Database;
|
|
2416
|
+
settings: Settings;
|
|
2417
|
+
slackFetch?: typeof fetch;
|
|
2418
|
+
authorizeProviderRequest?: SlackProviderAuthorization;
|
|
2419
|
+
},
|
|
1705
2420
|
resolved: Awaited<ReturnType<typeof resolveSlackBotConnectionForTool>>,
|
|
1706
2421
|
): OpenGeniSlackBotClient {
|
|
1707
2422
|
return new OpenGeniSlackBotClient(
|
|
@@ -1711,11 +2426,17 @@ export function createOpenGeniSlackBotClient(
|
|
|
1711
2426
|
resolved.metadata,
|
|
1712
2427
|
resolved.context,
|
|
1713
2428
|
deps.slackFetch,
|
|
2429
|
+
deps.authorizeProviderRequest,
|
|
1714
2430
|
);
|
|
1715
2431
|
}
|
|
1716
2432
|
|
|
1717
2433
|
export async function createOpenGeniSlackBotInteractionClient(
|
|
1718
|
-
deps: {
|
|
2434
|
+
deps: {
|
|
2435
|
+
db: Database;
|
|
2436
|
+
settings: Settings;
|
|
2437
|
+
slackFetch?: typeof fetch;
|
|
2438
|
+
authorizeProviderRequest?: SlackProviderAuthorization;
|
|
2439
|
+
},
|
|
1719
2440
|
input: {
|
|
1720
2441
|
accountId: string;
|
|
1721
2442
|
workspaceId: string;
|
|
@@ -1730,10 +2451,10 @@ export async function createOpenGeniSlackBotInteractionClient(
|
|
|
1730
2451
|
input.connectionId,
|
|
1731
2452
|
);
|
|
1732
2453
|
if (connection.accountId !== input.accountId) {
|
|
1733
|
-
throw new Error("
|
|
2454
|
+
throw new Error("Opengeni Slack bot connection tenant mismatch");
|
|
1734
2455
|
}
|
|
1735
2456
|
const metadata = openGeniSlackBotMetadata(connection.metadata);
|
|
1736
|
-
if (!metadata) throw new Error("
|
|
2457
|
+
if (!metadata) throw new Error("Opengeni Slack bot connection metadata is invalid");
|
|
1737
2458
|
return new OpenGeniSlackBotClient(
|
|
1738
2459
|
deps.db,
|
|
1739
2460
|
deps.settings,
|
|
@@ -1747,6 +2468,7 @@ export async function createOpenGeniSlackBotInteractionClient(
|
|
|
1747
2468
|
scheduledTaskId: null,
|
|
1748
2469
|
},
|
|
1749
2470
|
deps.slackFetch,
|
|
2471
|
+
deps.authorizeProviderRequest,
|
|
1750
2472
|
);
|
|
1751
2473
|
}
|
|
1752
2474
|
|
|
@@ -1770,7 +2492,7 @@ async function slackApiFetchWithHeaders(
|
|
|
1770
2492
|
credentialHeaders: Record<string, string>,
|
|
1771
2493
|
params: Record<string, string>,
|
|
1772
2494
|
): Promise<{ response: Response; payload: SlackPayload }> {
|
|
1773
|
-
if (!/^[a-z]
|
|
2495
|
+
if (!/^[a-z]+(?:\.[a-z]+){1,2}$/i.test(method)) {
|
|
1774
2496
|
throw new Error("invalid Slack API method");
|
|
1775
2497
|
}
|
|
1776
2498
|
const url = new URL(method, SLACK_API_BASE);
|
|
@@ -1785,6 +2507,7 @@ async function slackApiFetchWithHeaders(
|
|
|
1785
2507
|
"content-type": "application/x-www-form-urlencoded",
|
|
1786
2508
|
},
|
|
1787
2509
|
body,
|
|
2510
|
+
redirect: "error",
|
|
1788
2511
|
signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
|
|
1789
2512
|
});
|
|
1790
2513
|
} catch {
|
|
@@ -1829,7 +2552,7 @@ function assertOpenGeniSlackBotScopes(grantedScopes: string[]): void {
|
|
|
1829
2552
|
];
|
|
1830
2553
|
throw new SlackBotCredentialVerificationError(
|
|
1831
2554
|
"scope_mismatch",
|
|
1832
|
-
`Slack bot scopes do not satisfy the
|
|
2555
|
+
`Slack bot scopes do not satisfy the Opengeni manifest (${facts.join("; ")})`,
|
|
1833
2556
|
);
|
|
1834
2557
|
}
|
|
1835
2558
|
}
|
|
@@ -1862,6 +2585,7 @@ function projectChannel(value: unknown) {
|
|
|
1862
2585
|
isMember: channel.is_member === true,
|
|
1863
2586
|
isDirectMessage: channel.is_im === true,
|
|
1864
2587
|
isMpim: channel.is_mpim === true,
|
|
2588
|
+
userId: nullableBoundedSlackString(channel.user, 128),
|
|
1865
2589
|
isArchived: channel.is_archived === true,
|
|
1866
2590
|
isShared: channel.is_shared === true,
|
|
1867
2591
|
isExternallyShared: channel.is_ext_shared === true,
|
|
@@ -1879,6 +2603,16 @@ function projectChannel(value: unknown) {
|
|
|
1879
2603
|
};
|
|
1880
2604
|
}
|
|
1881
2605
|
|
|
2606
|
+
/** A private route needs live provider proof of the exact linked requester's bot IM. */
|
|
2607
|
+
export function assertSlackPrivateTaskRecipient(
|
|
2608
|
+
channel: { isDirectMessage: boolean; isMpim: boolean; userId: string | null },
|
|
2609
|
+
slackUserId: string,
|
|
2610
|
+
): void {
|
|
2611
|
+
if (!channel.isDirectMessage || channel.isMpim || channel.userId !== slackUserId) {
|
|
2612
|
+
throw new SlackBotProviderError("private_task_recipient_changed");
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
|
|
1882
2616
|
function projectSlackTaskPolicyUser(value: unknown, installationTeamId: string) {
|
|
1883
2617
|
const user = slackRecord(value);
|
|
1884
2618
|
const id = slackString(user?.id);
|
|
@@ -1952,39 +2686,6 @@ function assertSlackReactionCheckpointBinding(
|
|
|
1952
2686
|
}
|
|
1953
2687
|
}
|
|
1954
2688
|
|
|
1955
|
-
function createSlackReactionContextCheckpoint(
|
|
1956
|
-
binding: SlackReactionContextCheckpointBinding,
|
|
1957
|
-
state: SlackReactionContextCheckpointUnsigned["state"],
|
|
1958
|
-
key: Uint8Array,
|
|
1959
|
-
): SlackReactionContextCheckpoint {
|
|
1960
|
-
const unsigned: SlackReactionContextCheckpointUnsigned = {
|
|
1961
|
-
version: SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION,
|
|
1962
|
-
binding: { ...binding },
|
|
1963
|
-
state: {
|
|
1964
|
-
createdAtMs: state.createdAtMs,
|
|
1965
|
-
pageCount: state.pageCount,
|
|
1966
|
-
nextCursor: state.nextCursor,
|
|
1967
|
-
seenCursors: [...state.seenCursors],
|
|
1968
|
-
seenMessageTimestamps: [...state.seenMessageTimestamps],
|
|
1969
|
-
threadTimestamp: state.threadTimestamp,
|
|
1970
|
-
messages: state.messages.map((message) => ({
|
|
1971
|
-
...message,
|
|
1972
|
-
files: message.files.map((file) => ({ ...file })),
|
|
1973
|
-
})),
|
|
1974
|
-
},
|
|
1975
|
-
};
|
|
1976
|
-
const checkpoint: SlackReactionContextCheckpoint = {
|
|
1977
|
-
...unsigned,
|
|
1978
|
-
signature: slackReactionContextCheckpointSignature(unsigned, key),
|
|
1979
|
-
};
|
|
1980
|
-
if (
|
|
1981
|
-
Buffer.byteLength(JSON.stringify(checkpoint), "utf8") > MAX_REACTION_CONTEXT_CHECKPOINT_BYTES
|
|
1982
|
-
) {
|
|
1983
|
-
throw new SlackBotProviderError("reaction_checkpoint_too_large");
|
|
1984
|
-
}
|
|
1985
|
-
return checkpoint;
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
2689
|
function parseSlackReactionContextCheckpoint(
|
|
1989
2690
|
value: unknown,
|
|
1990
2691
|
expectedBinding: SlackReactionContextCheckpointBinding,
|
|
@@ -2203,64 +2904,6 @@ function parseSlackReactionCheckpointMessage(value: unknown): SlackReactionCheck
|
|
|
2203
2904
|
};
|
|
2204
2905
|
}
|
|
2205
2906
|
|
|
2206
|
-
function slackReactionCheckpointMessage(
|
|
2207
|
-
message: ReturnType<typeof projectMessage>,
|
|
2208
|
-
): SlackReactionCheckpointMessage {
|
|
2209
|
-
const files: SlackReactionCheckpointMessage["files"] = [];
|
|
2210
|
-
let fileLabelChars = 0;
|
|
2211
|
-
for (const file of message.files) {
|
|
2212
|
-
const label = file.title || file.name || file.id;
|
|
2213
|
-
if (!label) continue;
|
|
2214
|
-
const addedChars = label.length + (files.length > 0 ? 2 : 0);
|
|
2215
|
-
if (
|
|
2216
|
-
files.length >= MAX_REACTION_CONTEXT_CHECKPOINT_FILES ||
|
|
2217
|
-
fileLabelChars + addedChars > MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS
|
|
2218
|
-
) {
|
|
2219
|
-
break;
|
|
2220
|
-
}
|
|
2221
|
-
files.push({ id: file.id, label });
|
|
2222
|
-
fileLabelChars += addedChars;
|
|
2223
|
-
}
|
|
2224
|
-
return {
|
|
2225
|
-
timestamp: message.timestamp,
|
|
2226
|
-
userId: message.userId,
|
|
2227
|
-
botId: message.botId,
|
|
2228
|
-
threadTimestamp: message.threadTimestamp,
|
|
2229
|
-
text: message.text,
|
|
2230
|
-
files,
|
|
2231
|
-
};
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
|
-
function projectSlackReactionCheckpointMessage(
|
|
2235
|
-
message: SlackReactionCheckpointMessage,
|
|
2236
|
-
): ReturnType<typeof projectMessage> {
|
|
2237
|
-
return {
|
|
2238
|
-
timestamp: message.timestamp,
|
|
2239
|
-
userId: message.userId,
|
|
2240
|
-
botId: message.botId,
|
|
2241
|
-
threadTimestamp: message.threadTimestamp,
|
|
2242
|
-
text: message.text,
|
|
2243
|
-
files: message.files.map((file) => ({
|
|
2244
|
-
id: file.id,
|
|
2245
|
-
name: "",
|
|
2246
|
-
title: file.label,
|
|
2247
|
-
mimetype: "",
|
|
2248
|
-
filetype: "",
|
|
2249
|
-
mode: "",
|
|
2250
|
-
size: null,
|
|
2251
|
-
originatingHuddleId: "",
|
|
2252
|
-
huddleTranscriptFileId: "",
|
|
2253
|
-
})),
|
|
2254
|
-
};
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
function selectSlackReactionCheckpointMessages(
|
|
2258
|
-
messages: ReturnType<typeof projectMessage>[],
|
|
2259
|
-
): ReturnType<typeof projectMessage>[] {
|
|
2260
|
-
if (messages.length <= MAX_REACTION_CONTEXT_MESSAGES) return [...messages];
|
|
2261
|
-
return [messages[0]!, ...messages.slice(-(MAX_REACTION_CONTEXT_MESSAGES - 1))];
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
2907
|
function hasExactSlackCheckpointKeys(value: Record<string, unknown>, expected: string[]): boolean {
|
|
2265
2908
|
return Object.keys(value).sort().join(",") === [...expected].sort().join(",");
|
|
2266
2909
|
}
|
|
@@ -2576,6 +3219,74 @@ function embeddedSlackFileIds(content: string): Set<string> {
|
|
|
2576
3219
|
);
|
|
2577
3220
|
}
|
|
2578
3221
|
|
|
3222
|
+
/**
|
|
3223
|
+
* Bounded projections of `assistant.search.context` results. Content excerpts
|
|
3224
|
+
* are capped, Slack's rich `blocks` and context messages are deliberately
|
|
3225
|
+
* dropped, and permalinks are kept only when they are https URLs.
|
|
3226
|
+
*/
|
|
3227
|
+
function projectSearchMessage(value: unknown) {
|
|
3228
|
+
const message = slackRecord(value);
|
|
3229
|
+
const channelId = slackString(message?.channel_id);
|
|
3230
|
+
const ts = slackString(message?.message_ts);
|
|
3231
|
+
if (!message || !channelId || !ts) return null;
|
|
3232
|
+
return {
|
|
3233
|
+
channelId,
|
|
3234
|
+
channelName: boundedSlackString(message.channel_name, 256),
|
|
3235
|
+
ts,
|
|
3236
|
+
authorUserId: boundedSlackString(message.author_user_id, 64),
|
|
3237
|
+
authorName: boundedSlackString(message.author_name, 256),
|
|
3238
|
+
isAuthorBot: message.is_author_bot === true,
|
|
3239
|
+
content: boundedSlackString(message.content, MAX_SEARCH_CONTENT_CHARS),
|
|
3240
|
+
permalink: safeSlackPermalink(message.permalink),
|
|
3241
|
+
};
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
function projectSearchFile(value: unknown) {
|
|
3245
|
+
const file = slackRecord(value);
|
|
3246
|
+
const id = slackString(file?.file_id);
|
|
3247
|
+
if (!file || !id) return null;
|
|
3248
|
+
return {
|
|
3249
|
+
id,
|
|
3250
|
+
title: boundedSlackString(file.title, 512),
|
|
3251
|
+
filetype: boundedSlackString(file.file_type, 128),
|
|
3252
|
+
authorUserId: boundedSlackString(file.author_user_id, 64),
|
|
3253
|
+
authorName: boundedSlackString(file.author_name, 256),
|
|
3254
|
+
dateCreated: slackUnixSeconds(file.date_created),
|
|
3255
|
+
dateUpdated: slackUnixSeconds(file.date_updated),
|
|
3256
|
+
content: boundedSlackString(file.content, MAX_SEARCH_CONTENT_CHARS),
|
|
3257
|
+
permalink: safeSlackPermalink(file.permalink),
|
|
3258
|
+
};
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
function projectSearchChannel(value: unknown) {
|
|
3262
|
+
const channel = slackRecord(value);
|
|
3263
|
+
const name = slackString(channel?.name);
|
|
3264
|
+
if (!channel || !name) return null;
|
|
3265
|
+
return {
|
|
3266
|
+
name: boundedSlackString(name, 256),
|
|
3267
|
+
topic: boundedSlackString(channel.topic, 512),
|
|
3268
|
+
purpose: boundedSlackString(channel.purpose, 512),
|
|
3269
|
+
creatorUserId: boundedSlackString(channel.creator_user_id, 64),
|
|
3270
|
+
dateCreated: slackUnixSeconds(channel.date_created),
|
|
3271
|
+
permalink: safeSlackPermalink(channel.permalink),
|
|
3272
|
+
};
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
function slackUnixSeconds(value: unknown): number | null {
|
|
3276
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
function safeSlackPermalink(value: unknown): string | null {
|
|
3280
|
+
// An over-long URL is dropped, never truncated into a still-parseable one.
|
|
3281
|
+
const raw = typeof value === "string" && value.length <= 1_024 ? value : "";
|
|
3282
|
+
if (!raw) return null;
|
|
3283
|
+
try {
|
|
3284
|
+
return new URL(raw).protocol === "https:" ? raw : null;
|
|
3285
|
+
} catch {
|
|
3286
|
+
return null;
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
|
|
2579
3290
|
function projectUser(value: unknown) {
|
|
2580
3291
|
const user = slackRecord(value);
|
|
2581
3292
|
const id = slackString(user?.id);
|
|
@@ -2701,28 +3412,31 @@ export function nextSlackFilesListPage(
|
|
|
2701
3412
|
return nextPage;
|
|
2702
3413
|
}
|
|
2703
3414
|
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
return
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
3415
|
+
/**
|
|
3416
|
+
* Slack action IDs are unique within a block. Keep the logical card untouched
|
|
3417
|
+
* for the durable request digest, and partition repeated actions only on the
|
|
3418
|
+
* provider wire. In particular, a legacy invalid_blocks receipt can retry with
|
|
3419
|
+
* its original operation ID and digest; completed/uncertain posts keep their
|
|
3420
|
+
* existing replay/reconciliation path. Values and action IDs remain exact.
|
|
3421
|
+
*/
|
|
3422
|
+
export function slackMessageWireBlocks(
|
|
3423
|
+
blocks: SlackMessageBlock[] | undefined,
|
|
3424
|
+
): SlackMessageBlock[] | undefined {
|
|
3425
|
+
if (!blocks) return undefined;
|
|
3426
|
+
const wire = blocks.flatMap((block, index): SlackMessageBlock[] => {
|
|
3427
|
+
if (
|
|
3428
|
+
block.type !== "actions" ||
|
|
3429
|
+
new Set(block.elements.map((e) => e.action_id)).size === block.elements.length
|
|
3430
|
+
)
|
|
3431
|
+
return [block];
|
|
3432
|
+
const id = createHash("sha256").update(`${index}:${block.block_id}`).digest("hex").slice(0, 24);
|
|
3433
|
+
return block.elements.map((element, position) => ({
|
|
3434
|
+
type: "actions",
|
|
3435
|
+
block_id: `opengeni_wire_${id}_${position}`,
|
|
3436
|
+
elements: [element],
|
|
3437
|
+
}));
|
|
3438
|
+
});
|
|
3439
|
+
return validateSlackMessageBlocks(wire);
|
|
2726
3440
|
}
|
|
2727
3441
|
|
|
2728
3442
|
function validateSlackMessageBlocks(
|
|
@@ -2738,6 +3452,16 @@ function validateSlackMessageBlocks(
|
|
|
2738
3452
|
return blocks;
|
|
2739
3453
|
}
|
|
2740
3454
|
|
|
3455
|
+
function validateSlackHomeBlocks(blocks: SlackHomeBlock[]): SlackHomeBlock[] {
|
|
3456
|
+
if (blocks.length < 1 || blocks.length > 100) {
|
|
3457
|
+
throw new RangeError("Slack App Home blocks exceed the supported count");
|
|
3458
|
+
}
|
|
3459
|
+
if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 48 * 1024) {
|
|
3460
|
+
throw new RangeError("Slack App Home blocks exceed the supported byte size");
|
|
3461
|
+
}
|
|
3462
|
+
return blocks;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
2741
3465
|
function boundedInt(value: number | undefined, max: number, fallback: number): number {
|
|
2742
3466
|
return typeof value === "number" && Number.isInteger(value) && value > 0
|
|
2743
3467
|
? Math.min(value, max)
|