@crewhaus/channel-adapter-telegram 0.1.3 โ†’ 0.1.4

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": "@crewhaus/channel-adapter-telegram",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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,7 +12,7 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.1.3"
15
+ "@crewhaus/errors": "0.1.4"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
package/src/index.test.ts CHANGED
@@ -568,6 +568,82 @@ describe("sendReply / setTyping (T3)", () => {
568
568
  });
569
569
  });
570
570
 
571
+ describe("react (Phase 3 ยง3.2)", () => {
572
+ function captureFetch() {
573
+ const calls: Array<{ url: string; init: RequestInit }> = [];
574
+ const f = (async (input: string | Request | URL, init?: RequestInit) => {
575
+ calls.push({ url: String(input), init: init ?? {} });
576
+ return new Response(JSON.stringify({ ok: true, result: true }), {
577
+ status: 200,
578
+ headers: { "content-type": "application/json" },
579
+ });
580
+ }) as unknown as typeof fetch;
581
+ return { calls, fetch: f };
582
+ }
583
+
584
+ const event: InboundEvent = {
585
+ idempotencyKey: "100001",
586
+ workspaceId: "4242",
587
+ channelId: "4242",
588
+ userId: "4242",
589
+ ts: "7",
590
+ text: "hello",
591
+ subtype: "message",
592
+ };
593
+
594
+ test("posts setMessageReaction with chat_id, message_id, and a mapped allowed emoji", async () => {
595
+ const { calls, fetch: f } = captureFetch();
596
+ const a = createTelegramAdapter(
597
+ { botToken: BOT_TOKEN, secretToken: SECRET },
598
+ { apiBaseUrl: "https://test.telegram.local", fetch: f },
599
+ );
600
+ expect(a.react).toBeDefined();
601
+ await a.react?.({ event, emoji: "eyes" });
602
+ expect(calls.length).toBe(1);
603
+ expect(calls[0]?.url).toBe(`https://test.telegram.local/bot${BOT_TOKEN}/setMessageReaction`);
604
+ const body = JSON.parse(String(calls[0]?.init.body));
605
+ expect(body.chat_id).toBe(4242);
606
+ expect(body.message_id).toBe(7);
607
+ expect(body.reaction).toEqual([{ type: "emoji", emoji: "๐Ÿ‘€" }]);
608
+ });
609
+
610
+ test("maps white_check_mark/warning to Telegram-allowed emoji", async () => {
611
+ const { calls, fetch: f } = captureFetch();
612
+ const a = createTelegramAdapter(
613
+ { botToken: BOT_TOKEN, secretToken: SECRET },
614
+ { apiBaseUrl: "https://test.telegram.local", fetch: f },
615
+ );
616
+ await a.react?.({ event, emoji: "white_check_mark" });
617
+ await a.react?.({ event, emoji: "warning" });
618
+ expect(JSON.parse(String(calls[0]?.init.body)).reaction[0].emoji).toBe("๐Ÿ‘");
619
+ expect(JSON.parse(String(calls[1]?.init.body)).reaction[0].emoji).toBe("๐Ÿ˜ฑ");
620
+ });
621
+
622
+ test("rejects when chat/message id is not numeric", async () => {
623
+ const { fetch: f } = captureFetch();
624
+ const a = createTelegramAdapter(
625
+ { botToken: BOT_TOKEN, secretToken: SECRET },
626
+ { apiBaseUrl: "https://test.telegram.local", fetch: f },
627
+ );
628
+ await expect(a.react?.({ event: { ...event, ts: "nope" }, emoji: "eyes" })).rejects.toThrow(
629
+ TelegramAdapterError,
630
+ );
631
+ });
632
+
633
+ test("throws on Telegram-side ok:false (router swallows it)", async () => {
634
+ const f = (async () =>
635
+ new Response(JSON.stringify({ ok: false, description: "REACTION_INVALID" }), {
636
+ status: 200,
637
+ headers: { "content-type": "application/json" },
638
+ })) as unknown as typeof fetch;
639
+ const a = createTelegramAdapter(
640
+ { botToken: BOT_TOKEN, secretToken: SECRET },
641
+ { apiBaseUrl: "https://test.telegram.local", fetch: f },
642
+ );
643
+ await expect(a.react?.({ event, emoji: "eyes" })).rejects.toThrow(/REACTION_INVALID/);
644
+ });
645
+ });
646
+
571
647
  describe("apiBaseUrl resolution", () => {
572
648
  const event: InboundEvent = {
573
649
  idempotencyKey: "1",
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * sendReply: POST to `https://api.telegram.org/bot<token>/sendMessage`.
22
22
  * setTyping: POST `sendChatAction` with `action=typing`.
23
+ * react: POST `setMessageReaction` (Bot API 7.0) โ€” best-effort status emoji.
23
24
  */
24
25
  import { CrewhausError } from "@crewhaus/errors";
25
26
  import { verifyTelegramSecret } from "./verify.js";
@@ -75,6 +76,14 @@ export interface ChannelAdapter {
75
76
  parseInbound(req: RawRequest): ParsedInbound;
76
77
  sendReply(args: { event: InboundEvent; text: string }): Promise<void>;
77
78
  setTyping(args: { event: InboundEvent }): Promise<void>;
79
+ /**
80
+ * Phase 3 ยง3.2 โ€” add an emoji reaction to an inbound message as a
81
+ * lightweight status acknowledgement (eyes/white_check_mark/warning).
82
+ * Telegram restricts reactions to a fixed emoji set, so the channel-generic
83
+ * status names are mapped to the nearest allowed reaction. Optional โ€” the
84
+ * session-router skips the hook when an adapter leaves it undefined.
85
+ */
86
+ react?(args: { event: InboundEvent; emoji: string }): Promise<void>;
78
87
  }
79
88
 
80
89
  export type TelegramAdapterConfig = {
@@ -90,6 +99,15 @@ export type TelegramAdapterOptions = {
90
99
 
91
100
  const DEFAULT_API_BASE_URL = "https://api.telegram.org";
92
101
 
102
+ // Telegram's `setMessageReaction` only accepts emoji from a fixed allowed set
103
+ // (โœ… and โš ๏ธ are NOT in it), so map the channel-generic status names to the
104
+ // nearest allowed reaction. An unrecognised name is passed through verbatim.
105
+ const TELEGRAM_REACTION_EMOJI: Record<string, string> = {
106
+ eyes: "๐Ÿ‘€",
107
+ white_check_mark: "๐Ÿ‘",
108
+ warning: "๐Ÿ˜ฑ",
109
+ };
110
+
93
111
  export function createTelegramAdapter(
94
112
  config: TelegramAdapterConfig,
95
113
  opts: TelegramAdapterOptions = {},
@@ -242,6 +260,45 @@ export function createTelegramAdapter(
242
260
  });
243
261
  // setTyping is best-effort; a non-200 here should not fail the run.
244
262
  },
263
+
264
+ // Phase 3 ยง3.2 โ€” emoji reactions via Bot API 7.0 `setMessageReaction`.
265
+ // chat_id comes from the raw chat id (workspaceId); message_id from `ts`.
266
+ async react(args: { event: InboundEvent; emoji: string }): Promise<void> {
267
+ const url = botEndpoint("setMessageReaction");
268
+ const chatId = Number.parseInt(args.event.workspaceId, 10);
269
+ const messageId = Number.parseInt(args.event.ts, 10);
270
+ if (!Number.isFinite(chatId) || !Number.isFinite(messageId)) {
271
+ throw new TelegramAdapterError(
272
+ `invalid chat/message id for reaction: ${args.event.workspaceId}/${args.event.ts}`,
273
+ );
274
+ }
275
+ const emoji = TELEGRAM_REACTION_EMOJI[args.emoji] ?? args.emoji;
276
+ const res = await doFetch(url, {
277
+ method: "POST",
278
+ headers: { "Content-Type": "application/json" },
279
+ body: JSON.stringify({
280
+ chat_id: chatId,
281
+ message_id: messageId,
282
+ reaction: [{ type: "emoji", emoji }],
283
+ }),
284
+ });
285
+ // Reactions are best-effort โ€” the session-router catches and continues so
286
+ // a flaky/disallowed reaction never aborts message processing.
287
+ if (!res.ok) {
288
+ throw new TelegramAdapterError(
289
+ `setMessageReaction failed: ${res.status} ${res.statusText}`,
290
+ );
291
+ }
292
+ const ct = res.headers.get("content-type") ?? "";
293
+ if (ct.includes("application/json")) {
294
+ const json = (await res.json()) as { ok?: boolean; description?: string };
295
+ if (json.ok === false) {
296
+ throw new TelegramAdapterError(
297
+ `setMessageReaction error: ${json.description ?? "unknown"}`,
298
+ );
299
+ }
300
+ }
301
+ },
245
302
  };
246
303
  }
247
304