@vellumai/vellum-gateway 0.10.11-dev.202607222129.31c06e5 → 0.10.11-dev.202607222325.35fc367

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.10.11-dev.202607222129.31c06e5",
3
+ "version": "0.10.11-dev.202607222325.35fc367",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -728,7 +728,7 @@ describe("graceful fallback when cache not initialized", () => {
728
728
  });
729
729
 
730
730
  describe("SkillLoadRiskClassifier inline command risk elevation", () => {
731
- test("skill with inline expansions is classified as medium risk", async () => {
731
+ test("skill with inline expansions is classified as high risk", async () => {
732
732
  const classifier = new SkillLoadRiskClassifier();
733
733
  const result = await classifier.classify({
734
734
  toolName: "skill_load",
@@ -743,7 +743,7 @@ describe("SkillLoadRiskClassifier inline command risk elevation", () => {
743
743
  },
744
744
  });
745
745
 
746
- expect(result.riskLevel).toBe("medium");
746
+ expect(result.riskLevel).toBe("high");
747
747
  expect(result.reason).toContain("inline command expansions");
748
748
  });
749
749
 
@@ -260,6 +260,32 @@ describe("telegram webhook handler: gatewayInternalBaseUrl", () => {
260
260
  "http://127.0.0.1:7830/deliver/telegram",
261
261
  );
262
262
  });
263
+
264
+ test("topic messages append threadId to the replyCallbackUrl", async () => {
265
+ const config = makeConfig({
266
+ gatewayInternalBaseUrl: "http://gateway.internal:9000",
267
+ routingEntries: [
268
+ { type: "conversation_id", key: "12345", assistantId: "assistant-a" },
269
+ ],
270
+ });
271
+ installFetchMock();
272
+ const { handler } = createTelegramWebhookHandler(config, makeCaches());
273
+
274
+ const base = makeTelegramPayload("hello topic", 2002);
275
+ const payload = {
276
+ ...base,
277
+ message: { ...base.message, message_thread_id: 777 },
278
+ };
279
+ const res = await handler(makeWebhookRequest(payload));
280
+
281
+ expect(res.status).toBe(200);
282
+
283
+ const runtimeCall = fetchCalls.find((c) => c.url.includes("/inbound"));
284
+ expect(runtimeCall).toBeDefined();
285
+ expect((runtimeCall!.body as any).replyCallbackUrl).toBe(
286
+ "http://gateway.internal:9000/deliver/telegram?threadId=777",
287
+ );
288
+ });
263
289
  });
264
290
 
265
291
  describe("telegram webhook handler: /new rejection", () => {
@@ -293,10 +293,31 @@ export function createTelegramWebhookHandler(
293
293
  chatId: normalized.message.conversationExternalId,
294
294
  messageId: normalized.message.externalMessageId,
295
295
  updateId,
296
+ threadId: normalized.source.threadId,
296
297
  },
297
298
  "Webhook received",
298
299
  );
299
300
 
301
+ // Private-chat topic scoping: when the inbound message belongs to a topic,
302
+ // the reply callback URL carries the thread id (the Telegram analog of
303
+ // Slack's `?threadTs=`) so the runtime's transport echoes it on outbound
304
+ // sends, and the gateway's own direct replies target the same topic.
305
+ // Messages outside a topic keep the bare URL and thread-less sends.
306
+ const topicThreadId = normalized.source.threadId;
307
+ const threadOpts = topicThreadId
308
+ ? { messageThreadId: topicThreadId }
309
+ : undefined;
310
+ // Every gateway direct reply in this handler targets the same topic (or
311
+ // main chat) as the inbound message — one decision, made once.
312
+ const replyOpts = {
313
+ credentials: caches?.credentials,
314
+ configFile: caches?.configFile,
315
+ ...threadOpts,
316
+ };
317
+ const replyCallbackUrl = topicThreadId
318
+ ? `${config.gatewayInternalBaseUrl}/deliver/telegram?${new URLSearchParams({ threadId: topicThreadId })}`
319
+ : `${config.gatewayInternalBaseUrl}/deliver/telegram`;
320
+
300
321
  // Handle /start command — forward to runtime as a channel command intent
301
322
  const startCmd = parseTelegramStartCommand(normalized.message.content);
302
323
  if (startCmd !== null) {
@@ -322,10 +343,7 @@ export function createTelegramWebhookHandler(
322
343
  normalized.message.conversationExternalId,
323
344
  "\u26a0\ufe0f This bot is not fully set up yet. Please check the gateway configuration.",
324
345
  undefined,
325
- {
326
- credentials: caches?.credentials,
327
- configFile: caches?.configFile,
328
- },
346
+ replyOpts,
329
347
  ).catch((err) => {
330
348
  tlog.error(
331
349
  { err, chatId: normalized.message.conversationExternalId },
@@ -350,7 +368,7 @@ export function createTelegramWebhookHandler(
350
368
  normalized.message.conversationExternalId,
351
369
  START_COMMAND_ACK_TEXT,
352
370
  undefined,
353
- { credentials: caches?.credentials },
371
+ replyOpts,
354
372
  ).catch((err) => {
355
373
  tlog.error(
356
374
  { err, chatId: normalized.message.conversationExternalId },
@@ -362,7 +380,7 @@ export function createTelegramWebhookHandler(
362
380
  try {
363
381
  const result = await handleInbound(config, normalized, {
364
382
  transportMetadata: buildTelegramTransportMetadata(),
365
- replyCallbackUrl: `${config.gatewayInternalBaseUrl}/deliver/telegram`,
383
+ replyCallbackUrl,
366
384
  traceId,
367
385
  sourceMetadata: {
368
386
  commandIntent: {
@@ -391,10 +409,7 @@ export function createTelegramWebhookHandler(
391
409
  normalized.message.conversationExternalId,
392
410
  "\u26a0\ufe0f This bot is not fully set up yet. Please check the gateway configuration.",
393
411
  undefined,
394
- {
395
- credentials: caches?.credentials,
396
- configFile: caches?.configFile,
397
- },
412
+ replyOpts,
398
413
  ).catch((err) => {
399
414
  tlog.error(
400
415
  { err, chatId: normalized.message.conversationExternalId },
@@ -414,10 +429,7 @@ export function createTelegramWebhookHandler(
414
429
  normalized.message.conversationExternalId,
415
430
  "Welcome! I'm having a brief setup hiccup. Please try again in a moment.",
416
431
  undefined,
417
- {
418
- credentials: caches?.credentials,
419
- configFile: caches?.configFile,
420
- },
432
+ replyOpts,
421
433
  ).catch((err) => {
422
434
  tlog.error({ err }, "Failed to send /start fallback reply");
423
435
  });
@@ -437,10 +449,7 @@ export function createTelegramWebhookHandler(
437
449
  normalized.message.conversationExternalId,
438
450
  startRuntimeResp.replyText,
439
451
  undefined,
440
- {
441
- credentials: caches?.credentials,
442
- configFile: caches?.configFile,
443
- },
452
+ replyOpts,
444
453
  ).catch((err) => {
445
454
  tlog.error(
446
455
  { err, chatId: normalized.message.conversationExternalId },
@@ -479,7 +488,7 @@ export function createTelegramWebhookHandler(
479
488
  normalized.message.conversationExternalId,
480
489
  "Welcome! I'm having a brief setup hiccup. Please try again in a moment.",
481
490
  undefined,
482
- { credentials: caches?.credentials },
491
+ replyOpts,
483
492
  ).catch((replyErr) => {
484
493
  tlog.error({ err: replyErr }, "Failed to send /start error fallback");
485
494
  });
@@ -516,10 +525,7 @@ export function createTelegramWebhookHandler(
516
525
  normalized.message.conversationExternalId,
517
526
  `\u26a0\ufe0f ${ROUTING_REJECTION_NOTICE}`,
518
527
  undefined,
519
- {
520
- credentials: caches?.credentials,
521
- configFile: caches?.configFile,
522
- },
528
+ replyOpts,
523
529
  ).catch((err) => {
524
530
  tlog.error(
525
531
  { err, chatId: normalized.message.conversationExternalId },
@@ -538,13 +544,11 @@ export function createTelegramWebhookHandler(
538
544
  normalized.message.conversationExternalId,
539
545
  text,
540
546
  undefined,
541
- {
542
- credentials: caches?.credentials,
543
- configFile: caches?.configFile,
544
- },
547
+ replyOpts,
545
548
  );
546
549
  },
547
550
  tlog,
551
+ topicThreadId,
548
552
  );
549
553
  }
550
554
 
@@ -689,7 +693,7 @@ export function createTelegramWebhookHandler(
689
693
  const result = await handleInbound(config, normalized, {
690
694
  attachmentIds,
691
695
  transportMetadata: buildTelegramTransportMetadata(),
692
- replyCallbackUrl: `${config.gatewayInternalBaseUrl}/deliver/telegram`,
696
+ replyCallbackUrl,
693
697
  traceId,
694
698
  });
695
699
 
@@ -704,10 +708,7 @@ export function createTelegramWebhookHandler(
704
708
  chatId,
705
709
  `\u26a0\ufe0f ${ROUTING_REJECTION_NOTICE}`,
706
710
  undefined,
707
- {
708
- credentials: caches?.credentials,
709
- configFile: caches?.configFile,
710
- },
711
+ replyOpts,
711
712
  ).catch((err) => {
712
713
  tlog.error(
713
714
  { err, chatId },
@@ -750,10 +751,13 @@ export function createTelegramWebhookHandler(
750
751
  if (runtimeResp?.denied && runtimeResp.replyText) {
751
752
  const msgSender = normalized.actor.actorExternalId ?? chatId;
752
753
  if (recordDenialReplyIfAllowed("telegram", msgSender)) {
753
- sendTelegramReply(config, chatId, runtimeResp.replyText, undefined, {
754
- credentials: caches?.credentials,
755
- configFile: caches?.configFile,
756
- }).catch((err) => {
754
+ sendTelegramReply(
755
+ config,
756
+ chatId,
757
+ runtimeResp.replyText,
758
+ undefined,
759
+ replyOpts,
760
+ ).catch((err) => {
757
761
  tlog.error(
758
762
  { err, chatId },
759
763
  "Failed to send ACL denial fallback reply",
@@ -145,6 +145,10 @@ describe("allowlistOptions", () => {
145
145
  skillSelector: "dynamic-skill",
146
146
  resolvedMetadata: metadata,
147
147
  });
148
+ expect(result.riskLevel).toBe("high");
149
+ expect(result.reason).toBe(
150
+ "Skill load with inline command expansions (executes shell commands at load time)",
151
+ );
148
152
  expect(result.allowlistOptions).toEqual([
149
153
  {
150
154
  label: "dynamic-skill@trans789",
@@ -178,13 +178,15 @@ export class SkillLoadRiskClassifier implements RiskClassifier<SkillClassifierIn
178
178
  switch (toolName) {
179
179
  case "skill_load": {
180
180
  // Skills with inline command expansions execute shell commands at load
181
- // time via child_process.spawn, bypassing the normal bash-tool
182
- // permission pipeline. Elevate to medium so the default auto-approve
183
- // threshold (low) requires an explicit prompt instead of silently
184
- // running embedded commands.
181
+ // time via child_process.spawn, outside the normal bash-tool permission
182
+ // pipeline. Classify as high so the standard auto-approve threshold
183
+ // governs them like any other high-risk action: they auto-approve only
184
+ // at Full access (autoApproveUpTo "high") and prompt at every level
185
+ // below it. A user trust rule that covers the load lowers this
186
+ // downstream (arriving as matchType "user_rule").
185
187
  const hasExpansions = resolvedMetadata?.hasInlineExpansions === true;
186
188
  assessment = {
187
- riskLevel: hasExpansions ? "medium" : "low",
189
+ riskLevel: hasExpansions ? "high" : "low",
188
190
  reason: hasExpansions
189
191
  ? "Skill load with inline command expansions (executes shell commands at load time)"
190
192
  : "Skill load (default)",
@@ -25,6 +25,74 @@ function makeCallbackQueryPayload(overrides?: {
25
25
  };
26
26
  }
27
27
 
28
+ describe("normalizeTelegramUpdate — private-chat topics", () => {
29
+ it("maps message_thread_id to source.threadId", () => {
30
+ const result = normalizeTelegramUpdate({
31
+ update_id: 500,
32
+ message: {
33
+ message_id: 50,
34
+ message_thread_id: 777,
35
+ text: "hello topic",
36
+ chat: { id: 42, type: "private" },
37
+ from: { id: 42, first_name: "Alice" },
38
+ },
39
+ });
40
+
41
+ expect(result).not.toBeNull();
42
+ expect(result!.source.threadId).toBe("777");
43
+ expect(result!.message.content).toBe("hello topic");
44
+ });
45
+
46
+ it("leaves source.threadId undefined for messages outside a topic", () => {
47
+ const result = normalizeTelegramUpdate({
48
+ update_id: 501,
49
+ message: {
50
+ message_id: 51,
51
+ text: "plain dm",
52
+ chat: { id: 42, type: "private" },
53
+ from: { id: 42, first_name: "Alice" },
54
+ },
55
+ });
56
+
57
+ expect(result).not.toBeNull();
58
+ expect(result!.source.threadId).toBeUndefined();
59
+ });
60
+
61
+ it("maps callback_query message_thread_id to source.threadId", () => {
62
+ const result = normalizeTelegramUpdate({
63
+ update_id: 502,
64
+ callback_query: {
65
+ id: "cbq-topic",
66
+ from: { id: 42, first_name: "Alice" },
67
+ message: {
68
+ message_id: 52,
69
+ message_thread_id: 777,
70
+ chat: { id: 42, type: "private" },
71
+ },
72
+ data: "apr:run1:approve",
73
+ },
74
+ });
75
+
76
+ expect(result).not.toBeNull();
77
+ expect(result!.source.threadId).toBe("777");
78
+ });
79
+
80
+ it("still rejects group messages even when they carry message_thread_id", () => {
81
+ const result = normalizeTelegramUpdate({
82
+ update_id: 503,
83
+ message: {
84
+ message_id: 53,
85
+ message_thread_id: 777,
86
+ text: "forum topic message",
87
+ chat: { id: -100123, type: "supergroup" },
88
+ from: { id: 42, first_name: "Alice" },
89
+ },
90
+ });
91
+
92
+ expect(result).toBeNull();
93
+ });
94
+ });
95
+
28
96
  describe("normalizeTelegramUpdate — callback_query DM-only guard", () => {
29
97
  it("accepts callback_query from private chat", () => {
30
98
  const result = normalizeTelegramUpdate(
@@ -37,6 +37,7 @@ interface TelegramAudio {
37
37
 
38
38
  interface TelegramMessage {
39
39
  message_id?: number;
40
+ message_thread_id?: number;
40
41
  text?: string;
41
42
  caption?: string;
42
43
  chat?: { id?: number; type?: string };
@@ -54,6 +55,17 @@ interface TelegramMessage {
54
55
  audio?: TelegramAudio;
55
56
  }
56
57
 
58
+ /**
59
+ * Topic thread id of a private-chat message, as a string, or undefined for
60
+ * messages outside a topic. Callers run after the DM-only guard, so a thread
61
+ * id here always identifies a private-chat topic.
62
+ */
63
+ function threadIdFromMessage(message: TelegramMessage): string | undefined {
64
+ return message.message_thread_id != null
65
+ ? String(message.message_thread_id)
66
+ : undefined;
67
+ }
68
+
57
69
  interface TelegramCallbackQuery {
58
70
  id: string;
59
71
  from: {
@@ -114,6 +126,7 @@ export function normalizeTelegramUpdate(
114
126
  }
115
127
 
116
128
  const actorExternalId = String(cbq.from.id);
129
+ const callbackThreadId = threadIdFromMessage(cbq.message);
117
130
 
118
131
  const displayName = [cbq.from?.first_name, cbq.from?.last_name]
119
132
  .filter(Boolean)
@@ -147,6 +160,7 @@ export function normalizeTelegramUpdate(
147
160
  ? String(cbq.message.message_id)
148
161
  : undefined,
149
162
  chatType: cbq.message.chat.type,
163
+ ...(callbackThreadId ? { threadId: callbackThreadId } : {}),
150
164
  },
151
165
  raw: payload,
152
166
  };
@@ -183,6 +197,8 @@ export function normalizeTelegramUpdate(
183
197
  .join(" ")
184
198
  .trim();
185
199
 
200
+ const topicThreadId = threadIdFromMessage(message);
201
+
186
202
  const content = message.text || message.caption || "";
187
203
 
188
204
  const attachments: {
@@ -253,6 +269,7 @@ export function normalizeTelegramUpdate(
253
269
  messageId:
254
270
  message.message_id != null ? String(message.message_id) : undefined,
255
271
  chatType: message.chat.type,
272
+ ...(topicThreadId ? { threadId: topicThreadId } : {}),
256
273
  },
257
274
  raw: payload,
258
275
  };
@@ -50,9 +50,15 @@ export async function sendTelegramReply(
50
50
  chatId: string,
51
51
  text: string,
52
52
  approval?: ApprovalUIMetadata,
53
- opts?: { credentials?: CredentialCache; configFile?: ConfigFileCache },
53
+ opts?: {
54
+ credentials?: CredentialCache;
55
+ configFile?: ConfigFileCache;
56
+ /** Private-chat topic to send into; omitted → main chat. */
57
+ messageThreadId?: string;
58
+ },
54
59
  ): Promise<void> {
55
60
  const chunks = splitText(text, TELEGRAM_MAX_MESSAGE_LEN);
61
+ const messageThreadId = opts?.messageThreadId?.trim();
56
62
 
57
63
  for (let i = 0; i < chunks.length; i++) {
58
64
  const payload: Record<string, unknown> = {
@@ -60,6 +66,13 @@ export async function sendTelegramReply(
60
66
  text: chunks[i],
61
67
  };
62
68
 
69
+ // message_thread_id is the Bot API topic field for both forum supergroups
70
+ // and private chats of bots with topic ("threaded") mode enabled.
71
+ // (direct_messages_topic_id is a different surface — channel monoforums.)
72
+ if (messageThreadId) {
73
+ payload.message_thread_id = Number(messageThreadId);
74
+ }
75
+
63
76
  // Attach inline keyboard only to the last chunk so buttons appear after
64
77
  // the full message text.
65
78
  if (approval && i === chunks.length - 1) {