@vellumai/vellum-gateway 0.12.2-staging.7 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/AGENTS.md +1 -3
  2. package/README.md +1 -1
  3. package/node_modules/@vellumai/environments/src/shell.test.ts +0 -21
  4. package/node_modules/@vellumai/environments/src/shell.ts +0 -24
  5. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +2 -8
  6. package/package.json +1 -1
  7. package/src/__tests__/telegram-normalize.test.ts +111 -123
  8. package/src/__tests__/telegram-webhook-handler.test.ts +0 -160
  9. package/src/channels/inbound-event.ts +0 -9
  10. package/src/discord/admission-log.test.ts +12 -15
  11. package/src/discord/admission-log.ts +95 -11
  12. package/src/discord/admit.test.ts +9 -8
  13. package/src/discord/admit.ts +93 -48
  14. package/src/discord/gateway-socket.ts +2 -4
  15. package/src/discord/normalize.test.ts +1 -34
  16. package/src/discord/normalize.ts +3 -23
  17. package/src/handlers/handle-inbound.ts +0 -3
  18. package/src/http/routes/telegram-webhook.ts +9 -50
  19. package/src/risk/bash-risk-classifier.test.ts +0 -63
  20. package/src/risk/command-registry/commands/dig.ts +1 -2
  21. package/src/risk/command-registry/commands/host.ts +1 -2
  22. package/src/risk/command-registry/commands/mtr.ts +1 -2
  23. package/src/risk/command-registry/commands/nslookup.ts +1 -2
  24. package/src/risk/command-registry/commands/ping.ts +1 -2
  25. package/src/risk/command-registry/commands/tracepath.ts +1 -2
  26. package/src/risk/command-registry/commands/traceroute.ts +1 -2
  27. package/src/risk/risk-types.ts +0 -1
  28. package/src/risk/shell-parser.test.ts +1 -82
  29. package/src/risk/shell-parser.ts +2 -50
  30. package/src/slack/message-normalizer.ts +1 -14
  31. package/src/slack/source-metadata.test.ts +2 -5
  32. package/src/slack/source-metadata.ts +6 -2
  33. package/src/telegram/normalize.test.ts +134 -343
  34. package/src/telegram/normalize.ts +197 -197
  35. package/src/telegram/send.ts +1 -1
  36. package/src/__tests__/telegram-webhook-drop-visibility.test.ts +0 -229
  37. package/src/channels/admission-drop-log.test.ts +0 -47
  38. package/src/channels/admission-drop-log.ts +0 -94
  39. package/src/channels/room-admission.test.ts +0 -95
  40. package/src/channels/room-admission.ts +0 -149
  41. package/src/telegram/admit.test.ts +0 -155
  42. package/src/telegram/admit.ts +0 -149
  43. package/src/telegram/bot-identity.test.ts +0 -124
  44. package/src/telegram/bot-identity.ts +0 -127
  45. package/src/telegram/drop-log.ts +0 -41
  46. package/src/telegram/schemas.ts +0 -217
