@crewhaus/channel-adapter-telegram 0.1.1 → 0.1.3

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 (2) hide show
  1. package/package.json +6 -11
  2. package/src/index.test.ts +342 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/channel-adapter-telegram",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Telegram channel adapter: secret_token webhook verification, message/edited_message/callback_query parsing, group + topic session keying (Section 33)",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,13 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.1.1"
15
+ "@crewhaus/errors": "0.1.3"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
20
+ "email": "max@crewhaus.ai",
21
+ "url": "https://crewhaus.ai"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -30,12 +30,7 @@
30
30
  "url": "https://github.com/crewhaus/factory/issues"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "restricted"
33
+ "access": "public"
34
34
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
35
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
36
  }
package/src/index.test.ts CHANGED
@@ -59,6 +59,14 @@ describe("verifyTelegramSecret (T8)", () => {
59
59
  const h = new Headers({ "X-Telegram-Bot-Api-Secret-Token": "" });
60
60
  expect(verifyTelegramSecret({ headers: h, secretToken: SECRET })).toBe(false);
61
61
  });
62
+
63
+ test("rejects when UTF-16 length matches but UTF-8 byte length differs", () => {
64
+ // "é" is one UTF-16 code unit but two UTF-8 bytes; "e" is one unit / one
65
+ // byte. They pass the `.length` check yet must be rejected by the
66
+ // byte-length guard before timingSafeEqual (which throws on size mismatch).
67
+ const h = new Headers({ "X-Telegram-Bot-Api-Secret-Token": "é" });
68
+ expect(verifyTelegramSecret({ headers: h, secretToken: "e" })).toBe(false);
69
+ });
62
70
  });
63
71
 
