@vellumai/assistant 0.9.0-staging.1 → 0.9.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.
Files changed (36) hide show
  1. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +105 -0
  2. package/node_modules/@vellumai/gateway-client/src/index.ts +12 -0
  3. package/openapi.yaml +99 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/app-compiler.test.ts +7 -1
  6. package/src/__tests__/app-executors.test.ts +43 -0
  7. package/src/__tests__/conversation-surfaces-data-persist.test.ts +97 -0
  8. package/src/__tests__/dynamic-page-surface.test.ts +94 -0
  9. package/src/__tests__/mock-gateway-ipc.ts +23 -0
  10. package/src/__tests__/resolve-app-id.test.ts +56 -0
  11. package/src/bundler/package-resolver.ts +0 -1
  12. package/src/config/bundled-skills/app-builder/SKILL.md +1 -1
  13. package/src/config/bundled-skills/app-builder/tools/app-create.ts +6 -1
  14. package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +7 -1
  15. package/src/config/bundled-skills/app-builder/tools/app-refresh.ts +7 -1
  16. package/src/config/bundled-skills/app-builder/tools/app-update.ts +10 -1
  17. package/src/daemon/conversation-surfaces.ts +92 -3
  18. package/src/notifications/home-feed-side-effect.ts +27 -10
  19. package/src/runtime/channel-invite-transports/telegram.ts +6 -5
  20. package/src/runtime/channel-invite-transports/voice.ts +2 -2
  21. package/src/runtime/channel-invite-types.ts +4 -2
  22. package/src/runtime/channel-retry-sweep.ts +19 -41
  23. package/src/runtime/finalize-event-delivery.ts +72 -0
  24. package/src/runtime/routes/channel-delivery-routes.ts +11 -7
  25. package/src/runtime/routes/channel-route-definitions.ts +3 -0
  26. package/src/runtime/routes/inbound-message-handler.ts +13 -12
  27. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +12 -36
  28. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +7 -5
  29. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -21
  30. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +5 -5
  31. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +6 -15
  32. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +1 -1
  33. package/src/schedule/schedule-store.ts +30 -0
  34. package/src/tools/apps/executors.ts +23 -0
  35. package/src/tools/apps/resolve-app-id.ts +42 -0
  36. package/src/tools/ui-surface/definitions.ts +43 -0
@@ -2,6 +2,10 @@ import { setAppCommitMessage } from "../../../../memory/app-git-service.js";
2
2
  import * as appStore from "../../../../memory/app-store.js";
3
3
  import type { AppUpdateInput } from "../../../../tools/apps/executors.js";
4
4
  import { executeAppUpdate } from "../../../../tools/apps/executors.js";