package/AGENTS.md CHANGED
@@ -90,8 +90,6 @@ Gateway inbound events use a channel-discriminated union model (`GatewayInboundE
90
90
 
91
91
  Trust/guardian decisions must be keyed on `actorExternalId` only — never fall back to `conversationExternalId` for actor identity.
92
92
 
93
- **Room admission is one verdict, two adapter halves.** Whether the gateway acts on an inbound message at all is decided by `channels/room-admission.ts` over a neutral candidate: self and bot authors drop first, a direct chat is admitted on its own lane without a mention, a room only when the message addresses the bot, a chat kind the product does not serve is refused, and an unknown bot identity drops rather than admits. Each channel builds the candidate from its own facts (`discord/admit.ts`, `telegram/admit.ts`): how the platform proves a chat is direct (guild absence, `chat.type`) and how it proves a message is addressed (a mentions array, text entities, a reply to the bot's post). Extend the verdict, never a copy of it; a platform-only rule rides a neutral fact (Discord's legacy allow-list is `roomAllowed`). A drop is never silent: every channel logs it through `channels/admission-drop-log.ts`, one promoted line per reason and conversation, with a drop that names no conversation promoted every time. Slack's socket filter predates this and still carries the same rules inline; converging it is queued with the Slack envelope work.
94
-
95
93
  Physical DB column names (`externalUserId`, `externalChatId`) are unchanged; the rename is at the API/type layer only.
96
94
 
97
95
  **Provider words that are not our words.** A Discord **guild** is what Discord's
@@ -195,7 +193,7 @@ Both the flat (`/v1/channel-admission-policy/...`) and assistant-scoped (`/v1/as
195
193
 
196
194
  - **Gateway kill switch** — `handle-inbound.ts` enforces the `no_one` floor before forwarding. Zero contact-table lookups, zero daemon I/O, true kill.
197
195
  - **Runtime floor** — every other policy flows through the gateway unchanged; the runtime evaluates rank-vs-floor inside `admission-policy.ts`. This keeps the canonical gateway classifier (`gateway/src/risk/trust-verdict-resolver.ts`) as the single source of `TrustClass` truth (no fork): the runtime consumes the stamped verdict; the daemon's `actor-trust-resolver.ts` is only a residual sync guardian-or-unknown view for the vellum reset-drift path.
198
- - **Gateway vs runtime reciprocity**: this section records _which channels the gateway enforces_; the runtime's evaluation lives in `assistant/src/runtime/routes/inbound-stages/admission-policy.ts`, which reads `TRUST_CLASS_RANK` and `ADMISSION_FLOOR` from the shared contract. Either side getting out of sync is a bug, not an over-defended boundary.
196
+ - **Gateway vs runtime reciprocity** — the gateway section in `gateway/CLAUDE.md` records _which channels the gateway enforces_; the assistant section records _how the runtime classifies_. Either side getting out of sync is a bug, not an over-defended boundary.
199
197
 
200
198
  **Adding a new policy**: extend the `AdmissionPolicy` union in `packages/gateway-client/src/admission-policy-contract.ts`, add its floor in `ADMISSION_FLOOR`, update the openapi schema, and update `gateway/src/__tests__/channel-admission-policy-routes.test.ts` + `assistant/src/runtime/routes/inbound-stages/admission-policy.test.ts`. Do not add a 6th floor without also bumping the `TRUST_CLASS_RANK` ceiling to match.
201
199
 
package/README.md CHANGED
@@ -106,7 +106,7 @@ The gateway normalizes Telegram `callback_query` updates (inline button clicks)
106
106
 
107
107
  These fields are forwarded to the runtime in the `/channels/inbound` payload alongside the standard `conversationExternalId`, `externalMessageId`, and actor metadata. The runtime uses `callbackData` to route the click to the appropriate approval handler.
108
108
 
109
- **Normalization constraints:** Callback queries from private chats, groups, and supergroups are processed; a tap on a keyboard the bot posted is addressed to the bot by construction, and who may press it is the runtime's decision. Callbacks from broadcast channels are dropped and acknowledged with `answerCallbackQuery` so the Telegram button spinner clears. Callback queries with no `data` field or no associated `message` are also dropped. Every drop is logged with its reason before the update is acknowledged (`telegram/drop-log.ts`).
109
+ **Normalization constraints:** Only DM-only (`private` chat type) callback queries are processed. Group and channel callbacks are dropped and acknowledged with `answerCallbackQuery` so the Telegram button spinner clears. Callback queries with no `data` field or no associated `message` are also dropped.
110
110
 
111
111
  **Stale callback blocking:** When the runtime receives `callbackData` that does not match any pending approval (e.g., a button from an old prompt), it returns `stale_ignored` and does not process the payload as a regular message. This is enforced regardless of whether the callback has non-empty content. The gateway sends a best-effort `answerCallbackQuery` acknowledgment for normalized callback updates (including stale, rejected, and forward-failure paths) so the button spinner clears promptly. Transient forwarding failures may still return `500` so Telegram retries update delivery.
112
112
 
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  buildShellInvocation,
5
- buildShellSpawnFlags,
6
5
  pathListDelimiter,
7
6
  prependUniquePathEntries,
8
7
  } from "./shell.js";
