@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.
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +105 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +12 -0
- package/openapi.yaml +99 -0
- package/package.json +1 -1
- package/src/__tests__/app-compiler.test.ts +7 -1
- package/src/__tests__/app-executors.test.ts +43 -0
- package/src/__tests__/conversation-surfaces-data-persist.test.ts +97 -0
- package/src/__tests__/dynamic-page-surface.test.ts +94 -0
- package/src/__tests__/mock-gateway-ipc.ts +23 -0
- package/src/__tests__/resolve-app-id.test.ts +56 -0
- package/src/bundler/package-resolver.ts +0 -1
- package/src/config/bundled-skills/app-builder/SKILL.md +1 -1
- package/src/config/bundled-skills/app-builder/tools/app-create.ts +6 -1
- package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +7 -1
- package/src/config/bundled-skills/app-builder/tools/app-refresh.ts +7 -1
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +10 -1
- package/src/daemon/conversation-surfaces.ts +92 -3
- package/src/notifications/home-feed-side-effect.ts +27 -10
- package/src/runtime/channel-invite-transports/telegram.ts +6 -5
- package/src/runtime/channel-invite-transports/voice.ts +2 -2
- package/src/runtime/channel-invite-types.ts +4 -2
- package/src/runtime/channel-retry-sweep.ts +19 -41
- package/src/runtime/finalize-event-delivery.ts +72 -0
- package/src/runtime/routes/channel-delivery-routes.ts +11 -7
- package/src/runtime/routes/channel-route-definitions.ts +3 -0
- package/src/runtime/routes/inbound-message-handler.ts +13 -12
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +12 -36
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +7 -5
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -21
- package/src/runtime/routes/inbound-stages/escalation-intercept.ts +5 -5
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +6 -15
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +1 -1
- package/src/schedule/schedule-store.ts +30 -0
- package/src/tools/apps/executors.ts +23 -0
- package/src/tools/apps/resolve-app-id.ts +42 -0
- 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
|
-
|
|
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
|
-
|
|
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)
|
|
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 } =
|
|
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
|
-
|
|
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:
|
|
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?:
|
|
135
|
+
commandIntent?: CommandIntent;
|
|
134
136
|
content: string;
|
|
135
|
-
sourceMetadata?:
|
|
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.
|
|
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?:
|
|
43
|
+
commandIntent?: import("@vellumai/gateway-client").CommandIntent;
|
|
44
44
|
content: string;
|
|
45
|
-
sourceMetadata?:
|
|
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?:
|
|
36
|
+
commandIntent?: CommandIntent;
|
|
35
37
|
content: string;
|
|
36
|
-
sourceMetadata?:
|
|
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
|
-
|
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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 {
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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:
|
|
132
|
+
metadata: SourceMetadata | undefined,
|
|
131
133
|
): SlackActorTimezoneMetadata | undefined {
|
|
132
134
|
if (sourceChannel !== "slack") return undefined;
|
|
133
135
|
|
|
134
|
-
const timezone =
|
|
135
|
-
const 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
|
-
|
|
139
|
-
|
|
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?:
|
|
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
|
-
|
|
205
|
-
|
|
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?:
|
|
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:
|
|
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
|
|
142
|
-
const isRestricted = sourceMetadata?.isRestricted
|
|
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
|
|
150
|
+
const commandIntentForAcl = sourceMetadata?.commandIntent;
|
|
155
151
|
const isBootstrapCommand =
|
|
156
|
-
|
|
157
|
-
typeof
|
|
158
|
-
|
|
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,
|