5
+ import {
6
+ missingAppIdError,
7
+ resolveAppId,
8
+ } from "../../../../tools/apps/resolve-app-id.js";
5
9
  import type {
6
10
  ToolContext,
7
11
  ToolExecutionResult,
@@ -14,5 +18,10 @@ export async function run(
14
18
  if (typeof input.change_summary === "string" && input.change_summary.trim()) {
15
19
  setAppCommitMessage(context.conversationId, input.change_summary.trim());
16
20
  }
17
- return executeAppUpdate(input as unknown as AppUpdateInput, appStore);
21
+ const appId = resolveAppId(input, context.conversationId);
22
+ if (!appId) return missingAppIdError();
23
+ return executeAppUpdate(
24
+ { ...input, app_id: appId } as unknown as AppUpdateInput,
25
+ appStore,
26
+ );
18
27
  }
@@ -7,6 +7,7 @@ import {
7
7
  getAppDirPath,
8
8
  getAppPreview,
9
9
  isMultifileApp,
10
+ listAppsByConversation,
10
11
  resolveAppDir,
11
12
  resolveEffectiveAppHtml,
12
13
  updateApp,
@@ -179,6 +180,18 @@ export function flushSurfaceDataPersist(surfaceId: string): void {
179
180
  persistSurfaceData(pending.conversationId, surfaceId, pending.data);
180
181
  }
181
182
 
183
+ /**
184
+ * Discard (without writing) any pending debounced persist for `surfaceId`.
185
+ * Called on dismissal so an in-flight `ui_update` snapshot cannot land after
186
+ * the surface block has been removed.
187
+ */
188
+ export function cancelSurfaceDataPersist(surfaceId: string): void {
189
+ const pending = pendingSurfacePersists.get(surfaceId);
190
+ if (!pending) return;
191
+ clearTimeout(pending.timer);
192
+ pendingSurfacePersists.delete(surfaceId);
193
+ }
194
+
182
195
  /**
183
196
  * Cancel all pending debounced persists. Called on conversation
184
197
  * teardown to avoid timers firing against torn-down state.
@@ -277,6 +290,60 @@ export function markSurfaceCompleted(
277
290
  log.warn({ err, surfaceId }, "Failed to persist surface completion to DB");
278
291
  }
279
292
  }
293
+
294
+ /**
295
+ * Remove a `ui_surface` content block from history so a passively dismissed
296
+ * surface does not survive a reload. The live client drops a dismissed surface
297
+ * entirely; this converges persisted state with that behaviour. Cancels any
298
+ * pending debounced data persist first so a late `ui_update` snapshot cannot
299
+ * re-add the block, then strips the block from in-memory messages and the DB.
300
+ */
301
+ export function removeSurfaceBlock(
302
+ ctx: { conversationId: string; messages?: Array<{ content: unknown }> },
303
+ surfaceId: string,
304
+ ): void {
305
+ cancelSurfaceDataPersist(surfaceId);
306
+
307
+ if (ctx.messages) {
308
+ for (let i = ctx.messages.length - 1; i >= 0; i--) {
309
+ const msg = ctx.messages[i];
310
+ if (!Array.isArray(msg.content)) continue;
311
+ const idx = msg.content.findIndex((block) => {
312
+ const b = block as Record<string, unknown>;
313
+ return b.type === "ui_surface" && b.surfaceId === surfaceId;
314
+ });
315
+ if (idx !== -1) {
316
+ msg.content.splice(idx, 1);
317
+ break;
318
+ }
319
+ }
320
+ }
321
+
322
+ try {
323
+ const rows = getMessages(ctx.conversationId);
324
+ for (let r = rows.length - 1; r >= 0; r--) {
325
+ let parsed: unknown[];
326
+ try {
327
+ const result = JSON.parse(rows[r].content);
328
+ if (!Array.isArray(result)) continue;
329
+ parsed = result;
330
+ } catch {
331
+ continue;
332
+ }
333
+ const idx = parsed.findIndex((pb) => {
334
+ const rb = pb as Record<string, unknown>;
335
+ return rb.type === "ui_surface" && rb.surfaceId === surfaceId;
336
+ });
337
+ if (idx !== -1) {
338
+ parsed.splice(idx, 1);
339
+ updateMessageContent(rows[r].id, JSON.stringify(parsed));
340
+ return;
341
+ }
342
+ }
343
+ } catch (err) {
344
+ log.warn({ err, surfaceId }, "Failed to remove dismissed surface from DB");
345
+ }
346
+ }
280
347
  const TASK_PROGRESS_TEMPLATE_FIELDS = ["title", "status", "steps"] as const;
281
348
 
282
349
  const TASK_PROGRESS_CARD_STATUSES = new Set([
@@ -2858,6 +2925,15 @@ export async function surfaceProxyResolver(
2858
2925
  conversationId: ctx.conversationId,
2859
2926
  surfaceId,
2860
2927
  });
2928
+ // The live client drops a dismissed surface entirely. Mirror that in
2929
+ // persisted state: pull it from the pending turn snapshot (appended to
2930
+ // the message at turn completion) and strip any already-persisted block,
2931
+ // so a reload does not resurrect a half-finished progress card.
2932
+ const turnIdx = ctx.currentTurnSurfaces.findIndex(
2933
+ (s) => s.surfaceId === surfaceId,
2934
+ );
2935
+ if (turnIdx !== -1) ctx.currentTurnSurfaces.splice(turnIdx, 1);
2936
+ removeSurfaceBlock(ctx, surfaceId);
2861
2937
  }
2862
2938
  ctx.pendingSurfaceActions.delete(surfaceId);
2863
2939
  ctx.surfaceState.delete(surfaceId);
@@ -2871,11 +2947,24 @@ export async function surfaceProxyResolver(
2871
2947
  }
2872
2948
 
2873
2949
  if (toolName === "app_open") {
2874
- const appId = input.app_id as string;
2950
+ // Weaker models routinely omit app_id even though the active app is in
2951
+ // context. Fall back to the conversation's most-recently-updated app
2952
+ // rather than failing with "Invalid ID: undefined".
2953
+ let appId = input.app_id as string;
2954
+ if (typeof appId !== "string" || appId.trim().length === 0) {
2955
+ appId = listAppsByConversation(ctx.conversationId)[0]?.id ?? "";
2956
+ }
2875
2957
  const preview = input.preview as DynamicPageSurfaceData["preview"];
2876
2958
  const openMode = input.open_mode as string | undefined;
2877
- const app = getApp(appId);
2878
- if (!app) return { content: `App not found: ${appId}`, isError: true };
2959
+ const app = appId ? getApp(appId) : null;
2960
+ if (!app) {
2961
+ return {
2962
+ content: appId
2963
+ ? `App not found: ${appId}`
2964
+ : "app_id is required and no active app exists in this conversation. Call app_create first, or pass app_id explicitly.",
2965
+ isError: true,
2966
+ };
2967
+ }
2879
2968
 
2880
2969
  // Track conversation association (best-effort — failures must not break open flow).
2881
2970
  try {
@@ -58,10 +58,8 @@ export async function writeHomeFeedItemForSignal(
58
58
  decision: NotificationDecision,
59
59
  fallbackConversationId?: string,
60
60
  ): Promise<FeedItem | null> {
61
- const { mirror, sourceConversationId } = resolveHomeFeedMirror(
62
- signal,
63
- fallbackConversationId,
64
- );
61
+ const { mirror, sourceConversationId, sourceScheduleJobId } =
62
+ resolveHomeFeedMirror(signal, fallbackConversationId);
65
63
  if (!mirror) return null;
66
64
 
67
65
  const renderedCopy =
@@ -106,13 +104,28 @@ export async function writeHomeFeedItemForSignal(
106
104
 
107
105
  const category = deriveCategory(signal);
108
106
  const panelKind = deriveDetailPanelKind(signal);
109
- const metadata =
107
+
108
+ const baseMetadata =
110
109
  signal.contextPayload &&
111
110
  typeof signal.contextPayload === "object" &&
112
111
  !Array.isArray(signal.contextPayload)
113
- ? (signal.contextPayload as Record<string, unknown>)
112
+ ? { ...(signal.contextPayload as Record<string, unknown>) }
114
113
  : undefined;
115
114
 
115
+ // Link scheduled-run notifications back to their schedule. `notify`-mode
116
+ // jobs put `scheduleId` directly in the context payload; `execute`-mode (and
117
+ // other agent-backed) jobs only tag their conversation, so fall back to the
118
+ // source conversation's `scheduleJobId`.
119
+ const scheduleId =
120
+ readPayloadString(signal.contextPayload, "scheduleId") ??
121
+ sourceScheduleJobId ??
122
+ undefined;
123
+
124
+ const metadata =
125
+ scheduleId !== undefined
126
+ ? { ...(baseMetadata ?? {}), scheduleId }
127
+ : baseMetadata;
128
+
116
129
  const item: FeedItem = {
117
130
  id: `notif:${signal.signalId}`,
118
131
  type: "notification",
@@ -202,8 +215,11 @@ function resolveHomeFeedMirror(
202
215
  ): {
203
216
  mirror: boolean;
204
217
  sourceConversationId?: string;
218
+ sourceScheduleJobId?: string;
205
219
  } {
206
- let sourceRow: { conversationType?: string } | null = null;
220
+ let sourceRow:
221
+ | { conversationType?: string; scheduleJobId?: string | null }
222
+ | null = null;
207
223
  if (signal.sourceContextId) {
208
224
  try {
209
225
  sourceRow = getConversation(signal.sourceContextId) ?? null;
@@ -219,15 +235,16 @@ function resolveHomeFeedMirror(
219
235
  const sourceConversationId = sourceRow
220
236
  ? signal.sourceContextId
221
237
  : fallbackConversationId;
238
+ const sourceScheduleJobId = sourceRow?.scheduleJobId ?? undefined;
222
239
 
223
240
  if (signal.sourceChannel === "assistant_tool") {
224
- return { mirror: true, sourceConversationId };
241
+ return { mirror: true, sourceConversationId, sourceScheduleJobId };
225
242
  }
226
243
  if (signal.attentionHints.isAsyncBackground) {
227
- return { mirror: true, sourceConversationId };
244
+ return { mirror: true, sourceConversationId, sourceScheduleJobId };
228
245
  }
229
246
  if (isBackgroundConversationType(sourceRow?.conversationType)) {
230
- return { mirror: true, sourceConversationId };
247
+ return { mirror: true, sourceConversationId, sourceScheduleJobId };
231
248
  }
232
249
  return { mirror: false };
233
250
  }
@@ -9,6 +9,8 @@
9
9
  * verification) tokens that use the same `/start` deep-link mechanism.
10
10
  */
11
11
 
12
+ import type { CommandIntent, SourceMetadata } from "@vellumai/gateway-client";
13
+
12
14
  import type { ChannelId } from "../../channels/types.js";
13
15
  import {
14
16
  invalidateConfigCache,
@@ -130,17 +132,16 @@ export const telegramInviteAdapter: ChannelInviteAdapter = {
130
132
  },
131
133
 
132
134
  extractInboundToken(params: {
133
- commandIntent?: Record<string, unknown>;
135
+ commandIntent?: CommandIntent;
134
136
  content: string;
135
- sourceMetadata?: Record<string, unknown>;
137
+ sourceMetadata?: SourceMetadata;
136
138
  }): string | undefined {
137
139
  // Primary path: structured command intent from the gateway.
138
140
  // The gateway normalizes `/start <payload>` into
139
141
  // `{ type: 'start', payload: '<payload>' }`.
140
142
  if (
141
- params.commandIntent &&
142
- params.commandIntent.type === "start" &&
143
- typeof params.commandIntent.payload === "string"
143
+ params.commandIntent?.type === "start" &&
144
+ params.commandIntent.payload
144
145
  ) {
145
146
  const payload = params.commandIntent.payload;
146
147
  if (payload.startsWith(INVITE_TOKEN_PREFIX)) {
@@ -40,9 +40,9 @@ export const voiceInviteAdapter: ChannelInviteAdapter = {
40
40
  },
41
41
 
42
42
  extractInboundToken(_params: {
43
- commandIntent?: Record<string, unknown>;
43
+ commandIntent?: import("@vellumai/gateway-client").CommandIntent;
44
44
  content: string;
45
- sourceMetadata?: Record<string, unknown>;
45
+ sourceMetadata?: import("@vellumai/gateway-client").SourceMetadata;
46
46
  }): string | undefined {
47
47
  // Voice invite redemption bypasses generic token extraction — it uses
48
48
  // the identity-bound voice-code flow in invite-redemption-service.ts.
@@ -3,6 +3,8 @@
3
3
  * transport ↔ channel-invite-transports/* cycles (×5).
4
4
  */
5
5
 
6
+ import type { CommandIntent, SourceMetadata } from "@vellumai/gateway-client";
7
+
6
8
  import type { ChannelId } from "../channels/types.js";
7
9
 
8
10
  export interface InviteShareLink {
@@ -31,9 +33,9 @@ export interface ChannelInviteAdapter {
31
33
  * channels with link-based invites.
32
34
  */
33
35
  extractInboundToken?(params: {
34
- commandIntent?: Record<string, unknown>;
36
+ commandIntent?: CommandIntent;
35
37
  content: string;
36
- sourceMetadata?: Record<string, unknown>;
38
+ sourceMetadata?: SourceMetadata;
37
39
  }): string | undefined;
38
40
 
39
41
  /**
@@ -35,6 +35,7 @@ import {
35
35
  deliverReplyViaCallback,
36
36
  findAssistantReplyMessageIdForTurn,
37
37
  } from "./channel-reply-delivery.js";
38
+ import { finalizeEventDelivery } from "./finalize-event-delivery.js";
38
39
  import { deliverChannelReply } from "./gateway-client.js";
39
40
  import type { MessageProcessor } from "./http-types.js";
40
41
  import { createSlackDmTextDeliveryController } from "./slack-dm-text-delivery.js";
@@ -154,7 +155,7 @@ export async function sweepFailedEvents(
154
155
  parseInterfaceId(payload.sourceChannel) ??
155
156
  "web";
156
157
  const sourceMetadata = payload.sourceMetadata as
157
- | Record<string, unknown>
158
+ | import("@vellumai/gateway-client").SourceMetadata
158
159
  | undefined;
159
160
  const assistantId =
160
161
  typeof payload.assistantId === "string" ? payload.assistantId : undefined;
@@ -334,46 +335,23 @@ export async function sweepFailedEvents(
334
335
  continue;
335
336
  }
336
337
 
337
- if (replyCallbackUrl) {
338
- if (externalChatId) {
339
- try {
340
- if (slackDmTextDelivery) {
341
- await slackDmTextDelivery.waitForPendingDeliveries();
342
- }
343
- // processMessage above generated a fresh assistant response, so any
344
- // previously tracked final-delivery progress belongs to the old
345
- // response and must not carry over. Use live Slack DM progress as
346
- // the new baseline before final delivery so delivery-only retries do
347
- // not duplicate text if the callback fails before reporting progress.
348
- const liveDeliveryResumeOptions =
349
- slackDmTextDelivery?.getFinalDeliveryResumeOptions(replyMessageId);
350
- const finalDeliveryStartFromSegment =
351
- liveDeliveryResumeOptions?.startFromSegment ?? 0;
352
- updateDeliveredSegmentCount(event.id, finalDeliveryStartFromSegment);
353
- await deliverReplyViaCallback(
354
- event.conversationId,
355
- externalChatId,
356
- replyCallbackUrl,
357
- assistantId,
358
- {
359
- messageId: replyMessageId,
360
- sinceMessageId: userMessageId,
361
- startFromSegment: finalDeliveryStartFromSegment,
362
- ...(liveDeliveryResumeOptions?.messageTs
363
- ? { messageTs: liveDeliveryResumeOptions.messageTs }
364
- : {}),
365
- onSegmentDelivered: (count) =>
366
- updateDeliveredSegmentCount(event.id, count),
367
- },
368
- );
369
- markDeliveryDelivered(event.id);
370
- } catch (err) {
371
- log.error(
372
- { err, eventId: event.id },
373
- "Retry delivery failed for channel event",
374
- );
375
- recordDeliveryFailure(event.id, err);
376
- }
338
+ if (replyCallbackUrl && externalChatId) {
339
+ try {
340
+ await finalizeEventDelivery({
341
+ eventId: event.id,
342
+ conversationId: event.conversationId,
343
+ externalChatId,
344
+ replyCallbackUrl,
345
+ assistantId,
346
+ replyMessageId,
347
+ userMessageId,
348
+ slackDmTextDelivery,
349
+ });
350
+ } catch (err) {
351
+ log.error(
352
+ { err, eventId: event.id },
353
+ "Retry delivery failed for channel event",
354
+ );
377
355
  }
378
356
  }
379
357
  }
@@ -0,0 +1,72 @@
1
+ import { updateDeliveredSegmentCount } from "../memory/delivery-channels.js";
2
+ import {
3
+ markDeliveryDelivered,
4
+ recordDeliveryFailure,
5
+ } from "../memory/delivery-status.js";
6
+ import { deliverReplyViaCallback } from "./channel-reply-delivery.js";
7
+ import type { SlackDmTextDeliveryController } from "./slack-dm-text-delivery.js";
8
+
9
+ /**
10
+ * Owns the complete delivery-after-processing sequence for a channel
11
+ * inbound event: waits for in-flight live Slack DM deliveries to settle,
12
+ * persists the segment baseline so delivery-only retries resume correctly,
13
+ * delivers remaining content + attachments, and transitions the event to
14
+ * its terminal delivery state.
15
+ *
16
+ * Both the primary dispatch path and the processing-retry path call this
17
+ * function. The delivery-only retry path does NOT use this function — it
18
+ * reads the already-persisted segment count and calls
19
+ * `deliverReplyViaCallback` directly.
20
+ */
21
+ export async function finalizeEventDelivery(params: {
22
+ eventId: string;
23
+ conversationId: string;
24
+ externalChatId: string;
25
+ replyCallbackUrl: string;
26
+ assistantId: string | undefined;
27
+ replyMessageId: string | undefined;
28
+ userMessageId: string | undefined;
29
+ slackDmTextDelivery: SlackDmTextDeliveryController | undefined;
30
+ }): Promise<void> {
31
+ const {
32
+ eventId,
33
+ conversationId,
34
+ externalChatId,
35
+ replyCallbackUrl,
36
+ assistantId,
37
+ replyMessageId,
38
+ userMessageId,
39
+ slackDmTextDelivery,
40
+ } = params;
41
+
42
+ if (slackDmTextDelivery) {
43
+ await slackDmTextDelivery.waitForPendingDeliveries();
44
+ }
45
+
46
+ const resumeOptions =
47
+ slackDmTextDelivery?.getFinalDeliveryResumeOptions(replyMessageId);
48
+ const startFromSegment = resumeOptions?.startFromSegment ?? 0;
49
+ try {
50
+ updateDeliveredSegmentCount(eventId, startFromSegment);
51
+ await deliverReplyViaCallback(
52
+ conversationId,
53
+ externalChatId,
54
+ replyCallbackUrl,
55
+ assistantId,
56
+ {
57
+ messageId: replyMessageId,
58
+ sinceMessageId: userMessageId,
59
+ startFromSegment,
60
+ ...(resumeOptions?.messageTs
61
+ ? { messageTs: resumeOptions.messageTs }
62
+ : {}),
63
+ onSegmentDelivered: (count) =>
64
+ updateDeliveredSegmentCount(eventId, count),
65
+ },
66
+ );
67
+ markDeliveryDelivered(eventId);
68
+ } catch (err) {
69
+ recordDeliveryFailure(eventId, err);
70
+ throw err;
71
+ }
72
+ }
@@ -2,7 +2,11 @@
2
2
  * Channel delivery routes: delivery ack, dead letters, reply delivery,
3
3
  * and post-decision delivery scheduling.
4
4
  */
5
- import { acknowledgeDelivery, getDeadLetterEvents, replayDeadLetters } from "../../memory/delivery-status.js";
5
+ import {
6
+ acknowledgeDelivery,
7
+ getDeadLetterEvents,
8
+ replayDeadLetters,
9
+ } from "../../memory/delivery-status.js";
6
10
  import { BadRequestError, NotFoundError } from "./errors.js";
7
11
  import type { RouteHandlerArgs } from "./types.js";
8
12
 
@@ -10,6 +14,7 @@ export {
10
14
  type DeliverReplyOptions,
11
15
  deliverReplyViaCallback,
12
16
  } from "../channel-reply-delivery.js";
17
+ export { finalizeEventDelivery } from "../finalize-event-delivery.js";
13
18
 
14
19
  // ---------------------------------------------------------------------------
15
20
  // Dead letter management
@@ -36,12 +41,11 @@ export function handleReplayDeadLetters({ body = {} }: RouteHandlerArgs) {
36
41
  // ---------------------------------------------------------------------------
37
42
 
38
43
  export function handleChannelDeliveryAck({ body = {} }: RouteHandlerArgs) {
39
- const { sourceChannel, conversationExternalId, externalMessageId } =
40
- body as {
41
- sourceChannel?: string;
42
- conversationExternalId?: string;
43
- externalMessageId?: string;
44
- };
44
+ const { sourceChannel, conversationExternalId, externalMessageId } = body as {
45
+ sourceChannel?: string;
46
+ conversationExternalId?: string;
47
+ externalMessageId?: string;
48
+ };
45
49
 
46
50
  if (!sourceChannel || typeof sourceChannel !== "string") {
47
51
  throw new BadRequestError("sourceChannel is required");
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Static ROUTES array for channel endpoints.
3
3
  */
4
+ import { RuntimeInboundPayloadSchema } from "@vellumai/gateway-client";
5
+
4
6
  import { ACTOR_PRINCIPALS, GATEWAY_PRINCIPALS } from "../auth/route-policy.js";
5
7
  import {
6
8
  handleChannelDeliveryAck,
@@ -38,6 +40,7 @@ export const CHANNEL_ROUTES: RouteDefinition[] = [
38
40
  summary: "Process inbound channel message",
39
41
  description: "Receive an inbound message from a channel integration.",
40
42
  tags: ["channels"],
43
+ requestBody: RuntimeInboundPayloadSchema,
41
44
  handler: handleChannelInbound,
42
45
  },
43
46
  {
@@ -4,6 +4,8 @@
4
4
  * verification, guardian action answers, approval interception, and
5
5
  * invite token redemption.
6
6
  */
7
+ import type { SourceMetadata } from "@vellumai/gateway-client";
8
+
7
9
  import {
8
10
  attachmentsToContentBlocks,
9
11
  type MessageAttachmentInput,
@@ -127,16 +129,16 @@ function trimMetadataString(
127
129
 
128
130
  function parseSlackActorTimezoneMetadata(
129
131
  sourceChannel: string,
130
- metadata: Record<string, unknown> | undefined,
132
+ metadata: SourceMetadata | undefined,
131
133
  ): SlackActorTimezoneMetadata | undefined {
132
134
  if (sourceChannel !== "slack") return undefined;
133
135
 
134
- const timezone = trimMetadataString(metadata, "timezone");
135
- const timezoneLabel = trimMetadataString(metadata, "timezoneLabel");
136
- const rawOffset = metadata?.timezoneOffsetSeconds;
136
+ const timezone = metadata?.timezone?.trim() || undefined;
137
+ const timezoneLabel = metadata?.timezoneLabel?.trim() || undefined;
137
138
  const timezoneOffsetSeconds =
138
- typeof rawOffset === "number" && Number.isFinite(rawOffset)
139
- ? rawOffset
139
+ metadata?.timezoneOffsetSeconds != null &&
140
+ Number.isFinite(metadata.timezoneOffsetSeconds)
141
+ ? metadata.timezoneOffsetSeconds
140
142
  : undefined;
141
143
 
142
144
  if (
@@ -193,17 +195,16 @@ function resolveSlackTranscriptTimestampTimezone(
193
195
 
194
196
  function resolveInboundClientTimezone(params: {
195
197
  bodyClientTimezone?: unknown;
196
- sourceMetadata?: Record<string, unknown>;
198
+ sourceMetadata?: SourceMetadata;
197
199
  conversationId: string;
198
200
  }): string | undefined {
199
201
  const bodyClientTimezone =
200
202
  typeof params.bodyClientTimezone === "string"
201
203
  ? canonicalizeTimeZone(params.bodyClientTimezone)
202
204
  : undefined;
203
- const metadataClientTimezone =
204
- typeof params.sourceMetadata?.clientTimezone === "string"
205
- ? canonicalizeTimeZone(params.sourceMetadata.clientTimezone)
206
- : undefined;
205
+ const metadataClientTimezone = params.sourceMetadata?.clientTimezone
206
+ ? canonicalizeTimeZone(params.sourceMetadata.clientTimezone)
207
+ : undefined;
207
208
  return (
208
209
  bodyClientTimezone ??
209
210
  metadataClientTimezone ??
@@ -248,7 +249,7 @@ export async function handleChannelInbound({
248
249
  attachmentIds?: string[];
249
250
  actorExternalId?: string;
250
251
  actorUsername?: string;
251
- sourceMetadata?: Record<string, unknown>;
252
+ sourceMetadata?: SourceMetadata;
252
253
  replyCallbackUrl?: string;
253
254
  callbackQueryId?: string;
254
255
  callbackData?: string;
@@ -2,10 +2,9 @@
2
2
  * Ingress ACL enforcement stage: resolves the inbound actor to a member
3
3
  * record, enforces allow/deny/escalate policies, handles invite token
4
4
  * intercepts, and notifies the guardian of denied access requests.
5
- *
6
- * Extracted from inbound-message-handler.ts to keep the top-level handler
7
- * focused on orchestration.
8
5
  */
6
+ import type { SourceMetadata } from "@vellumai/gateway-client";
7
+
9
8
  import { isInviteCodeRedemptionEnabled } from "../../../channels/config.js";
10
9
  import type { ChannelId } from "../../../channels/types.js";
11
10
  import {
@@ -85,7 +84,7 @@ export interface AclEnforcementParams {
85
84
  conversationExternalId: string;
86
85
  canonicalAssistantId: string;
87
86
  trimmedContent: string;
88
- sourceMetadata: Record<string, unknown> | undefined;
87
+ sourceMetadata: SourceMetadata | undefined;
89
88
  actorDisplayName: string | undefined;
90
89
  actorUsername: string | undefined;
91
90
  replyCallbackUrl: string | undefined;
@@ -138,40 +137,21 @@ export async function enforceIngressAcl(
138
137
  } = params;
139
138
 
140
139
  // Trust signals from Slack users.info, forwarded via sourceMetadata.
141
- const isStranger = sourceMetadata?.isStranger === true ? true : undefined;
142
- const isRestricted = sourceMetadata?.isRestricted === true ? true : undefined;
140
+ const isStranger = sourceMetadata?.isStranger ?? undefined;
141
+ const isRestricted = sourceMetadata?.isRestricted ?? undefined;
143
142
 
144
143
  // Slack message timestamp for permalink construction.
145
- const messageTs =
146
- typeof sourceMetadata?.messageId === "string"
147
- ? sourceMetadata.messageId
148
- : undefined;
144
+ const messageTs = sourceMetadata?.messageId ?? undefined;
149
145
 
150
146
  let resolvedMember: ResolvedMember | null = null;
151
147
 
152
148
  // /start gv_<token> bootstrap commands must also bypass ACL — the user
153
149
  // hasn't been verified yet and needs to complete the bootstrap handshake.
154
- const rawCommandIntentForAcl = sourceMetadata?.commandIntent;
150
+ const commandIntentForAcl = sourceMetadata?.commandIntent;
155
151
  const isBootstrapCommand =
156
- rawCommandIntentForAcl &&
157
- typeof rawCommandIntentForAcl === "object" &&
158
- !Array.isArray(rawCommandIntentForAcl) &&
159
- (rawCommandIntentForAcl as Record<string, unknown>).type === "start" &&
160
- typeof (rawCommandIntentForAcl as Record<string, unknown>).payload ===
161
- "string" &&
162
- (
163
- (rawCommandIntentForAcl as Record<string, unknown>).payload as string
164
- ).startsWith("gv_");
165
-
166
- // Parse invite token from /start payloads using the channel transport
167
- // adapter. The token is extracted once here so both the ACL bypass and
168
- // the intercept handler can reference it without re-parsing.
169
- const commandIntentForAcl =
170
- rawCommandIntentForAcl &&
171
- typeof rawCommandIntentForAcl === "object" &&
172
- !Array.isArray(rawCommandIntentForAcl)
173
- ? (rawCommandIntentForAcl as Record<string, unknown>)
174
- : undefined;
152
+ commandIntentForAcl?.type === "start" &&
153
+ typeof commandIntentForAcl.payload === "string" &&
154
+ commandIntentForAcl.payload.startsWith("gv_");
175
155
  const inviteAdapter = getInviteAdapterRegistry().get(sourceChannel);
176
156
  const inviteToken = inviteAdapter?.extractInboundToken?.({
177
157
  commandIntent: commandIntentForAcl,
@@ -205,9 +185,7 @@ export async function enforceIngressAcl(
205
185
  // any `/start gv_<garbage>` would bypass the not_a_member gate and
206
186
  // fall through to normal /start processing.
207
187
  if (isBootstrapCommand) {
208
- const bootstrapPayload = (
209
- rawCommandIntentForAcl as Record<string, unknown>
210
- ).payload as string;
188
+ const bootstrapPayload = commandIntentForAcl!.payload!;
211
189
  const bootstrapTokenForAcl = bootstrapPayload.slice(3); // strip 'gv_' prefix
212
190
  const bootstrapSessionForAcl = resolveBootstrapToken(
213
191
  sourceChannel,
@@ -476,9 +454,7 @@ export async function enforceIngressAcl(
476
454
  // (pending/revoked), but never for blocked members.
477
455
  let denyInactiveMember = true;
478
456
  if (!isBlockedMember && isBootstrapCommand) {
479
- const bootstrapPayload = (
480
- rawCommandIntentForAcl as Record<string, unknown>
481
- ).payload as string;
457
+ const bootstrapPayload = commandIntentForAcl!.payload!;
482
458
  const bootstrapTokenForAcl = bootstrapPayload.slice(3);
483
459
  const bootstrapSessionForAcl = resolveBootstrapToken(
484
460
  sourceChannel,