64
72
  describe("createTelegramAdapter.verify()", () => {
@@ -178,6 +186,231 @@ describe("parseInbound — fixtures (T2)", () => {
178
186
  const a = adapter();
179
187
  expect(a.parseInbound(withHeaders(SECRET, "null")).kind).toBe("skip");
180
188
  });
189
+
190
+ test("non-numeric update_id → skip", () => {
191
+ const a = adapter();
192
+ const body = JSON.stringify({ update_id: "not-a-number", message: {} });
193
+ expect(a.parseInbound(withHeaders(SECRET, body)).kind).toBe("skip");
194
+ });
195
+
196
+ test("JSON array body (object, not null, but not an Update) → skip", () => {
197
+ const a = adapter();
198
+ // Arrays are typeof "object" and non-null; they lack a numeric update_id.
199
+ expect(a.parseInbound(withHeaders(SECRET, "[1,2,3]")).kind).toBe("skip");
200
+ });
201
+
202
+ test("message with chat but no `from` (userId undefined) → skip", () => {
203
+ const a = adapter();
204
+ const body = JSON.stringify({
205
+ update_id: 200001,
206
+ message: { message_id: 5, chat: { id: 9, type: "private" }, text: "hi" },
207
+ });
208
+ expect(a.parseInbound(withHeaders(SECRET, body)).kind).toBe("skip");
209
+ });
210
+
211
+ test("message text prefers `text` over `caption`", () => {
212
+ const a = adapter();
213
+ const body = JSON.stringify({
214
+ update_id: 200002,
215
+ message: {
216
+ message_id: 6,
217
+ from: { id: 1 },
218
+ chat: { id: 9, type: "private" },
219
+ text: "the-text",
220
+ caption: "the-caption",
221
+ },
222
+ });
223
+ const r = a.parseInbound(withHeaders(SECRET, body)) as Extract<
224
+ ParsedInbound,
225
+ { kind: "event" }
226
+ >;
227
+ expect(r.event.text).toBe("the-text");
228
+ });
229
+
230
+ test("bot_command entity → subtype app_mention", () => {
231
+ const a = adapter();
232
+ const body = JSON.stringify({
233
+ update_id: 200003,
234
+ message: {
235
+ message_id: 7,
236
+ from: { id: 1 },
237
+ chat: { id: 9, type: "private" },
238
+ text: "/start",
239
+ entities: [{ type: "bot_command", offset: 0, length: 6 }],
240
+ },
241
+ });
242
+ const r = a.parseInbound(withHeaders(SECRET, body)) as Extract<
243
+ ParsedInbound,
244
+ { kind: "event" }
245
+ >;
246
+ expect(r.event.subtype).toBe("app_mention");
247
+ });
248
+
249
+ test("entity with unrelated type (e.g. `url`) → subtype message", () => {
250
+ const a = adapter();
251
+ const body = JSON.stringify({
252
+ update_id: 200004,
253
+ message: {
254
+ message_id: 8,
255
+ from: { id: 1 },
256
+ chat: { id: 9, type: "private" },
257
+ text: "see https://example.com",
258
+ entities: [{ type: "url", offset: 4, length: 19 }],
259
+ },
260
+ });
261
+ const r = a.parseInbound(withHeaders(SECRET, body)) as Extract<
262
+ ParsedInbound,
263
+ { kind: "event" }
264
+ >;
265
+ expect(r.event.subtype).toBe("message");
266
+ });
267
+
268
+ test("bot message is NOT skipped when no selfBotId is configured", () => {
269
+ // selfBotId-less adapter must let bot-authored messages through (the
270
+ // `opts.selfBotId !== undefined` short-circuit guards this branch).
271
+ const a = createTelegramAdapter(
272
+ { botToken: BOT_TOKEN, secretToken: SECRET },
273
+ { apiBaseUrl: "https://test.telegram.local" },
274
+ );
275
+ const body = JSON.stringify({
276
+ update_id: 200005,
277
+ message: {
278
+ message_id: 9,
279
+ from: { id: 9999, is_bot: true, username: "crewhaus_bot" },
280
+ chat: { id: 9, type: "private" },
281
+ text: "I am the bot",
282
+ },
283
+ });
284
+ const r = a.parseInbound(withHeaders(SECRET, body)) as Extract<
285
+ ParsedInbound,
286
+ { kind: "event" }
287
+ >;
288
+ expect(r.kind).toBe("event");
289
+ expect(r.event.userId).toBe("9999");
290
+ });
291
+
292
+ test("non-self bot message is NOT skipped even with selfBotId set", () => {
293
+ // selfBotId is set, message is from a bot, but a *different* bot id →
294
+ // exercises the `String(from.id) === selfBotId` false branch.
295
+ const a = adapter();
296
+ const body = JSON.stringify({
297
+ update_id: 200006,
298
+ message: {
299
+ message_id: 10,
300
+ from: { id: 8888, is_bot: true, username: "other_bot" },
301
+ chat: { id: 9, type: "private" },
302
+ text: "different bot",
303
+ },
304
+ });
305
+ const r = a.parseInbound(withHeaders(SECRET, body)) as Extract<
306
+ ParsedInbound,
307
+ { kind: "event" }
308
+ >;
309
+ expect(r.kind).toBe("event");
310
+ expect(r.event.userId).toBe("8888");
311
+ });
312
+
313
+ test("edited_message with neither text nor caption → skip", () => {
314
+ const a = adapter();
315
+ const body = JSON.stringify({
316
+ update_id: 200007,
317
+ edited_message: { message_id: 11, from: { id: 1 }, chat: { id: 9, type: "private" } },
318
+ });
319
+ expect(a.parseInbound(withHeaders(SECRET, body)).kind).toBe("skip");
320
+ });
321
+
322
+ test("edited_message in a topic → channelId chatId:topicId, threadTs set", () => {
323
+ const a = adapter();
324
+ const body = JSON.stringify({
325
+ update_id: 200008,
326
+ edited_message: {
327
+ message_id: 12,
328
+ from: { id: 1 },
329
+ chat: { id: -100123, type: "supergroup" },
330
+ message_thread_id: 21,
331
+ text: "edited in topic",
332
+ },
333
+ });
334
+ const r = a.parseInbound(withHeaders(SECRET, body)) as Extract<
335
+ ParsedInbound,
336
+ { kind: "event" }
337
+ >;
338
+ expect(r.event.channelId).toBe("-100123:21");
339
+ expect(r.event.threadTs).toBe("21");
340
+ });
341
+
342
+ test("callback_query without `message` → skip", () => {
343
+ const a = adapter();
344
+ const body = JSON.stringify({
345
+ update_id: 200009,
346
+ callback_query: { id: "cq", from: { id: 7 }, data: "go" },
347
+ });
348
+ expect(a.parseInbound(withHeaders(SECRET, body)).kind).toBe("skip");
349
+ });
350
+
351
+ test("callback_query without `from` (userId undefined) → skip", () => {
352
+ const a = adapter();
353
+ const body = JSON.stringify({
354
+ update_id: 200010,
355
+ callback_query: {
356
+ id: "cq",
357
+ data: "go",
358
+ message: { message_id: 3, chat: { id: 9, type: "private" } },
359
+ },
360
+ });
361
+ expect(a.parseInbound(withHeaders(SECRET, body)).kind).toBe("skip");
362
+ });
363
+
364
+ test("callback_query whose message lacks `chat` → skip", () => {
365
+ const a = adapter();
366
+ const body = JSON.stringify({
367
+ update_id: 200011,
368
+ callback_query: { id: "cq", from: { id: 7 }, data: "go", message: { message_id: 3 } },
369
+ });
370
+ expect(a.parseInbound(withHeaders(SECRET, body)).kind).toBe("skip");
371
+ });
372
+
373
+ test("callback_query without `data` → text defaults to empty string", () => {
374
+ const a = adapter();
375
+ const body = JSON.stringify({
376
+ update_id: 200012,
377
+ callback_query: {
378
+ id: "cq",
379
+ from: { id: 7 },
380
+ message: { message_id: 3, chat: { id: 9, type: "private" } },
381
+ },
382
+ });
383
+ const r = a.parseInbound(withHeaders(SECRET, body)) as Extract<
384
+ ParsedInbound,
385
+ { kind: "event" }
386
+ >;
387
+ expect(r.kind).toBe("event");
388
+ expect(r.event.text).toBe("");
389
+ });
390
+
391
+ test("callback_query in a topic → channelId chatId:topicId, threadTs set", () => {
392
+ const a = adapter();
393
+ const body = JSON.stringify({
394
+ update_id: 200013,
395
+ callback_query: {
396
+ id: "cq",
397
+ from: { id: 7 },
398
+ data: "approve",
399
+ message: {
400
+ message_id: 3,
401
+ chat: { id: -100123, type: "supergroup" },
402
+ message_thread_id: 42,
403
+ },
404
+ },
405
+ });
406
+ const r = a.parseInbound(withHeaders(SECRET, body)) as Extract<
407
+ ParsedInbound,
408
+ { kind: "event" }
409
+ >;
410
+ expect(r.event.channelId).toBe("-100123:42");
411
+ expect(r.event.threadTs).toBe("42");
412
+ expect(r.event.text).toBe("approve");
413
+ });
181
414
  });
