@vellumai/assistant 0.11.1 → 0.11.2-staging.2
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/Dockerfile +1 -3
- package/README.md +1 -1
- package/eslint.config.mjs +28 -8
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
- package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
- package/openapi.yaml +292 -5
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
- package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
- package/src/__tests__/compaction-events.test.ts +139 -2
- package/src/__tests__/config-loader-backfill.test.ts +35 -3
- package/src/__tests__/config-schema.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
- package/src/__tests__/conversation-agent-loop.test.ts +180 -7
- package/src/__tests__/conversation-error.test.ts +211 -4
- package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
- package/src/__tests__/conversation-queue.test.ts +126 -0
- package/src/__tests__/conversation-retry-route.test.ts +46 -7
- package/src/__tests__/conversation-slash.test.ts +4 -2
- package/src/__tests__/conversation-summarize-route.test.ts +7 -1
- package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
- package/src/__tests__/document-create-dedupe.test.ts +2 -1
- package/src/__tests__/document-find-replace.test.ts +2 -1
- package/src/__tests__/document-tool-security.test.ts +2 -1
- package/src/__tests__/document-update-default-surface.test.ts +2 -1
- package/src/__tests__/document-workspace-file.test.ts +467 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
- package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
- package/src/__tests__/http-user-message-parity.test.ts +66 -0
- package/src/__tests__/list-messages-attachments.test.ts +87 -0
- package/src/__tests__/list-messages-provider-error.test.ts +143 -0
- package/src/__tests__/list-messages-system-card.test.ts +100 -0
- package/src/__tests__/llm-resolver.test.ts +19 -9
- package/src/__tests__/managed-profile-guard.test.ts +23 -0
- package/src/__tests__/notification-platform-adapter.test.ts +130 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
- package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/provider-error-scenarios.test.ts +140 -0
- package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
- package/src/__tests__/skills.test.ts +27 -0
- package/src/__tests__/slack-channels-routes.test.ts +0 -2
- package/src/__tests__/slack-share-routes.test.ts +0 -3
- package/src/__tests__/slack-users-routes.test.ts +0 -2
- package/src/__tests__/subagent-tools.test.ts +11 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
- package/src/__tests__/tool-result-spool.test.ts +5 -4
- package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
- package/src/__tests__/turn-events-store.test.ts +26 -0
- package/src/__tests__/ui-visual-surface.test.ts +695 -0
- package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
- package/src/__tests__/visible-app-context.test.ts +189 -0
- package/src/__tests__/workspace-git-service.test.ts +173 -33
- package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
- package/src/agent/loop.ts +36 -7
- package/src/api/events/context-window-usage.ts +31 -0
- package/src/api/events/notification-intent.ts +8 -0
- package/src/api/events/ui-surface-pending.ts +35 -0
- package/src/api/index.ts +14 -0
- package/src/api/responses/conversation-message.ts +25 -4
- package/src/api/surfaces.ts +90 -1
- package/src/approvals/guardian-card-withdrawal.ts +66 -31
- package/src/approvals/guardian-decision-primitive.ts +4 -0
- package/src/calls/__tests__/call-setup-router.test.ts +156 -26
- package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
- package/src/calls/call-setup-router.ts +80 -36
- package/src/calls/voice-session-bridge.ts +173 -26
- package/src/cli/commands/inference.help.ts +3 -3
- package/src/cli/commands/notifications.help.ts +3 -2
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
- package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
- package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
- package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
- package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
- package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
- package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
- package/src/cli/commands/platform/index.help.ts +92 -0
- package/src/cli/commands/platform/index.ts +7 -0
- package/src/cli/commands/platform/invoices.ts +132 -0
- package/src/cli/commands/usage.help.ts +1 -1
- package/src/cli/lib/list-installed-plugins.ts +2 -1
- package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
- package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
- package/src/config/assistant-feature-flags.ts +7 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
- package/src/config/bundled-skills/subagent/SKILL.md +3 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
- package/src/config/bundled-skills/visualize/SKILL.md +163 -0
- package/src/config/call-site-defaults.ts +3 -2
- package/src/config/default-profile-catalog.ts +75 -70
- package/src/config/default-profile-names.ts +13 -28
- package/src/config/env-registry.ts +1 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/llm-resolver.ts +21 -5
- package/src/config/loader.ts +18 -11
- package/src/config/schemas/llm.ts +1 -9
- package/src/config/schemas/memory-retrospective.ts +9 -0
- package/src/config/schemas/monitoring.ts +28 -2
- package/src/config/schemas/workspace-git.ts +15 -0
- package/src/config/seed-inference-profiles.ts +19 -5
- package/src/context/post-turn-tool-result-truncation.ts +2 -2
- package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
- package/src/conversations/message-consolidation.ts +17 -15
- package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
- package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
- package/src/daemon/conversation-agent-loop.ts +107 -22
- package/src/daemon/conversation-error.ts +169 -48
- package/src/daemon/conversation-messaging.ts +87 -4
- package/src/daemon/conversation-process.ts +57 -45
- package/src/daemon/conversation-runtime-assembly.ts +57 -0
- package/src/daemon/conversation-store.ts +36 -1
- package/src/daemon/conversation-surfaces.ts +68 -6
- package/src/daemon/conversation-turn-finalize.ts +76 -18
- package/src/daemon/conversation.ts +122 -27
- package/src/daemon/lifecycle.ts +9 -0
- package/src/daemon/message-types/conversations.ts +8 -0
- package/src/daemon/message-types/surfaces.ts +3 -0
- package/src/documents/document-store.ts +247 -10
- package/src/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- package/src/live-voice/__tests__/activity-label.test.ts +95 -0
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
- package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
- package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
- package/src/live-voice/activity-label.ts +169 -0
- package/src/live-voice/live-activity-reporter.ts +35 -6
- package/src/live-voice/live-voice-session.ts +282 -41
- package/src/live-voice/protocol.ts +35 -0
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
- package/src/messaging/providers/slack/adapter.ts +86 -19
- package/src/messaging/providers/slack/api.test.ts +85 -1
- package/src/messaging/providers/slack/api.ts +121 -288
- package/src/messaging/providers/slack/client.ts +20 -251
- package/src/messaging/providers/slack/send.test.ts +4 -9
- package/src/messaging/providers/slack/send.ts +1 -1
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
- package/src/messaging/providers/slack/web-api-transport.ts +367 -0
- package/src/messaging/providers/slack/withdraw.ts +6 -14
- package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
- package/src/messaging/providers/telegram-bot/send.ts +23 -2
- package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
- package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
- package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
- package/src/monitoring/file-descriptors.ts +262 -0
- package/src/monitoring/process-memory.ts +4 -11
- package/src/monitoring/resource-sampler.ts +5 -0
- package/src/monitoring/worker.ts +12 -0
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
- package/src/notifications/__tests__/broadcaster.test.ts +347 -7
- package/src/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +349 -29
- package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/adapters/macos.ts +3 -0
- package/src/notifications/adapters/platform.ts +90 -14
- package/src/notifications/adapters/telegram.ts +6 -4
- package/src/notifications/assistant-reply-producer.ts +219 -0
- package/src/notifications/broadcaster.ts +523 -317
- package/src/notifications/copy-composer.ts +27 -6
- package/src/notifications/decision-engine.ts +154 -77
- package/src/notifications/deterministic-checks.ts +6 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/emit-signal.ts +49 -16
- package/src/notifications/guardian-delivery-recorder.ts +8 -2
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +39 -4
- package/src/notifications/signal.ts +10 -0
- package/src/notifications/types.ts +17 -0
- package/src/persistence/bookmark-crud.ts +18 -9
- package/src/persistence/conversation-attention-store.ts +26 -0
- package/src/persistence/conversation-crud.ts +79 -32
- package/src/persistence/conversation-queries.ts +30 -15
- package/src/persistence/conversation-title-service.ts +5 -170
- package/src/persistence/conversation-types.ts +192 -1
- package/src/persistence/db-init.ts +14 -1
- package/src/persistence/db-maintenance.ts +5 -4
- package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
- package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
- package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
- package/src/persistence/embeddings/embedding-local.ts +2 -1
- package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
- package/src/persistence/embeddings/plugin-index.ts +84 -12
- package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
- package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
- package/src/persistence/planner-statistics.ts +14 -0
- package/src/persistence/schema/documents.ts +26 -11
- package/src/persistence/steps.ts +2 -0
- package/src/platform/client.test.ts +172 -2
- package/src/platform/client.ts +178 -55
- package/src/plugin-api/conversation-turn.ts +13 -0
- package/src/plugin-api/model-profiles.test.ts +6 -2
- package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
- package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
- package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
- package/src/plugins/defaults/memory/host-utils.ts +5 -0
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
- package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
- package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
- package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
- package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
- package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
- package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
- package/src/plugins/defaults/memory/v2/injection.ts +48 -3
- package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
- package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
- package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
- package/src/plugins/types.ts +17 -0
- package/src/prompts/templates/system-sections.ts +2 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
- package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
- package/src/providers/call-site-routing.ts +77 -14
- package/src/providers/connection-resolution.ts +42 -2
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
- package/src/providers/inference/adapter-factory.ts +70 -12
- package/src/providers/model-catalog.ts +0 -11
- package/src/providers/model-intents.ts +11 -0
- package/src/providers/registry.ts +8 -0
- package/src/providers/retry.ts +85 -17
- package/src/providers/types.ts +8 -1
- package/src/runtime/__tests__/agent-wake.test.ts +181 -0
- package/src/runtime/agent-wake.ts +132 -9
- package/src/runtime/channel-approval-types.ts +25 -0
- package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
- package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
- package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
- package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
- package/src/runtime/routes/consolidation-routes.ts +1 -1
- package/src/runtime/routes/conversation-management-routes.ts +18 -6
- package/src/runtime/routes/conversation-query-routes.ts +14 -23
- package/src/runtime/routes/conversation-routes.ts +113 -8
- package/src/runtime/routes/credential-routes.ts +1 -5
- package/src/runtime/routes/documents-routes.ts +207 -12
- package/src/runtime/routes/identity-routes.ts +3 -93
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
- package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
- package/src/runtime/routes/notification-routes.ts +4 -1
- package/src/runtime/routes/platform-routes.ts +238 -3
- package/src/runtime/routes/playground/guard.ts +1 -2
- package/src/runtime/routes/slack-channel-routes.ts +2 -4
- package/src/runtime/routes/workspace-commit-routes.ts +49 -2
- package/src/runtime/routes/workspace-routes.ts +11 -12
- package/src/runtime/routes/workspace-utils.ts +49 -2
- package/src/runtime/services/conversation-serializer.ts +2 -4
- package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
- package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
- package/src/subagent/__tests__/consult-context.test.ts +84 -0
- package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
- package/src/subagent/consult-context.ts +410 -0
- package/src/subagent/consult-prompt.ts +38 -5
- package/src/subagent/manager.ts +9 -4
- package/src/subagent/types.ts +8 -0
- package/src/telemetry/telemetry-event-sources.ts +7 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire.generated.ts +1 -0
- package/src/telemetry/turn-events-store.ts +30 -1
- package/src/telemetry/types.ts +24 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
- package/src/tools/browser/pinned-tabs.ts +3 -1
- package/src/tools/subagent/spawn.ts +23 -1
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/tools/ui-surface/definitions.ts +39 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
- package/src/tools/ui-surface/visual-validation.ts +787 -0
- package/src/tools/workflows/run-workflow.test.ts +1 -0
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/__tests__/worker-compute.test.ts +65 -0
- package/src/util/cgroup-cpu.ts +93 -0
- package/src/util/errors.ts +27 -0
- package/src/util/process-tree.ts +19 -0
- package/src/util/short-title.ts +189 -0
- package/src/util/worker-compute.ts +83 -0
- package/src/workspace/byok-default-profile-ensure.ts +25 -15
- package/src/workspace/git-service.ts +79 -30
- package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Slack Web API transport: the single implementation of request
|
|
3
|
+
* dispatch, rate-limit/5xx retries, envelope checking, and error
|
|
4
|
+
* classification used by both Slack feature surfaces (`api.ts` for
|
|
5
|
+
* streaming/uploads/channel info, `client.ts` for the typed messaging
|
|
6
|
+
* wrappers). Not to be confused with `transport.ts`, the outbound
|
|
7
|
+
* `ChannelTransport` delivery seam.
|
|
8
|
+
*
|
|
9
|
+
* There is exactly ONE `SlackApiError` class, defined here. Callers branch on
|
|
10
|
+
* three fields, all always present:
|
|
11
|
+
* - `slackError`: Slack's error code (`"channel_not_found"`, ...), or
|
|
12
|
+
* `"http_<status>"` for HTTP-level failures, or `"unknown_error"`.
|
|
13
|
+
* - `category`: coarse classification for policy decisions
|
|
14
|
+
* (`auth` / `rate_limit` / `permission` / ...).
|
|
15
|
+
* - `status`: HTTP-shaped status for OAuth-refresh compatibility:
|
|
16
|
+
* explicit HTTP status when the failure was HTTP-level, otherwise derived
|
|
17
|
+
* from the category (`auth` → 401, `rate_limit` → 429, else 400).
|
|
18
|
+
* `oauth/connection.js` retry logic and the adapter's user→bot fallback
|
|
19
|
+
* key on `status === 401`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { OAuthConnection } from "../../../oauth/connection.js";
|
|
23
|
+
import { getLogger } from "../../../util/logger.js";
|
|
24
|
+
import type { SlackApiResponse } from "./types.js";
|
|
25
|
+
|
|
26
|
+
const log = getLogger("slack-web-api");
|
|
27
|
+
|
|
28
|
+
export const SLACK_API_BASE = "https://slack.com/api";
|
|
29
|
+
const MAX_RATE_LIMIT_RETRIES = 3;
|
|
30
|
+
const DEFAULT_RETRY_AFTER_S = 1;
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Error classification
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export type SlackErrorCategory =
|
|
37
|
+
| "auth"
|
|
38
|
+
| "rate_limit"
|
|
39
|
+
| "not_found"
|
|
40
|
+
| "permission"
|
|
41
|
+
| "channel_not_found"
|
|
42
|
+
| "client_error"
|
|
43
|
+
| "transient"
|
|
44
|
+
| "unknown";
|
|
45
|
+
|
|
46
|
+
const SLACK_ERROR_CODE_MAP: Record<string, SlackErrorCategory> = {
|
|
47
|
+
invalid_auth: "auth",
|
|
48
|
+
token_expired: "auth",
|
|
49
|
+
token_revoked: "auth",
|
|
50
|
+
not_authed: "auth",
|
|
51
|
+
account_inactive: "auth",
|
|
52
|
+
org_login_required: "auth",
|
|
53
|
+
rate_limited: "rate_limit",
|
|
54
|
+
ratelimited: "rate_limit",
|
|
55
|
+
channel_not_found: "channel_not_found",
|
|
56
|
+
is_archived: "channel_not_found",
|
|
57
|
+
not_in_channel: "permission",
|
|
58
|
+
missing_scope: "permission",
|
|
59
|
+
ekm_access_denied: "permission",
|
|
60
|
+
not_allowed_token_type: "permission",
|
|
61
|
+
restricted_action: "permission",
|
|
62
|
+
cannot_dm_bot: "permission",
|
|
63
|
+
user_not_found: "not_found",
|
|
64
|
+
message_not_found: "not_found",
|
|
65
|
+
thread_not_found: "not_found",
|
|
66
|
+
invalid_blocks: "client_error",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function classifySlackError(
|
|
70
|
+
errorCode: string | undefined,
|
|
71
|
+
): SlackErrorCategory {
|
|
72
|
+
if (!errorCode) {
|
|
73
|
+
return "unknown";
|
|
74
|
+
}
|
|
75
|
+
return SLACK_ERROR_CODE_MAP[errorCode] ?? "unknown";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deriveStatus(category: SlackErrorCategory): number {
|
|
79
|
+
switch (category) {
|
|
80
|
+
case "auth":
|
|
81
|
+
return 401;
|
|
82
|
+
case "rate_limit":
|
|
83
|
+
return 429;
|
|
84
|
+
default:
|
|
85
|
+
return 400;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class SlackApiError extends Error {
|
|
90
|
+
readonly slackError: string;
|
|
91
|
+
readonly category: SlackErrorCategory;
|
|
92
|
+
readonly status: number;
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
slackError: string | undefined,
|
|
96
|
+
opts: { status?: number; message?: string } = {},
|
|
97
|
+
) {
|
|
98
|
+
const code = slackError ?? "unknown_error";
|
|
99
|
+
super(opts.message ?? `Slack API error: ${code}`);
|
|
100
|
+
this.name = "SlackApiError";
|
|
101
|
+
this.slackError = code;
|
|
102
|
+
this.category = classifySlackError(slackError);
|
|
103
|
+
this.status = opts.status ?? deriveStatus(this.category);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Envelope checking
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check a Slack API response envelope and throw a classified `SlackApiError`
|
|
113
|
+
* when `ok` is false. Slack reports most failures as HTTP 200 with an error
|
|
114
|
+
* code in the body, so this runs on every successful HTTP exchange.
|
|
115
|
+
*/
|
|
116
|
+
export function checkSlackEnvelope<T extends SlackApiResponse>(data: T): T {
|
|
117
|
+
if (!data.ok) {
|
|
118
|
+
throw new SlackApiError(data.error);
|
|
119
|
+
}
|
|
120
|
+
return data;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sleepMs(ms: number): Promise<void> {
|
|
124
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function retryAfterSeconds(header: string | null): number {
|
|
128
|
+
return parseInt(header ?? "", 10) || DEFAULT_RETRY_AFTER_S;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Raw-token requests
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
export interface SlackRequestOptions {
|
|
136
|
+
/** Query parameters; presence selects GET (unless `body`/`form` is set). */
|
|
137
|
+
query?: Record<string, string | undefined>;
|
|
138
|
+
/** JSON body; presence selects POST with a JSON content type. */
|
|
139
|
+
body?: Record<string, unknown>;
|
|
140
|
+
/** Form body; presence selects POST with form encoding. */
|
|
141
|
+
form?: URLSearchParams;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildRequest(
|
|
145
|
+
token: string,
|
|
146
|
+
method: string,
|
|
147
|
+
opts: SlackRequestOptions,
|
|
148
|
+
): { url: string; init: RequestInit } {
|
|
149
|
+
let url = `${SLACK_API_BASE}/${method}`;
|
|
150
|
+
const headers: Record<string, string> = {
|
|
151
|
+
Authorization: `Bearer ${token}`,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (opts.body) {
|
|
155
|
+
headers["Content-Type"] = "application/json; charset=utf-8";
|
|
156
|
+
return {
|
|
157
|
+
url,
|
|
158
|
+
init: { method: "POST", headers, body: JSON.stringify(opts.body) },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (opts.form) {
|
|
162
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
163
|
+
return { url, init: { method: "POST", headers, body: opts.form } };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const searchParams = new URLSearchParams();
|
|
167
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
168
|
+
if (v !== undefined) {
|
|
169
|
+
searchParams.set(k, v);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const query = searchParams.toString();
|
|
173
|
+
if (query) {
|
|
174
|
+
url += `?${query}`;
|
|
175
|
+
}
|
|
176
|
+
return { url, init: { method: "GET", headers } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Execute a raw-token Slack Web API request with the shared retry policy:
|
|
181
|
+
* HTTP 429 honors `Retry-After`, 5xx retries with fixed backoff, and
|
|
182
|
+
* body-level rate-limit errors (Slack sometimes returns HTTP 200 +
|
|
183
|
+
* `rate_limited`/`ratelimited`) retry via their classified category. All
|
|
184
|
+
* failures throw the unified `SlackApiError`.
|
|
185
|
+
*/
|
|
186
|
+
export async function rawSlackRequest<T extends SlackApiResponse>(
|
|
187
|
+
token: string,
|
|
188
|
+
method: string,
|
|
189
|
+
opts: SlackRequestOptions = {},
|
|
190
|
+
): Promise<T> {
|
|
191
|
+
const { url, init } = buildRequest(token, method, opts);
|
|
192
|
+
|
|
193
|
+
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) {
|
|
194
|
+
const resp = await fetch(url, init);
|
|
195
|
+
|
|
196
|
+
if (resp.status === 429) {
|
|
197
|
+
if (attempt >= MAX_RATE_LIMIT_RETRIES) {
|
|
198
|
+
throw new SlackApiError("rate_limited", {
|
|
199
|
+
status: 429,
|
|
200
|
+
message: "Slack API rate limited",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const retryAfter = retryAfterSeconds(resp.headers.get("Retry-After"));
|
|
204
|
+
log.warn({ method, retryAfter, attempt }, "Slack rate limited, retrying");
|
|
205
|
+
await sleepMs(retryAfter * 1000);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (resp.status >= 500) {
|
|
210
|
+
if (attempt >= MAX_RATE_LIMIT_RETRIES) {
|
|
211
|
+
throw new SlackApiError(`http_${resp.status}`, {
|
|
212
|
+
status: resp.status,
|
|
213
|
+
message: `Slack ${method} failed with status ${resp.status} after retries`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
log.warn(
|
|
217
|
+
{ method, status: resp.status, attempt },
|
|
218
|
+
"Slack 5xx error, retrying",
|
|
219
|
+
);
|
|
220
|
+
await sleepMs(DEFAULT_RETRY_AFTER_S * 1000);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!resp.ok) {
|
|
225
|
+
throw new SlackApiError(`http_${resp.status}`, {
|
|
226
|
+
status: resp.status,
|
|
227
|
+
message: `Slack API HTTP ${resp.status}`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const data = (await resp.json()) as T;
|
|
232
|
+
|
|
233
|
+
if (
|
|
234
|
+
!data.ok &&
|
|
235
|
+
classifySlackError(data.error) === "rate_limit" &&
|
|
236
|
+
attempt < MAX_RATE_LIMIT_RETRIES
|
|
237
|
+
) {
|
|
238
|
+
log.warn(
|
|
239
|
+
{ method, slackError: data.error, attempt },
|
|
240
|
+
"Slack rate limited (body), retrying",
|
|
241
|
+
);
|
|
242
|
+
await sleepMs(DEFAULT_RETRY_AFTER_S * 1000);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return checkSlackEnvelope(data);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Unreachable: every loop exit path above either returns or throws.
|
|
250
|
+
throw new SlackApiError("rate_limited", {
|
|
251
|
+
status: 429,
|
|
252
|
+
message: "Slack API rate limited",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// OAuth-connection requests
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Execute a Slack API request via a refreshing `OAuthConnection`.
|
|
262
|
+
*
|
|
263
|
+
* Slack returns HTTP 200 with `{ ok: false, error: "invalid_auth" }` for auth
|
|
264
|
+
* errors. Because `connection.request()` delegates to `withValidToken`, which
|
|
265
|
+
* only retries HTTP-level 401s, envelope auth errors (classified to
|
|
266
|
+
* `status === 401`) are caught here and retried once via
|
|
267
|
+
* `connection.withToken()`, which forces a token refresh first.
|
|
268
|
+
*/
|
|
269
|
+
async function connectionSlackRequest<T extends SlackApiResponse>(
|
|
270
|
+
connection: OAuthConnection,
|
|
271
|
+
method: string,
|
|
272
|
+
opts: SlackRequestOptions,
|
|
273
|
+
): Promise<T> {
|
|
274
|
+
// `connection.request()` carries only query/body; silently dropping a form
|
|
275
|
+
// payload would send Slack a parameterless request that fails with a
|
|
276
|
+
// misleading Slack-side error. Fail fast instead: form-encoded methods are
|
|
277
|
+
// only reachable on Socket Mode installs, whose auth is a raw token.
|
|
278
|
+
if (opts.form) {
|
|
279
|
+
throw new SlackApiError("form_unsupported_over_oauth", {
|
|
280
|
+
message:
|
|
281
|
+
`Slack ${method} uses a form-encoded body, which is not supported ` +
|
|
282
|
+
"over a legacy OAuth connection; a Socket Mode bot token is required",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const query: Record<string, string> | undefined = opts.query
|
|
287
|
+
? Object.fromEntries(
|
|
288
|
+
Object.entries(opts.query).filter(
|
|
289
|
+
(entry): entry is [string, string] => entry[1] !== undefined,
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
: undefined;
|
|
293
|
+
|
|
294
|
+
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) {
|
|
295
|
+
const resp = await connection.request({
|
|
296
|
+
method: opts.body ? "POST" : "GET",
|
|
297
|
+
path: `/${method}`,
|
|
298
|
+
query: query && Object.keys(query).length > 0 ? query : undefined,
|
|
299
|
+
body: opts.body,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (resp.status === 429) {
|
|
303
|
+
if (attempt >= MAX_RATE_LIMIT_RETRIES) {
|
|
304
|
+
throw new SlackApiError("rate_limited", {
|
|
305
|
+
status: 429,
|
|
306
|
+
message: "Slack API rate limited",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const retryAfter = retryAfterSeconds(
|
|
310
|
+
resp.headers["retry-after"] ?? resp.headers["Retry-After"] ?? null,
|
|
311
|
+
);
|
|
312
|
+
await sleepMs(retryAfter * 1000);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (resp.status >= 400) {
|
|
317
|
+
throw new SlackApiError(`http_${resp.status}`, {
|
|
318
|
+
status: resp.status,
|
|
319
|
+
message: `Slack API HTTP ${resp.status}`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const data = resp.body as T;
|
|
324
|
+
|
|
325
|
+
if (
|
|
326
|
+
!data.ok &&
|
|
327
|
+
classifySlackError(data.error) === "rate_limit" &&
|
|
328
|
+
attempt < MAX_RATE_LIMIT_RETRIES
|
|
329
|
+
) {
|
|
330
|
+
await sleepMs(DEFAULT_RETRY_AFTER_S * 1000);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
return checkSlackEnvelope(data);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (err instanceof SlackApiError && err.status === 401) {
|
|
338
|
+
return connection.withToken((freshToken) =>
|
|
339
|
+
rawSlackRequest<T>(freshToken, method, opts),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Unreachable: every loop exit path above either returns or throws.
|
|
347
|
+
throw new SlackApiError("rate_limited", {
|
|
348
|
+
status: 429,
|
|
349
|
+
message: "Slack API rate limited",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Dispatch a Slack Web API request on whichever auth form the caller holds:
|
|
355
|
+
* a raw token (Socket Mode installs) or a refreshing `OAuthConnection`
|
|
356
|
+
* (legacy OAuth installs).
|
|
357
|
+
*/
|
|
358
|
+
export async function slackRequest<T extends SlackApiResponse>(
|
|
359
|
+
auth: OAuthConnection | string,
|
|
360
|
+
method: string,
|
|
361
|
+
opts: SlackRequestOptions = {},
|
|
362
|
+
): Promise<T> {
|
|
363
|
+
if (typeof auth === "string") {
|
|
364
|
+
return rawSlackRequest<T>(auth, method, opts);
|
|
365
|
+
}
|
|
366
|
+
return connectionSlackRequest<T>(auth, method, opts);
|
|
367
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
isParkAction,
|
|
20
|
-
|
|
20
|
+
resolveDecisionStatusWord,
|
|
21
21
|
} from "../../../runtime/channel-approval-types.js";
|
|
22
22
|
import { getLogger } from "../../../util/logger.js";
|
|
23
23
|
import { callSlackApi, getSlackMessageBlocks } from "./api.js";
|
|
@@ -46,13 +46,6 @@ const STATUS_GLYPH: Record<string, string> = {
|
|
|
46
46
|
cancelled: ":no_entry_sign:",
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
const STATUS_WORD: Record<string, string> = {
|
|
50
|
-
approved: "Approved",
|
|
51
|
-
denied: "Denied",
|
|
52
|
-
expired: "Expired",
|
|
53
|
-
cancelled: "Cancelled",
|
|
54
|
-
};
|
|
55
|
-
|
|
56
49
|
/**
|
|
57
50
|
* Glyph for a parked (leave-unverified) decision. A neutral "on hold" mark
|
|
58
51
|
* rather than the `denied` red cross — the sender was neither trusted nor kept
|
|
@@ -69,9 +62,10 @@ export interface WithdrawSlackApprovalCardParams {
|
|
|
69
62
|
status: string;
|
|
70
63
|
/**
|
|
71
64
|
* The action the guardian took, when known. A `denied` status reached by a
|
|
72
|
-
* park action (`leave_unverified`) reads as the neutral
|
|
73
|
-
* {@link
|
|
74
|
-
* denial. Omitted for status-only transitions (e.g. the expiry
|
|
65
|
+
* park action (`leave_unverified`) reads as the neutral park label (see
|
|
66
|
+
* {@link resolveDecisionStatusWord}) rather than "Denied"; `block`/`reject`
|
|
67
|
+
* stay a denial. Omitted for status-only transitions (e.g. the expiry
|
|
68
|
+
* sweep).
|
|
75
69
|
*/
|
|
76
70
|
decidedAction?: string;
|
|
77
71
|
/** Slack user id of the decider, when the decision came from Slack. */
|
|
@@ -87,9 +81,7 @@ export interface WithdrawSlackApprovalCardParams {
|
|
|
87
81
|
function buildStatusText(params: WithdrawSlackApprovalCardParams): string {
|
|
88
82
|
const park = params.status === "denied" && isParkAction(params.decidedAction);
|
|
89
83
|
const glyph = park ? PARK_STATUS_GLYPH : (STATUS_GLYPH[params.status] ?? "");
|
|
90
|
-
const word =
|
|
91
|
-
? PARK_STATUS_LABEL
|
|
92
|
-
: (STATUS_WORD[params.status] ?? "Resolved");
|
|
84
|
+
const word = resolveDecisionStatusWord(params.status, params.decidedAction);
|
|
93
85
|
let line = glyph ? `${glyph} *${word}*` : `*${word}*`;
|
|
94
86
|
if (params.decidedByExternalUserId) {
|
|
95
87
|
line += ` by <@${params.decidedByExternalUserId}>`;
|
|
@@ -30,7 +30,7 @@ mock.module("./api.js", () => ({
|
|
|
30
30
|
}));
|
|
31
31
|
|
|
32
32
|
const { TelegramNonRetryableError } = await import("./api.js");
|
|
33
|
-
const { sendTelegramRichReply } = await import("./send.js");
|
|
33
|
+
const { sendTelegramReply, sendTelegramRichReply } = await import("./send.js");
|
|
34
34
|
const { telegramTransport } = await import("./transport.js");
|
|
35
35
|
|
|
36
36
|
const approval: ApprovalUIMetadata = {
|
|
@@ -148,6 +148,36 @@ describe("sendTelegramRichReply", () => {
|
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
describe("sendTelegramReply message id capture", () => {
|
|
152
|
+
test("returns the sent message id so approval cards can be addressed later", async () => {
|
|
153
|
+
callTelegramBotApiMock.mockImplementation(
|
|
154
|
+
async () => ({ message_id: 42 }) as never,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const result = await sendTelegramReply("123", "Please approve", approval);
|
|
158
|
+
|
|
159
|
+
expect(result.lastMessageId).toBe("42");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("returns the id of the last chunk for a split message", async () => {
|
|
163
|
+
let nextId = 1;
|
|
164
|
+
callTelegramBotApiMock.mockImplementation(
|
|
165
|
+
async () => ({ message_id: nextId++ }) as never,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const result = await sendTelegramReply("123", "x".repeat(4500));
|
|
169
|
+
|
|
170
|
+
expect(callsTo("sendMessage")).toHaveLength(2);
|
|
171
|
+
expect(result.lastMessageId).toBe("2");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("omits the message id when the API response lacks one", async () => {
|
|
175
|
+
const result = await sendTelegramReply("123", "Hello");
|
|
176
|
+
|
|
177
|
+
expect(result.lastMessageId).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
151
181
|
describe("telegramTransport.deliver routing", () => {
|
|
152
182
|
const ctx: CallbackContext = { callbackUrl: "/deliver/telegram", params: {} };
|
|
153
183
|
|
|
@@ -13,6 +13,7 @@ import { getLogger } from "../../../util/logger.js";
|
|
|
13
13
|
import {
|
|
14
14
|
callTelegramBotApi,
|
|
15
15
|
callTelegramBotApiMultipart,
|
|
16
|
+
type TelegramMessage,
|
|
16
17
|
TelegramNonRetryableError,
|
|
17
18
|
} from "./api.js";
|
|
18
19
|
import { renderTelegramHtml } from "./render.js";
|
|
@@ -121,6 +122,17 @@ function buildInlineKeyboard(approval: ApprovalUIMetadata): {
|
|
|
121
122
|
// Public API
|
|
122
123
|
// ---------------------------------------------------------------------------
|
|
123
124
|
|
|
125
|
+
/** Outcome of a Telegram reply send. */
|
|
126
|
+
export interface TelegramSendResult {
|
|
127
|
+
/**
|
|
128
|
+
* Channel-native id of the last sent chunk (the message carrying the
|
|
129
|
+
* inline keyboard when an approval was attached). Callers that need to
|
|
130
|
+
* address the message later (e.g. approval-card withdrawal) persist this.
|
|
131
|
+
* Undefined when the API response did not carry a message id.
|
|
132
|
+
*/
|
|
133
|
+
lastMessageId?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
/**
|
|
125
137
|
* Send a Telegram text reply, splitting long messages and optionally
|
|
126
138
|
* attaching inline keyboard buttons for approval prompts.
|
|
@@ -130,9 +142,10 @@ export async function sendTelegramReply(
|
|
|
130
142
|
text: string,
|
|
131
143
|
approval?: ApprovalUIMetadata,
|
|
132
144
|
opts?: TelegramSendOptions,
|
|
133
|
-
): Promise<
|
|
145
|
+
): Promise<TelegramSendResult> {
|
|
134
146
|
const chunks = splitText(text, TELEGRAM_MAX_MESSAGE_LEN);
|
|
135
147
|
|
|
148
|
+
let lastMessageId: string | undefined;
|
|
136
149
|
for (let i = 0; i < chunks.length; i++) {
|
|
137
150
|
const payload: Record<string, unknown> = {
|
|
138
151
|
chat_id: chatId,
|
|
@@ -146,10 +159,18 @@ export async function sendTelegramReply(
|
|
|
146
159
|
payload.reply_markup = buildInlineKeyboard(approval);
|
|
147
160
|
}
|
|
148
161
|
|
|
149
|
-
await callTelegramBotApi(
|
|
162
|
+
const sent = await callTelegramBotApi<TelegramMessage>(
|
|
163
|
+
"sendMessage",
|
|
164
|
+
payload,
|
|
165
|
+
);
|
|
166
|
+
lastMessageId =
|
|
167
|
+
typeof sent?.message_id === "number"
|
|
168
|
+
? String(sent.message_id)
|
|
169
|
+
: undefined;
|
|
150
170
|
}
|
|
151
171
|
|
|
152
172
|
log.debug({ chatId, chunks: chunks.length }, "Telegram reply sent");
|
|
173
|
+
return lastMessageId !== undefined ? { lastMessageId } : {};
|
|
153
174
|
}
|
|
154
175
|
|
|
155
176
|
/**
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Derive the mock signature from the real export so the test cannot drift from
|
|
4
|
+
// the production call signature.
|
|
5
|
+
type CallTelegramBotApi = typeof import("./api.js").callTelegramBotApi;
|
|
6
|
+
|
|
7
|
+
const callTelegramBotApiMock = mock<CallTelegramBotApi>(
|
|
8
|
+
async () => ({}) as never,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
mock.module("./api.js", () => ({
|
|
12
|
+
callTelegramBotApi: (method: string, body: Record<string, unknown>) =>
|
|
13
|
+
callTelegramBotApiMock(method, body),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const { withdrawTelegramApprovalCard } = await import("./withdraw.js");
|
|
17
|
+
|
|
18
|
+
const callsTo = (method: string) =>
|
|
19
|
+
callTelegramBotApiMock.mock.calls.filter((call) => call[0] === method);
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
callTelegramBotApiMock.mockReset();
|
|
23
|
+
callTelegramBotApiMock.mockImplementation(async () => ({}) as never);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("withdrawTelegramApprovalCard", () => {
|
|
27
|
+
test("clears the inline keyboard and posts a silent quoted status reply", async () => {
|
|
28
|
+
await withdrawTelegramApprovalCard({
|
|
29
|
+
chatId: "chat-1",
|
|
30
|
+
messageId: "42",
|
|
31
|
+
status: "approved",
|
|
32
|
+
postStatusReply: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const edits = callsTo("editMessageReplyMarkup");
|
|
36
|
+
expect(edits).toHaveLength(1);
|
|
37
|
+
expect(edits[0]?.[1]).toEqual({
|
|
38
|
+
chat_id: "chat-1",
|
|
39
|
+
message_id: 42,
|
|
40
|
+
reply_markup: null,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const sends = callsTo("sendMessage");
|
|
44
|
+
expect(sends).toHaveLength(1);
|
|
45
|
+
expect(sends[0]?.[1]).toEqual({
|
|
46
|
+
chat_id: "chat-1",
|
|
47
|
+
text: "✅ Approved",
|
|
48
|
+
reply_parameters: { message_id: 42, allow_sending_without_reply: true },
|
|
49
|
+
disable_notification: true,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("skips the status reply when the decision originated on Telegram", async () => {
|
|
54
|
+
await withdrawTelegramApprovalCard({
|
|
55
|
+
chatId: "chat-1",
|
|
56
|
+
messageId: "42",
|
|
57
|
+
status: "denied",
|
|
58
|
+
postStatusReply: false,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(callsTo("editMessageReplyMarkup")).toHaveLength(1);
|
|
62
|
+
expect(callsTo("sendMessage")).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("falls back to an empty inline keyboard when the null form is rejected", async () => {
|
|
66
|
+
callTelegramBotApiMock.mockImplementationOnce(async () => {
|
|
67
|
+
throw new Error("Bad Request: REPLY_MARKUP_INVALID");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await withdrawTelegramApprovalCard({
|
|
71
|
+
chatId: "chat-1",
|
|
72
|
+
messageId: "7",
|
|
73
|
+
status: "expired",
|
|
74
|
+
postStatusReply: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const edits = callsTo("editMessageReplyMarkup");
|
|
78
|
+
expect(edits).toHaveLength(2);
|
|
79
|
+
expect(edits[1]?.[1]).toEqual({
|
|
80
|
+
chat_id: "chat-1",
|
|
81
|
+
message_id: 7,
|
|
82
|
+
reply_markup: { inline_keyboard: [] },
|
|
83
|
+
});
|
|
84
|
+
expect(callsTo("sendMessage")).toHaveLength(1);
|
|
85
|
+
expect(callsTo("sendMessage")[0]?.[1]).toMatchObject({
|
|
86
|
+
text: "⌛ Expired",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("treats 'message is not modified' as already cleared", async () => {
|
|
91
|
+
callTelegramBotApiMock.mockImplementationOnce(async () => {
|
|
92
|
+
throw new Error("Bad Request: message is not modified");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await withdrawTelegramApprovalCard({
|
|
96
|
+
chatId: "chat-1",
|
|
97
|
+
messageId: "7",
|
|
98
|
+
status: "approved",
|
|
99
|
+
postStatusReply: true,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// No empty-keyboard retry: the keyboard is already gone.
|
|
103
|
+
expect(callsTo("editMessageReplyMarkup")).toHaveLength(1);
|
|
104
|
+
expect(callsTo("sendMessage")).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("still posts the status reply when both keyboard edits fail", async () => {
|
|
108
|
+
callTelegramBotApiMock.mockImplementation(
|
|
109
|
+
async (method: string): Promise<never> => {
|
|
110
|
+
if (method === "editMessageReplyMarkup") {
|
|
111
|
+
throw new Error("Bad Request: message to edit not found");
|
|
112
|
+
}
|
|
113
|
+
return {} as never;
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
await withdrawTelegramApprovalCard({
|
|
118
|
+
chatId: "chat-1",
|
|
119
|
+
messageId: "7",
|
|
120
|
+
status: "approved",
|
|
121
|
+
postStatusReply: true,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(callsTo("editMessageReplyMarkup")).toHaveLength(2);
|
|
125
|
+
expect(callsTo("sendMessage")).toHaveLength(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("renders a parked denial with the neutral park label", async () => {
|
|
129
|
+
await withdrawTelegramApprovalCard({
|
|
130
|
+
chatId: "chat-1",
|
|
131
|
+
messageId: "9",
|
|
132
|
+
status: "denied",
|
|
133
|
+
decidedAction: "leave_unverified",
|
|
134
|
+
postStatusReply: true,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(callsTo("sendMessage")[0]?.[1]).toMatchObject({
|
|
138
|
+
text: "⏸️ Left unverified",
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("skips everything on a non-numeric message id", async () => {
|
|
143
|
+
await withdrawTelegramApprovalCard({
|
|
144
|
+
chatId: "chat-1",
|
|
145
|
+
messageId: "not-a-number",
|
|
146
|
+
status: "approved",
|
|
147
|
+
postStatusReply: true,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(callTelegramBotApiMock.mock.calls).toHaveLength(0);
|
|
151
|
+
});
|
|
152
|
+
});
|