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

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 +3 -1
  2. package/README.md +1 -1
  3. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  4. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  5. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  6. package/package.json +1 -1
  7. package/src/__tests__/telegram-normalize.test.ts +123 -111
  8. package/src/__tests__/telegram-webhook-drop-visibility.test.ts +229 -0
  9. package/src/__tests__/telegram-webhook-handler.test.ts +160 -0
  10. package/src/channels/admission-drop-log.test.ts +47 -0
  11. package/src/channels/admission-drop-log.ts +94 -0
  12. package/src/channels/inbound-event.ts +9 -0
  13. package/src/channels/room-admission.test.ts +95 -0
  14. package/src/channels/room-admission.ts +149 -0
  15. package/src/discord/admission-log.test.ts +15 -12
  16. package/src/discord/admission-log.ts +11 -95
  17. package/src/discord/admit.test.ts +8 -9
  18. package/src/discord/admit.ts +48 -93
  19. package/src/discord/gateway-socket.ts +4 -2
  20. package/src/discord/normalize.test.ts +34 -1
  21. package/src/discord/normalize.ts +23 -3
  22. package/src/handlers/handle-inbound.ts +3 -0
  23. package/src/http/routes/telegram-webhook.ts +50 -9
  24. package/src/risk/bash-risk-classifier.test.ts +63 -0
  25. package/src/risk/command-registry/commands/dig.ts +2 -1
  26. package/src/risk/command-registry/commands/host.ts +2 -1
  27. package/src/risk/command-registry/commands/mtr.ts +2 -1
  28. package/src/risk/command-registry/commands/nslookup.ts +2 -1
  29. package/src/risk/command-registry/commands/ping.ts +2 -1
  30. package/src/risk/command-registry/commands/tracepath.ts +2 -1
  31. package/src/risk/command-registry/commands/traceroute.ts +2 -1
  32. package/src/risk/risk-types.ts +1 -0
  33. package/src/risk/shell-parser.test.ts +82 -1
  34. package/src/risk/shell-parser.ts +50 -2
  35. package/src/slack/message-normalizer.ts +14 -1
  36. package/src/slack/source-metadata.test.ts +5 -2
  37. package/src/slack/source-metadata.ts +2 -6
  38. package/src/telegram/admit.test.ts +155 -0
  39. package/src/telegram/admit.ts +149 -0
  40. package/src/telegram/bot-identity.test.ts +124 -0
  41. package/src/telegram/bot-identity.ts +127 -0
  42. package/src/telegram/drop-log.ts +41 -0
  43. package/src/telegram/normalize.test.ts +343 -134
  44. package/src/telegram/normalize.ts +197 -197
  45. package/src/telegram/schemas.ts +217 -0
  46. package/src/telegram/send.ts +1 -1
package/AGENTS.md CHANGED
@@ -90,6 +90,8 @@ 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
+
93
95
  Physical DB column names (`externalUserId`, `externalChatId`) are unchanged; the rename is at the API/type layer only.
94
96
 
95
97
  **Provider words that are not our words.** A Discord **guild** is what Discord's
@@ -193,7 +195,7 @@ Both the flat (`/v1/channel-admission-policy/...`) and assistant-scoped (`/v1/as
193
195
 
194
196
  - **Gateway kill switch** — `handle-inbound.ts` enforces the `no_one` floor before forwarding. Zero contact-table lookups, zero daemon I/O, true kill.
195
197
  - **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.
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.
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.
197
199
 
198
200
  **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.
199
201
 
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:** 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.
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`).
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,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  buildShellInvocation,
5
+ buildShellSpawnFlags,
5
6
  pathListDelimiter,
6
7
  prependUniquePathEntries,
7
8
  } from "./shell.js";
@@ -38,6 +39,26 @@ describe("buildShellInvocation", () => {
38
39
  });
39
40
  });