182
415
 
183
416
  describe("sendReply / setTyping (T3)", () => {
@@ -275,4 +508,113 @@ describe("sendReply / setTyping (T3)", () => {
275
508
  expect(body.chat_id).toBe(4242);
276
509
  expect(body.action).toBe("typing");
277
510
  });
511
+
512
+ test("sendReply tolerates a 2xx response without a JSON content-type", async () => {
513
+ // res.ok is true but content-type is not application/json, so the
514
+ // ok:false body inspection is skipped and no error is thrown.
515
+ const f = (async () =>
516
+ new Response("OK", { status: 200, statusText: "OK" })) as unknown as typeof fetch;
517
+ const a = createTelegramAdapter(
518
+ { botToken: BOT_TOKEN, secretToken: SECRET },
519
+ { apiBaseUrl: "https://test.telegram.local", fetch: f },
520
+ );
521
+ await expect(a.sendReply({ event, text: "x" })).resolves.toBeUndefined();
522
+ });
523
+
524
+ test("sendReply accepts a 2xx JSON response with ok:true", async () => {
525
+ const f = (async () =>
526
+ new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), {
527
+ status: 200,
528
+ headers: { "content-type": "application/json" },
529
+ })) as unknown as typeof fetch;
530
+ const a = createTelegramAdapter(
531
+ { botToken: BOT_TOKEN, secretToken: SECRET },
532
+ { apiBaseUrl: "https://test.telegram.local", fetch: f },
533
+ );
534
+ await expect(a.sendReply({ event, text: "x" })).resolves.toBeUndefined();
535
+ });
536
+
537
+ test("setTyping forwards message_thread_id when threadTs is set", async () => {
538
+ const { calls, fetch: f } = captureFetch();
539
+ const a = createTelegramAdapter(
540
+ { botToken: BOT_TOKEN, secretToken: SECRET },
541
+ { apiBaseUrl: "https://test.telegram.local", fetch: f },
542
+ );
543
+ await a.setTyping({ event: { ...event, threadTs: "17", channelId: "4242:17" } });
544
+ const body = JSON.parse(String(calls[0]?.init.body));
545
+ expect(body.message_thread_id).toBe(17);
546
+ });
547
+
548
+ test("setTyping rejects malformed workspaceId", async () => {
549
+ const a = adapter();
550
+ await expect(a.setTyping({ event: { ...event, workspaceId: "not-a-number" } })).rejects.toThrow(
551
+ /invalid workspaceId/,
552
+ );
553
+ });
554
+
555
+ test("setTyping ignores a non-200 response (best-effort)", async () => {
556
+ // setTyping never checks res.ok; a 500 must NOT reject.
557
+ let called = false;
558
+ const f = (async () => {
559
+ called = true;
560
+ return new Response("nope", { status: 500, statusText: "Server Error" });
561
+ }) as unknown as typeof fetch;
562
+ const a = createTelegramAdapter(
563
+ { botToken: BOT_TOKEN, secretToken: SECRET },
564
+ { apiBaseUrl: "https://test.telegram.local", fetch: f },
565
+ );
566
+ await expect(a.setTyping({ event })).resolves.toBeUndefined();
567
+ expect(called).toBe(true);
568
+ });
569
+ });
570
+
571
+ describe("apiBaseUrl resolution", () => {
572
+ const event: InboundEvent = {
573
+ idempotencyKey: "1",
574
+ workspaceId: "4242",
575
+ channelId: "4242",
576
+ userId: "4242",
577
+ ts: "7",
578
+ text: "hi",
579
+ subtype: "message",
580
+ };
581
+
582
+ function captureUrlFetch() {
583
+ const calls: string[] = [];
584
+ const f = (async (input: string | Request | URL) => {
585
+ calls.push(String(input));
586
+ return new Response(JSON.stringify({ ok: true }), {
587
+ status: 200,
588
+ headers: { "content-type": "application/json" },
589
+ });
590
+ }) as unknown as typeof fetch;
591
+ return { calls, fetch: f };
592
+ }
593
+
594
+ test("falls back to TELEGRAM_API_BASE_URL env var when no apiBaseUrl option", async () => {
595
+ const prev = process.env["TELEGRAM_API_BASE_URL"];
596
+ process.env["TELEGRAM_API_BASE_URL"] = "https://env.telegram.local";
597
+ try {
598
+ const { calls, fetch: f } = captureUrlFetch();
599
+ const a = createTelegramAdapter({ botToken: BOT_TOKEN, secretToken: SECRET }, { fetch: f });
600
+ await a.sendReply({ event, text: "x" });
601
+ expect(calls[0]).toBe(`https://env.telegram.local/bot${BOT_TOKEN}/sendMessage`);
602
+ } finally {
603
+ if (prev === undefined) Reflect.deleteProperty(process.env, "TELEGRAM_API_BASE_URL");
604
+ else process.env["TELEGRAM_API_BASE_URL"] = prev;
605
+ }
606
+ });
607
+
608
+ test("falls back to the public Telegram API host when nothing is configured", async () => {
609
+ const prev = process.env["TELEGRAM_API_BASE_URL"];
610
+ Reflect.deleteProperty(process.env, "TELEGRAM_API_BASE_URL");
611
+ try {
612
+ const { calls, fetch: f } = captureUrlFetch();
613
+ const a = createTelegramAdapter({ botToken: BOT_TOKEN, secretToken: SECRET }, { fetch: f });
614
+ await a.sendReply({ event, text: "x" });
615
+ expect(calls[0]).toBe(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`);
616
+ } finally {
617
+ if (prev !== undefined) process.env["TELEGRAM_API_BASE_URL"] = prev;
618
+ }
619
+ });
278
620
  });