@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 +1 -1
- package/src/__tests__/nonbash-trust-rule-overrides.test.ts +2 -2
- package/src/__tests__/telegram-webhook-handler.test.ts +26 -0
- package/src/http/routes/telegram-webhook.ts +40 -36
- package/src/risk/skill-risk-classifier.test.ts +4 -0
- package/src/risk/skill-risk-classifier.ts +7 -5
- package/src/telegram/normalize.test.ts +68 -0
- package/src/telegram/normalize.ts +17 -0
- package/src/telegram/send.ts +14 -1
package/package.json
CHANGED
|
@@ -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
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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,
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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 ? "
|
|
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
|
};
|
package/src/telegram/send.ts
CHANGED
|
@@ -50,9 +50,15 @@ export async function sendTelegramReply(
|
|
|
50
50
|
chatId: string,
|
|
51
51
|
text: string,
|
|
52
52
|
approval?: ApprovalUIMetadata,
|
|
53
|
-
opts?: {
|
|
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) {
|