@@ -39,26 +38,6 @@ describe("buildShellInvocation", () => {
39
38
  });
40
39
  });
41
40
 
42
- describe("buildShellSpawnFlags", () => {
43
- test("creates a POSIX process group and hides Windows consoles", () => {
44
- expect(buildShellSpawnFlags("linux")).toEqual({
45
- detached: true,
46
- windowsHide: true,
47
- });
48
- expect(buildShellSpawnFlags("darwin")).toEqual({
49
- detached: true,
50
- windowsHide: true,
51
- });
52
- });
53
-
54
- test("does not detach Windows children that use piped stdio", () => {
55
- expect(buildShellSpawnFlags("win32")).toEqual({
56
- detached: false,
57
- windowsHide: true,
58
- });
59
- });
60
- });
61
-
62
41
  describe("path list handling", () => {
63
42
  test("uses the platform delimiter", () => {
64
43
  expect(pathListDelimiter("win32")).toBe(";");
@@ -5,11 +5,6 @@ export interface ShellInvocation {
5
5
  args: string[];
6
6
  }
7
7
 
8
- export interface ShellSpawnFlags {
9
- detached: boolean;
10
- windowsHide: true;
11
- }
12
-
13
8
  const WINDOWS_UTF8_PREAMBLE =
14
9
  "try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}; " +
15
10
  "$OutputEncoding = [System.Text.Encoding]::UTF8; " +
@@ -48,25 +43,6 @@ export function buildShellInvocation(
48
43
  return { command: "bash", args: ["-c", "--", command] };
49
44
  }
50
45
 
51
- /**
52
- * Spawn flags for assistant-owned shell children (sandbox bash, local
53
- * host_bash fallback, sanitized CLI bash, skill runners, scheduled scripts).
54
- *
55
- * POSIX uses a new process group so timeout/abort can SIGKILL the tree via
56
- * `-pid`. Windows process trees are torn down with `taskkill /T`, which does
57
- * not need a detached process. Combining `DETACHED_PROCESS`,
58
- * `CREATE_NO_WINDOW`, and piped stdio on Windows can emit `close` with exit
59
- * 0 and empty pipes without running the encoded command.
60
- */
61
- export function buildShellSpawnFlags(
62
- hostPlatform: NodeJS.Platform = process.platform,
63
- ): ShellSpawnFlags {
64
- return {
65
- detached: hostPlatform !== "win32",
66
- windowsHide: true,
67
- };
68
- }
69
-
70
46
  export function pathListDelimiter(
71
47
  hostPlatform: NodeJS.Platform = process.platform,
72
48
  ): string {
@@ -83,14 +83,8 @@ export const SourceMetadataSchema = z
83
83
  clientTimezone: z.string().optional(),
84
84
  /** Channel command intent (e.g. Telegram /start). */
85
85
  commandIntent: CommandIntentSchema.optional(),
86
- /**
87
- * Whether the message addresses the assistant by name, an @-mention on
88
- * the platforms that have one. Stated by the channel's normalizer only
89
- * where it proved the answer; absent means "not established". Read to
90
- * decide whether a reply is expected before any text exists, never to
91
- * admit or deny: admission is the gate's job, upstream of this.
92
- */
93
- botMentioned: z.boolean().optional(),
86
+ /** Slack-specific: whether the bot was @-mentioned. */
87
+ slackBotMentioned: z.boolean().optional(),
94
88
  /**
95
89
  * Slack-specific: the `edited.ts` float-string from a `message_changed`
96
90
  * event. Used by the daemon to reject out-of-order edit deliveries whose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.12.2-staging.7",
3
+ "version": "0.12.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,29 +1,6 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import {
3
- normalizeTelegramUpdate,
4
- type TelegramDropReason,
5
- type TelegramNormalization,
6
- } from "../telegram/normalize.js";
2
+ import { normalizeTelegramUpdate } from "../telegram/normalize.js";
7
3
  import { verifyWebhookSecret } from "../telegram/verify.js";
8
- import type { GatewayInboundEvent } from "../types.js";
9
-
10
- /** The event a normalization produced, failing the test if it dropped. */
11
- function eventOf(result: TelegramNormalization): GatewayInboundEvent {
12
- if (result.dropped) {
13
- throw new Error(`expected an event, got drop: ${result.reason}`);
14
- }
15
- return result.event;
16
- }
17
-
18
- function expectDrop(
19
- result: TelegramNormalization,
20
- reason: TelegramDropReason,
21
- ): void {
22
- expect(result.dropped).toBe(true);
23
- if (result.dropped) {
24
- expect(result.reason).toBe(reason);
25
- }
26
- }
27
4
 
28
5
  describe("normalizeTelegramUpdate", () => {
29
6
  const validPayload = {
@@ -44,36 +21,36 @@ describe("normalizeTelegramUpdate", () => {
44
21
  };
45
22
 
46
23
  test("normalizes a valid private text message", () => {
47
- const result = eventOf(normalizeTelegramUpdate(validPayload));
48
- expect(result.version).toBe("v1");
49
- expect(result.sourceChannel).toBe("telegram");
50
- expect(result.message.content).toBe("Hello bot");
51
- expect(result.message.conversationExternalId).toBe("99001");
52
- expect(result.message.externalMessageId).toBe("123456");
53
- expect(result.actor.actorExternalId).toBe("55001");
54
- expect(result.actor.username).toBe("testuser");
55
- expect(result.actor.displayName).toBe("Test User");
56
- expect(result.actor.firstName).toBe("Test");
57
- expect(result.actor.lastName).toBe("User");
58
- expect(result.actor.languageCode).toBe("en");
59
- expect(result.actor.isBot).toBe(false);
60
- expect(result.source.updateId).toBe("123456");
61
- expect(result.source.messageId).toBe("42");
62
- expect(result.source.chatType).toBe("private");
63
- expect(result.raw).toEqual(validPayload);
24
+ const result = normalizeTelegramUpdate(validPayload);
25
+ expect(result).not.toBeNull();
26
+ expect(result!.version).toBe("v1");
27
+ expect(result!.sourceChannel).toBe("telegram");
28
+ expect(result!.message.content).toBe("Hello bot");
29
+ expect(result!.message.conversationExternalId).toBe("99001");
30
+ expect(result!.message.externalMessageId).toBe("123456");
31
+ expect(result!.actor.actorExternalId).toBe("55001");
32
+ expect(result!.actor.username).toBe("testuser");
33
+ expect(result!.actor.displayName).toBe("Test User");
34
+ expect(result!.actor.firstName).toBe("Test");
35
+ expect(result!.actor.lastName).toBe("User");
36
+ expect(result!.actor.languageCode).toBe("en");
37
+ expect(result!.actor.isBot).toBe(false);
38
+ expect(result!.source.updateId).toBe("123456");
39
+ expect(result!.source.messageId).toBe("42");
40
+ expect(result!.source.chatType).toBe("private");
41
+ expect(result!.raw).toEqual(validPayload);
64
42
  });
65
43
 
66
- test("drops unsupported message types (e.g. sticker-only)", () => {
44
+ test("returns null for unsupported message types (e.g. sticker-only)", () => {
67
45
  const payload = {
68
46
  update_id: 1,
69
47
  message: {
70
48
  message_id: 1,
71
49
  chat: { id: 1, type: "private" },
72
- from: { id: 7 },
73
50
  sticker: { file_id: "abc" },
74
51
  },
75
52
  };
76
- expectDrop(normalizeTelegramUpdate(payload), "no_supported_content");
53
+ expect(normalizeTelegramUpdate(payload)).toBeNull();
77
54
  });
78
55
 
79
56
  test("normalizes a photo message", () => {
@@ -106,10 +83,11 @@ describe("normalizeTelegramUpdate", () => {
106
83
  caption: "Check this out",
107
84
  },
108
85
  };
109
- const result = eventOf(normalizeTelegramUpdate(payload));
110
- expect(result.message.content).toBe("Check this out");
111
- expect(result.message.attachments).toHaveLength(1);
112
- expect(result.message.attachments![0]).toEqual({
86
+ const result = normalizeTelegramUpdate(payload);
87
+ expect(result).not.toBeNull();
88
+ expect(result!.message.content).toBe("Check this out");
89
+ expect(result!.message.attachments).toHaveLength(1);
90
+ expect(result!.message.attachments![0]).toEqual({
113
91
  type: "photo",
114
92
  fileId: "large_id",
115
93
  fileSize: undefined,
@@ -128,10 +106,11 @@ describe("normalizeTelegramUpdate", () => {
128
106
  ],
129
107
  },
130
108
  };
131
- const result = eventOf(normalizeTelegramUpdate(payload));
132
- expect(result.message.content).toBe("");
133
- expect(result.message.attachments).toHaveLength(1);
134
- expect(result.message.attachments![0].fileId).toBe("only_id");
109
+ const result = normalizeTelegramUpdate(payload);
110
+ expect(result).not.toBeNull();
111
+ expect(result!.message.content).toBe("");
112
+ expect(result!.message.attachments).toHaveLength(1);
113
+ expect(result!.message.attachments![0].fileId).toBe("only_id");
135
114
  });
136
115
 
137
116
  test("normalizes a document message", () => {
@@ -151,10 +130,11 @@ describe("normalizeTelegramUpdate", () => {
151
130
  caption: "Here is the report",
152
131
  },
153
132
  };
154
- const result = eventOf(normalizeTelegramUpdate(payload));
155
- expect(result.message.content).toBe("Here is the report");
156
- expect(result.message.attachments).toHaveLength(1);
157
- expect(result.message.attachments![0]).toEqual({
133
+ const result = normalizeTelegramUpdate(payload);
134
+ expect(result).not.toBeNull();
135
+ expect(result!.message.content).toBe("Here is the report");
136
+ expect(result!.message.attachments).toHaveLength(1);
137
+ expect(result!.message.attachments![0]).toEqual({
158
138
  type: "document",
159
139
  fileId: "doc_file_id",
160
140
  fileName: "report.pdf",
@@ -178,38 +158,40 @@ describe("normalizeTelegramUpdate", () => {
178
158
  },
179
159
  },
180
160
  };
181
- const result = eventOf(normalizeTelegramUpdate(payload));
182
- expect(result.message.content).toBe("");
183
- expect(result.message.attachments).toHaveLength(1);
161
+ const result = normalizeTelegramUpdate(payload);
162
+ expect(result).not.toBeNull();
163
+ expect(result!.message.content).toBe("");
164
+ expect(result!.message.attachments).toHaveLength(1);
184
165
  });
185
166
 
186
167
  test("text-only messages have no attachments field", () => {
187
- const result = eventOf(normalizeTelegramUpdate(validPayload));
188
- expect(result.message.attachments).toBeUndefined();
168
+ const result = normalizeTelegramUpdate(validPayload);
169
+ expect(result).not.toBeNull();
170
+ expect(result!.message.attachments).toBeUndefined();
189
171
  });
190
172
 
191
- test("drops group messages when the bot's identity is unknown", () => {
173
+ test("returns null for group messages", () => {
192
174
  const payload = {
193
175
  ...validPayload,
194
176
  message: { ...validPayload.message, chat: { id: 99001, type: "group" } },
195
177
  };
196
- expectDrop(normalizeTelegramUpdate(payload), "bot_identity_unknown");
178
+ expect(normalizeTelegramUpdate(payload)).toBeNull();
197
179
  });
198
180
 
199
- test("drops payloads without update_id", () => {
181
+ test("returns null for payloads without update_id", () => {
200
182
  const { update_id: _, ...rest } = validPayload;
201
- expectDrop(normalizeTelegramUpdate(rest), "missing_update_id");
183
+ expect(normalizeTelegramUpdate(rest)).toBeNull();
202
184
  });
203
185
 
204
- test("drops payloads without chat id", () => {
186
+ test("returns null for payloads without chat id", () => {
205
187
  const payload = {
206
188
  update_id: 1,
207
189
  message: { message_id: 1, text: "hello", chat: {} },
208
190
  };
209
- expectDrop(normalizeTelegramUpdate(payload), "missing_chat");
191
+ expect(normalizeTelegramUpdate(payload)).toBeNull();
210
192
  });
211
193
 
212
- test("drops a message when from.id is missing", () => {
194
+ test("returns null when from.id is missing", () => {
213
195
  const payload = {
214
196
  update_id: 1,
215
197
  message: {
@@ -219,15 +201,15 @@ describe("normalizeTelegramUpdate", () => {
219
201
  },
220
202
  };
221
203
  const result = normalizeTelegramUpdate(payload);
222
- expectDrop(result, "missing_sender");
204
+ expect(result).toBeNull();
223
205
  });
224
206
 
225
- test("drops callback_query without message context", () => {
207
+ test("returns null for callback_query without message context", () => {
226
208
  const payload = {
227
209
  update_id: 1,
228
210
  callback_query: { id: "abc", from: { id: 123 }, data: "some_data" },
229
211
  };
230
- expectDrop(normalizeTelegramUpdate(payload), "callback_without_message");
212
+ expect(normalizeTelegramUpdate(payload)).toBeNull();
231
213
  });
232
214
 
233
215
  test("normalizes an edited_message update as an edit", () => {
@@ -245,14 +227,15 @@ describe("normalizeTelegramUpdate", () => {
245
227
  },
246
228
  },
247
229
  };
248
- const result = eventOf(normalizeTelegramUpdate(payload));
249
- expect(result.message.eventKind).toBe("edit");
250
- expect(result.message.content).toBe("Hello bot (edited)");
251
- expect(result.message.conversationExternalId).toBe("99001");
252
- expect(result.message.externalMessageId).toBe("200");
253
- expect(result.source.updateId).toBe("200");
254
- expect(result.source.messageId).toBe("42");
255
- expect(result.actor.actorExternalId).toBe("55001");
230
+ const result = normalizeTelegramUpdate(payload);
231
+ expect(result).not.toBeNull();
232
+ expect(result!.message.eventKind).toBe("edit");
233
+ expect(result!.message.content).toBe("Hello bot (edited)");
234
+ expect(result!.message.conversationExternalId).toBe("99001");
235
+ expect(result!.message.externalMessageId).toBe("200");
236
+ expect(result!.source.updateId).toBe("200");
237
+ expect(result!.source.messageId).toBe("42");
238
+ expect(result!.actor.actorExternalId).toBe("55001");
256
239
  });
257
240
 
258
241
  test("prefers message over edited_message when both are present", () => {
@@ -271,9 +254,10 @@ describe("normalizeTelegramUpdate", () => {
271
254
  from: { id: 55001, is_bot: false },
272
255
  },
273
256
  };
274
- const result = eventOf(normalizeTelegramUpdate(payload));
275
- expect(result.message.isEdit).toBeUndefined();
276
- expect(result.message.content).toBe("Original");
257
+ const result = normalizeTelegramUpdate(payload);
258
+ expect(result).not.toBeNull();
259
+ expect(result!.message.isEdit).toBeUndefined();
260
+ expect(result!.message.content).toBe("Original");
277
261
  });
278
262
 
279
263
  test("classifies an edited_message with photo as an edit", () => {
@@ -294,13 +278,14 @@ describe("normalizeTelegramUpdate", () => {
294
278
  caption: "Updated caption",
295
279
  },
296
280
  };
297
- const result = eventOf(normalizeTelegramUpdate(payload));
298
- expect(result.message.eventKind).toBe("edit");
299
- expect(result.message.content).toBe("Updated caption");
300
- expect(result.message.attachments).toHaveLength(1);
281
+ const result = normalizeTelegramUpdate(payload);
282
+ expect(result).not.toBeNull();
283
+ expect(result!.message.eventKind).toBe("edit");
284
+ expect(result!.message.content).toBe("Updated caption");
285
+ expect(result!.message.attachments).toHaveLength(1);
301
286
  });
302
287
 
303
- test("drops edited_message in a group when the bot's identity is unknown", () => {
288
+ test("returns null for edited_message in group chat", () => {
304
289
  const payload = {
305
290
  update_id: 500,
306
291
  edited_message: {
@@ -310,7 +295,7 @@ describe("normalizeTelegramUpdate", () => {
310
295
  from: { id: 55001, is_bot: false },
311
296
  },
312
297
  };
313
- expectDrop(normalizeTelegramUpdate(payload), "bot_identity_unknown");
298
+ expect(normalizeTelegramUpdate(payload)).toBeNull();
314
299
  });
315
300
  });
316
301
 
@@ -337,29 +322,30 @@ describe("normalizeTelegramUpdate: callback_query", () => {
337
322
  },
338
323
  };
339
324
 
340
- const result = eventOf(normalizeTelegramUpdate(payload));
341
-
342
- expect(result.version).toBe("v1");
343
- expect(result.sourceChannel).toBe("telegram");
344
- expect(result.message.content).toBe("apr:run-abc:approve");
345
- expect(result.message.conversationExternalId).toBe("12345");
346
- expect(result.message.externalMessageId).toBe("5001");
347
- expect(result.message.callbackQueryId).toBe("cbq-123");
348
- expect(result.message.callbackData).toBe("apr:run-abc:approve");
349
- expect(result.message.attachments).toBeUndefined();
350
- expect(result.actor.actorExternalId).toBe("67890");
351
- expect(result.actor.username).toBe("testuser");
352
- expect(result.actor.displayName).toBe("Test User");
353
- expect(result.actor.firstName).toBe("Test");
354
- expect(result.actor.lastName).toBe("User");
355
- expect(result.actor.languageCode).toBe("en");
356
- expect(result.actor.isBot).toBe(false);
357
- expect(result.source.updateId).toBe("5001");
358
- expect(result.source.messageId).toBe("42");
359
- expect(result.source.chatType).toBe("private");
325
+ const result = normalizeTelegramUpdate(payload);
326
+
327
+ expect(result).not.toBeNull();
328
+ expect(result!.version).toBe("v1");
329
+ expect(result!.sourceChannel).toBe("telegram");
330
+ expect(result!.message.content).toBe("apr:run-abc:approve");
331
+ expect(result!.message.conversationExternalId).toBe("12345");
332
+ expect(result!.message.externalMessageId).toBe("5001");
333
+ expect(result!.message.callbackQueryId).toBe("cbq-123");
334
+ expect(result!.message.callbackData).toBe("apr:run-abc:approve");
335
+ expect(result!.message.attachments).toBeUndefined();
336
+ expect(result!.actor.actorExternalId).toBe("67890");
337
+ expect(result!.actor.username).toBe("testuser");
338
+ expect(result!.actor.displayName).toBe("Test User");
339
+ expect(result!.actor.firstName).toBe("Test");
340
+ expect(result!.actor.lastName).toBe("User");
341
+ expect(result!.actor.languageCode).toBe("en");
342
+ expect(result!.actor.isBot).toBe(false);
343
+ expect(result!.source.updateId).toBe("5001");
344
+ expect(result!.source.messageId).toBe("42");
345
+ expect(result!.source.chatType).toBe("private");
360
346
  });
361
347
 
362
- test("drops a callback_query with no message (inline mode edge case)", () => {
348
+ test("returns null when callback_query has no message (inline mode edge case)", () => {
363
349
  const payload = {
364
350
  update_id: 5002,
365
351
  callback_query: {
@@ -375,10 +361,10 @@ describe("normalizeTelegramUpdate: callback_query", () => {
375
361
  };
376
362
 
377
363
  const result = normalizeTelegramUpdate(payload);
378
- expectDrop(result, "callback_without_message");
364
+ expect(result).toBeNull();
379
365
  });
380
366
 
381
- test("drops a callback_query with no data", () => {
367
+ test("returns null when callback_query has no data", () => {
382
368
  const payload = {
383
369
  update_id: 5003,
384
370
  callback_query: {
@@ -398,10 +384,10 @@ describe("normalizeTelegramUpdate: callback_query", () => {
398
384
  };
399
385
 
400
386
  const result = normalizeTelegramUpdate(payload);
401
- expectDrop(result, "callback_without_data");
387
+ expect(result).toBeNull();
402
388
  });
403
389
 
404
- test("drops a callback_query whose message has no chat id", () => {
390
+ test("returns null when callback_query message has no chat id", () => {
405
391
  const payload = {
406
392
  update_id: 5004,
407
393
  callback_query: {
@@ -422,10 +408,10 @@ describe("normalizeTelegramUpdate: callback_query", () => {
422
408
  };
423
409
 
424
410
  const result = normalizeTelegramUpdate(payload);
425
- expectDrop(result, "callback_without_message");
411
+ expect(result).toBeNull();
426
412
  });
427
413
 
428
- test("drops a callback_query when from.id is missing", () => {
414
+ test("returns null when callback_query from.id is missing", () => {
429
415
  const payload = {
430
416
  update_id: 5005,
431
417
  callback_query: {
@@ -441,7 +427,7 @@ describe("normalizeTelegramUpdate: callback_query", () => {
441
427
  };
442
428
 
443
429
  const result = normalizeTelegramUpdate(payload);
444
- expectDrop(result, "missing_sender");
430
+ expect(result).toBeNull();
445
431
  });
446
432
 
447
433
  test("callback_query does not set isEdit or attachments", () => {
@@ -464,9 +450,10 @@ describe("normalizeTelegramUpdate: callback_query", () => {
464
450
  },
465
451
  };
466
452
 
467
- const result = eventOf(normalizeTelegramUpdate(payload));
468
- expect(result.message.isEdit).toBeUndefined();
469
- expect(result.message.attachments).toBeUndefined();
453
+ const result = normalizeTelegramUpdate(payload);
454
+ expect(result).not.toBeNull();
455
+ expect(result!.message.isEdit).toBeUndefined();
456
+ expect(result!.message.attachments).toBeUndefined();
470
457
  });
471
458
 
472
459
  test("regular text messages are unaffected by callback_query support", () => {
@@ -485,10 +472,11 @@ describe("normalizeTelegramUpdate: callback_query", () => {
485
472
  },
486
473
  };
487
474
 
488
- const result = eventOf(normalizeTelegramUpdate(payload));
489
- expect(result.message.content).toBe("Hello world");
490
- expect(result.message.callbackQueryId).toBeUndefined();
491
- expect(result.message.callbackData).toBeUndefined();
475
+ const result = normalizeTelegramUpdate(payload);
476
+ expect(result).not.toBeNull();
477
+ expect(result!.message.content).toBe("Hello world");
478
+ expect(result!.message.callbackQueryId).toBeUndefined();
479
+ expect(result!.message.callbackData).toBeUndefined();
492
480
  });
493
481
  });
494
482