40
41
 
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
+
41
62
  describe("path list handling", () => {
42
63
  test("uses the platform delimiter", () => {
43
64
  expect(pathListDelimiter("win32")).toBe(";");
@@ -5,6 +5,11 @@ export interface ShellInvocation {
5
5
  args: string[];
6
6
  }
7
7
 
8
+ export interface ShellSpawnFlags {
9
+ detached: boolean;
10
+ windowsHide: true;
11
+ }
12
+
8
13
  const WINDOWS_UTF8_PREAMBLE =
9
14
  "try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}; " +
10
15
  "$OutputEncoding = [System.Text.Encoding]::UTF8; " +
@@ -43,6 +48,25 @@ export function buildShellInvocation(
43
48
  return { command: "bash", args: ["-c", "--", command] };
44
49
  }
45
50
 
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
+
46
70
  export function pathListDelimiter(
47
71
  hostPlatform: NodeJS.Platform = process.platform,
48
72
  ): string {
@@ -83,8 +83,14 @@ 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
- /** Slack-specific: whether the bot was @-mentioned. */
87
- slackBotMentioned: z.boolean().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(),
88
94
  /**
89
95
  * Slack-specific: the `edited.ts` float-string from a `message_changed`
90
96
  * 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.6",
3
+ "version": "0.12.2-staging.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,29 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { normalizeTelegramUpdate } from "../telegram/normalize.js";
2
+ import {
3
+ normalizeTelegramUpdate,
4
+ type TelegramDropReason,
5
+ type TelegramNormalization,
6
+ } from "../telegram/normalize.js";
3
7
  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
+ }
4
27
 
5
28
  describe("normalizeTelegramUpdate", () => {
6
29
  const validPayload = {
@@ -21,36 +44,36 @@ describe("normalizeTelegramUpdate", () => {
21
44
  };
22
45
 
23
46
  test("normalizes a valid private text message", () => {
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);
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);
42
64
  });
43
65
 
44
- test("returns null for unsupported message types (e.g. sticker-only)", () => {
66
+ test("drops unsupported message types (e.g. sticker-only)", () => {
45
67
  const payload = {
46
68
  update_id: 1,
47
69
  message: {
48
70
  message_id: 1,
49
71
  chat: { id: 1, type: "private" },
72
+ from: { id: 7 },
50
73
  sticker: { file_id: "abc" },
51
74
  },
52
75
  };
53
- expect(normalizeTelegramUpdate(payload)).toBeNull();
76
+ expectDrop(normalizeTelegramUpdate(payload), "no_supported_content");
54
77
  });
55
78
 
56
79
  test("normalizes a photo message", () => {
@@ -83,11 +106,10 @@ describe("normalizeTelegramUpdate", () => {
83
106
  caption: "Check this out",
84
107
  },
85
108
  };
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({
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({
91
113
  type: "photo",
92
114
  fileId: "large_id",
93
115
  fileSize: undefined,
@@ -106,11 +128,10 @@ describe("normalizeTelegramUpdate", () => {
106
128
  ],
107
129
  },
108
130
  };
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");
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");
114
135
  });
115
136
 
116
137
  test("normalizes a document message", () => {
@@ -130,11 +151,10 @@ describe("normalizeTelegramUpdate", () => {
130
151
  caption: "Here is the report",
131
152
  },
132
153
  };
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({
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({
138
158
  type: "document",
139
159
  fileId: "doc_file_id",
140
160
  fileName: "report.pdf",
@@ -158,40 +178,38 @@ describe("normalizeTelegramUpdate", () => {
158
178
  },
159
179
  },
160
180
  };
161
- const result = normalizeTelegramUpdate(payload);
162
- expect(result).not.toBeNull();
163
- expect(result!.message.content).toBe("");
164
- expect(result!.message.attachments).toHaveLength(1);
181
+ const result = eventOf(normalizeTelegramUpdate(payload));
182
+ expect(result.message.content).toBe("");
183
+ expect(result.message.attachments).toHaveLength(1);
165
184
  });
166
185
 
167
186
  test("text-only messages have no attachments field", () => {
168
- const result = normalizeTelegramUpdate(validPayload);
169
- expect(result).not.toBeNull();
170
- expect(result!.message.attachments).toBeUndefined();
187
+ const result = eventOf(normalizeTelegramUpdate(validPayload));
188
+ expect(result.message.attachments).toBeUndefined();
171
189
  });
172
190
 
173
- test("returns null for group messages", () => {
191
+ test("drops group messages when the bot's identity is unknown", () => {
174
192
  const payload = {
175
193
  ...validPayload,
176
194
  message: { ...validPayload.message, chat: { id: 99001, type: "group" } },
177
195
  };
178
- expect(normalizeTelegramUpdate(payload)).toBeNull();
196
+ expectDrop(normalizeTelegramUpdate(payload), "bot_identity_unknown");
179
197
  });
180
198
 
181
- test("returns null for payloads without update_id", () => {
199
+ test("drops payloads without update_id", () => {
182
200
  const { update_id: _, ...rest } = validPayload;
183
- expect(normalizeTelegramUpdate(rest)).toBeNull();
201
+ expectDrop(normalizeTelegramUpdate(rest), "missing_update_id");
184
202
  });
185
203
 
186
- test("returns null for payloads without chat id", () => {
204
+ test("drops payloads without chat id", () => {
187
205
  const payload = {
188
206
  update_id: 1,
189
207
  message: { message_id: 1, text: "hello", chat: {} },
190
208
  };
191
- expect(normalizeTelegramUpdate(payload)).toBeNull();
209
+ expectDrop(normalizeTelegramUpdate(payload), "missing_chat");
192
210
  });
193
211
 
194
- test("returns null when from.id is missing", () => {
212
+ test("drops a message when from.id is missing", () => {
195
213
  const payload = {
196
214
  update_id: 1,
197
215
  message: {
@@ -201,15 +219,15 @@ describe("normalizeTelegramUpdate", () => {
201
219
  },
202
220
  };
203
221
  const result = normalizeTelegramUpdate(payload);
204
- expect(result).toBeNull();
222
+ expectDrop(result, "missing_sender");
205
223
  });
206
224
 
207
- test("returns null for callback_query without message context", () => {
225
+ test("drops callback_query without message context", () => {
208
226
  const payload = {
209
227
  update_id: 1,
210
228
  callback_query: { id: "abc", from: { id: 123 }, data: "some_data" },
211
229
  };
212
- expect(normalizeTelegramUpdate(payload)).toBeNull();
230
+ expectDrop(normalizeTelegramUpdate(payload), "callback_without_message");
213
231
  });
214
232
 
215
233
  test("normalizes an edited_message update as an edit", () => {
@@ -227,15 +245,14 @@ describe("normalizeTelegramUpdate", () => {
227
245
  },
228
246
  },
229
247
  };
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");
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");
239
256
  });
240
257
 
241
258
  test("prefers message over edited_message when both are present", () => {
@@ -254,10 +271,9 @@ describe("normalizeTelegramUpdate", () => {
254
271
  from: { id: 55001, is_bot: false },
255
272
  },
256
273
  };
257
- const result = normalizeTelegramUpdate(payload);
258
- expect(result).not.toBeNull();
259
- expect(result!.message.isEdit).toBeUndefined();
260
- expect(result!.message.content).toBe("Original");
274
+ const result = eventOf(normalizeTelegramUpdate(payload));
275
+ expect(result.message.isEdit).toBeUndefined();
276
+ expect(result.message.content).toBe("Original");
261
277
  });
262
278
 
263
279
  test("classifies an edited_message with photo as an edit", () => {
@@ -278,14 +294,13 @@ describe("normalizeTelegramUpdate", () => {
278
294
  caption: "Updated caption",
279
295
  },
280
296
  };
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);
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);
286
301
  });
287
302
 
288
- test("returns null for edited_message in group chat", () => {
303
+ test("drops edited_message in a group when the bot's identity is unknown", () => {
289
304
  const payload = {
290
305
  update_id: 500,
291
306
  edited_message: {
@@ -295,7 +310,7 @@ describe("normalizeTelegramUpdate", () => {
295
310
  from: { id: 55001, is_bot: false },
296
311
  },
297
312
  };
298
- expect(normalizeTelegramUpdate(payload)).toBeNull();
313
+ expectDrop(normalizeTelegramUpdate(payload), "bot_identity_unknown");
299
314
  });
300
315
  });
301
316
 
@@ -322,30 +337,29 @@ describe("normalizeTelegramUpdate: callback_query", () => {
322
337
  },
323
338
  };
324
339
 
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");
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");
346
360
  });
347
361
 
348
- test("returns null when callback_query has no message (inline mode edge case)", () => {
362
+ test("drops a callback_query with no message (inline mode edge case)", () => {
349
363
  const payload = {
350
364
  update_id: 5002,
351
365
  callback_query: {
@@ -361,10 +375,10 @@ describe("normalizeTelegramUpdate: callback_query", () => {
361
375
  };
362
376
 
363
377
  const result = normalizeTelegramUpdate(payload);
364
- expect(result).toBeNull();
378
+ expectDrop(result, "callback_without_message");
365
379
  });
366
380
 
367
- test("returns null when callback_query has no data", () => {
381
+ test("drops a callback_query with no data", () => {
368
382
  const payload = {
369
383
  update_id: 5003,
370
384
  callback_query: {
@@ -384,10 +398,10 @@ describe("normalizeTelegramUpdate: callback_query", () => {
384
398
  };
385
399
 
386
400
  const result = normalizeTelegramUpdate(payload);
387
- expect(result).toBeNull();
401
+ expectDrop(result, "callback_without_data");
388
402
  });
389
403
 
390
- test("returns null when callback_query message has no chat id", () => {
404
+ test("drops a callback_query whose message has no chat id", () => {
391
405
  const payload = {
392
406
  update_id: 5004,
393
407
  callback_query: {
@@ -408,10 +422,10 @@ describe("normalizeTelegramUpdate: callback_query", () => {
408
422
  };
409
423
 
410
424
  const result = normalizeTelegramUpdate(payload);
411
- expect(result).toBeNull();
425
+ expectDrop(result, "callback_without_message");
412
426
  });
413
427
 
414
- test("returns null when callback_query from.id is missing", () => {
428
+ test("drops a callback_query when from.id is missing", () => {
415
429
  const payload = {
416
430
  update_id: 5005,
417
431
  callback_query: {
@@ -427,7 +441,7 @@ describe("normalizeTelegramUpdate: callback_query", () => {
427
441
  };
428
442
 
429
443
  const result = normalizeTelegramUpdate(payload);
430
- expect(result).toBeNull();
444
+ expectDrop(result, "missing_sender");
431
445
  });
432
446
 
433
447
  test("callback_query does not set isEdit or attachments", () => {
@@ -450,10 +464,9 @@ describe("normalizeTelegramUpdate: callback_query", () => {
450
464
  },
451
465
  };
452
466
 
453
- const result = normalizeTelegramUpdate(payload);
454
- expect(result).not.toBeNull();
455
- expect(result!.message.isEdit).toBeUndefined();
456
- expect(result!.message.attachments).toBeUndefined();
467
+ const result = eventOf(normalizeTelegramUpdate(payload));
468
+ expect(result.message.isEdit).toBeUndefined();
469
+ expect(result.message.attachments).toBeUndefined();
457
470
  });
458
471
 
459
472
  test("regular text messages are unaffected by callback_query support", () => {
@@ -472,11 +485,10 @@ describe("normalizeTelegramUpdate: callback_query", () => {
472
485
  },
473
486
  };
474
487
 
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();
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();
480
492
  });
481
493
  });
482
494