@crewhaus/channel-adapter-telegram 0.1.4 → 0.1.5

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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @crewhaus/channel-adapter-telegram — Telegram channel adapter for the
3
+ * channel target (Section 33).
4
+ *
5
+ * Implements the same `ChannelAdapter` contract used by
6
+ * @crewhaus/channel-adapter-slack — verify(), parseInbound(), sendReply(),
7
+ * setTyping(). The Section 12 daemon registers this adapter alongside
8
+ * Slack and any other channel adapter, with the gateway dispatching
9
+ * inbound webhooks to whichever adapter id matches.
10
+ *
11
+ * Verification: `X-Telegram-Bot-Api-Secret-Token` header (set via
12
+ * `setWebhook(secret_token=...)`) compared timing-safely to the configured
13
+ * secret. Telegram does NOT sign the body — the secret token alone
14
+ * authenticates.
15
+ *
16
+ * Parse: handles `message`, `edited_message`, and `callback_query`
17
+ * (button-press) Update payloads. Group-chat session keying uses
18
+ * `<chatId>:<topicId>` when topics are enabled (chat type "supergroup"
19
+ * with `message_thread_id` present); otherwise just `<chatId>`.
20
+ *
21
+ * sendReply: POST to `https://api.telegram.org/bot<token>/sendMessage`.
22
+ * setTyping: POST `sendChatAction` with `action=typing`.
23
+ * react: POST `setMessageReaction` (Bot API 7.0) — best-effort status emoji.
24
+ */
25
+ import { CrewhausError } from "@crewhaus/errors";
26
+ export { verifyTelegramSecret } from "./verify.js";
27
+ export declare class TelegramAdapterError extends CrewhausError {
28
+ readonly name = "TelegramAdapterError";
29
+ constructor(message: string, cause?: unknown);
30
+ }
31
+ export type RawRequest = {
32
+ readonly headers: Headers;
33
+ readonly body: string;
34
+ };
35
+ /**
36
+ * Channel-generic inbound event. Same shape as the Slack adapter's
37
+ * `InboundEvent` so the gateway and session-router stay channel-agnostic.
38
+ *
39
+ * Mappings:
40
+ * - workspaceId → chat.id (Telegram has no "workspace"; we reuse the
41
+ * field for chat-level grouping)
42
+ * - channelId → chat.id : topicId (group-chat thread scope) or
43
+ * chat.id alone for private/group chats
44
+ * - userId → from.id (numeric, stringified)
45
+ * - threadTs → message_thread_id when present
46
+ * - ts → message_id (stringified) — monotonic per chat
47
+ * - text → message.text or callback_query.data
48
+ * - subtype → "message" | "app_mention"
49
+ * - idempotencyKey → update_id (stringified)
50
+ */
51
+ export type InboundEvent = {
52
+ readonly idempotencyKey: string;
53
+ readonly workspaceId: string;
54
+ readonly channelId: string;
55
+ readonly userId: string;
56
+ readonly threadTs?: string;
57
+ readonly ts: string;
58
+ readonly text: string;
59
+ readonly subtype: "app_mention" | "message";
60
+ };
61
+ export type ParsedInbound = {
62
+ readonly kind: "event";
63
+ readonly event: InboundEvent;
64
+ } | {
65
+ readonly kind: "skip";
66
+ };
67
+ export interface ChannelAdapter {
68
+ readonly id: string;
69
+ verify(req: RawRequest): boolean;
70
+ parseInbound(req: RawRequest): ParsedInbound;
71
+ sendReply(args: {
72
+ event: InboundEvent;
73
+ text: string;
74
+ }): Promise<void>;
75
+ setTyping(args: {
76
+ event: InboundEvent;
77
+ }): Promise<void>;
78
+ /**
79
+ * Phase 3 §3.2 — add an emoji reaction to an inbound message as a
80
+ * lightweight status acknowledgement (eyes/white_check_mark/warning).
81
+ * Telegram restricts reactions to a fixed emoji set, so the channel-generic
82
+ * status names are mapped to the nearest allowed reaction. Optional — the
83
+ * session-router skips the hook when an adapter leaves it undefined.
84
+ */
85
+ react?(args: {
86
+ event: InboundEvent;
87
+ emoji: string;
88
+ }): Promise<void>;
89
+ }
90
+ export type TelegramAdapterConfig = {
91
+ readonly botToken: string;
92
+ readonly secretToken: string;
93
+ };
94
+ export type TelegramAdapterOptions = {
95
+ readonly apiBaseUrl?: string;
96
+ readonly fetch?: typeof fetch;
97
+ readonly selfBotId?: string;
98
+ };
99
+ export declare function createTelegramAdapter(config: TelegramAdapterConfig, opts?: TelegramAdapterOptions): ChannelAdapter;
100
+ export type TelegramUpdate = {
101
+ readonly update_id: number;
102
+ readonly message?: TelegramMessage;
103
+ readonly edited_message?: TelegramMessage;
104
+ readonly callback_query?: TelegramCallbackQuery;
105
+ };
106
+ export type TelegramMessage = {
107
+ readonly message_id: number;
108
+ readonly from?: TelegramUser;
109
+ readonly chat: TelegramChat;
110
+ readonly text?: string;
111
+ readonly caption?: string;
112
+ readonly message_thread_id?: number;
113
+ readonly entities?: ReadonlyArray<TelegramMessageEntity>;
114
+ };
115
+ export type TelegramUser = {
116
+ readonly id: number;
117
+ readonly is_bot?: boolean;
118
+ readonly username?: string;
119
+ };
120
+ export type TelegramChat = {
121
+ readonly id: number;
122
+ readonly type?: "private" | "group" | "supergroup" | "channel";
123
+ };
124
+ export type TelegramMessageEntity = {
125
+ readonly type: string;
126
+ readonly offset?: number;
127
+ readonly length?: number;
128
+ };
129
+ export type TelegramCallbackQuery = {
130
+ readonly id: string;
131
+ readonly from: TelegramUser;
132
+ readonly data?: string;
133
+ readonly message?: TelegramMessage;
134
+ };
package/dist/index.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @crewhaus/channel-adapter-telegram — Telegram channel adapter for the
3
+ * channel target (Section 33).
4
+ *
5
+ * Implements the same `ChannelAdapter` contract used by
6
+ * @crewhaus/channel-adapter-slack — verify(), parseInbound(), sendReply(),
7
+ * setTyping(). The Section 12 daemon registers this adapter alongside
8
+ * Slack and any other channel adapter, with the gateway dispatching
9
+ * inbound webhooks to whichever adapter id matches.
10
+ *
11
+ * Verification: `X-Telegram-Bot-Api-Secret-Token` header (set via
12
+ * `setWebhook(secret_token=...)`) compared timing-safely to the configured
13
+ * secret. Telegram does NOT sign the body — the secret token alone
14
+ * authenticates.
15
+ *
16
+ * Parse: handles `message`, `edited_message`, and `callback_query`
17
+ * (button-press) Update payloads. Group-chat session keying uses
18
+ * `<chatId>:<topicId>` when topics are enabled (chat type "supergroup"
19
+ * with `message_thread_id` present); otherwise just `<chatId>`.
20
+ *
21
+ * sendReply: POST to `https://api.telegram.org/bot<token>/sendMessage`.
22
+ * setTyping: POST `sendChatAction` with `action=typing`.
23
+ * react: POST `setMessageReaction` (Bot API 7.0) — best-effort status emoji.
24
+ */
25
+ import { CrewhausError } from "@crewhaus/errors";
26
+ import { verifyTelegramSecret } from "./verify.js";
27
+ export { verifyTelegramSecret } from "./verify.js";
28
+ export class TelegramAdapterError extends CrewhausError {
29
+ name = "TelegramAdapterError";
30
+ constructor(message, cause) {
31
+ super("channel", message, cause);
32
+ }
33
+ }
34
+ const DEFAULT_API_BASE_URL = "https://api.telegram.org";
35
+ // Telegram's `setMessageReaction` only accepts emoji from a fixed allowed set
36
+ // (✅ and ⚠️ are NOT in it), so map the channel-generic status names to the
37
+ // nearest allowed reaction. An unrecognised name is passed through verbatim.
38
+ const TELEGRAM_REACTION_EMOJI = {
39
+ eyes: "👀",
40
+ white_check_mark: "👍",
41
+ warning: "😱",
42
+ };
43
+ export function createTelegramAdapter(config, opts = {}) {
44
+ const apiBaseUrl = opts.apiBaseUrl ?? process.env["TELEGRAM_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
45
+ const doFetch = opts.fetch ?? fetch;
46
+ const botEndpoint = (method) => `${apiBaseUrl}/bot${config.botToken}/${method}`;
47
+ function inboundFromMessage(update, msg, subtype) {
48
+ const chatId = msg.chat?.id;
49
+ const userId = msg.from?.id;
50
+ const text = msg.text ?? msg.caption ?? "";
51
+ if (chatId === undefined || userId === undefined)
52
+ return { kind: "skip" };
53
+ if (opts.selfBotId !== undefined &&
54
+ msg.from?.is_bot &&
55
+ String(msg.from.id) === opts.selfBotId) {
56
+ return { kind: "skip" };
57
+ }
58
+ const threadId = msg.message_thread_id;
59
+ const channelId = threadId !== undefined ? `${chatId}:${threadId}` : String(chatId);
60
+ const event = {
61
+ idempotencyKey: String(update.update_id),
62
+ workspaceId: String(chatId),
63
+ channelId,
64
+ userId: String(userId),
65
+ ...(threadId !== undefined ? { threadTs: String(threadId) } : {}),
66
+ ts: String(msg.message_id),
67
+ text,
68
+ subtype,
69
+ };
70
+ return { kind: "event", event };
71
+ }
72
+ return {
73
+ id: "telegram",
74
+ verify(req) {
75
+ return verifyTelegramSecret({ headers: req.headers, secretToken: config.secretToken });
76
+ },
77
+ parseInbound(req) {
78
+ let payload;
79
+ try {
80
+ payload = JSON.parse(req.body);
81
+ }
82
+ catch {
83
+ return { kind: "skip" };
84
+ }
85
+ if (typeof payload !== "object" || payload === null)
86
+ return { kind: "skip" };
87
+ const update = payload;
88
+ if (typeof update.update_id !== "number")
89
+ return { kind: "skip" };
90
+ // Determine which slot is populated. Telegram updates are mutually
91
+ // exclusive over message / edited_message / channel_post / callback_query.
92
+ if (update.message) {
93
+ const msg = update.message;
94
+ // Skip empty messages (sticker-only / photo-only / system messages)
95
+ if (!msg.text && !msg.caption)
96
+ return { kind: "skip" };
97
+ // Detect bot mention via entities (`type: "mention"` or `type: "bot_command"`).
98
+ const isMention = msg.entities?.some((e) => e.type === "mention" || e.type === "bot_command") ?? false;
99
+ return inboundFromMessage(update, msg, isMention ? "app_mention" : "message");
100
+ }
101
+ if (update.edited_message) {
102
+ // Treat edits as new inbound events (gateway dedups on update_id).
103
+ const msg = update.edited_message;
104
+ if (!msg.text && !msg.caption)
105
+ return { kind: "skip" };
106
+ return inboundFromMessage(update, msg, "message");
107
+ }
108
+ if (update.callback_query) {
109
+ const cq = update.callback_query;
110
+ const msg = cq.message;
111
+ const userId = cq.from?.id;
112
+ const data = cq.data ?? "";
113
+ if (!msg || userId === undefined)
114
+ return { kind: "skip" };
115
+ const chatId = msg.chat?.id;
116
+ if (chatId === undefined)
117
+ return { kind: "skip" };
118
+ const threadId = msg.message_thread_id;
119
+ const channelId = threadId !== undefined ? `${chatId}:${threadId}` : String(chatId);
120
+ const event = {
121
+ idempotencyKey: String(update.update_id),
122
+ workspaceId: String(chatId),
123
+ channelId,
124
+ userId: String(userId),
125
+ ...(threadId !== undefined ? { threadTs: String(threadId) } : {}),
126
+ ts: String(msg.message_id),
127
+ text: data,
128
+ subtype: "message",
129
+ };
130
+ return { kind: "event", event };
131
+ }
132
+ return { kind: "skip" };
133
+ },
134
+ async sendReply(args) {
135
+ const url = botEndpoint("sendMessage");
136
+ // Reconstruct chat_id from the workspaceId field (always the raw chat.id).
137
+ const chatId = Number.parseInt(args.event.workspaceId, 10);
138
+ if (!Number.isFinite(chatId)) {
139
+ throw new TelegramAdapterError(`invalid workspaceId in event: ${args.event.workspaceId}`);
140
+ }
141
+ const body = {
142
+ chat_id: chatId,
143
+ text: args.text,
144
+ };
145
+ const threadId = args.event.threadTs;
146
+ if (threadId) {
147
+ body["message_thread_id"] = Number.parseInt(threadId, 10);
148
+ }
149
+ const res = await doFetch(url, {
150
+ method: "POST",
151
+ headers: { "Content-Type": "application/json" },
152
+ body: JSON.stringify(body),
153
+ });
154
+ if (!res.ok) {
155
+ throw new TelegramAdapterError(`sendMessage failed: ${res.status} ${res.statusText}`);
156
+ }
157
+ const ct = res.headers.get("content-type") ?? "";
158
+ if (ct.includes("application/json")) {
159
+ const json = (await res.json());
160
+ if (json.ok === false) {
161
+ throw new TelegramAdapterError(`sendMessage error: ${json.description ?? "unknown"}`);
162
+ }
163
+ }
164
+ },
165
+ async setTyping(args) {
166
+ const url = botEndpoint("sendChatAction");
167
+ const chatId = Number.parseInt(args.event.workspaceId, 10);
168
+ if (!Number.isFinite(chatId)) {
169
+ throw new TelegramAdapterError(`invalid workspaceId in event: ${args.event.workspaceId}`);
170
+ }
171
+ const body = { chat_id: chatId, action: "typing" };
172
+ if (args.event.threadTs) {
173
+ body["message_thread_id"] = Number.parseInt(args.event.threadTs, 10);
174
+ }
175
+ await doFetch(url, {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json" },
178
+ body: JSON.stringify(body),
179
+ });
180
+ // setTyping is best-effort; a non-200 here should not fail the run.
181
+ },
182
+ // Phase 3 §3.2 — emoji reactions via Bot API 7.0 `setMessageReaction`.
183
+ // chat_id comes from the raw chat id (workspaceId); message_id from `ts`.
184
+ async react(args) {
185
+ const url = botEndpoint("setMessageReaction");
186
+ const chatId = Number.parseInt(args.event.workspaceId, 10);
187
+ const messageId = Number.parseInt(args.event.ts, 10);
188
+ if (!Number.isFinite(chatId) || !Number.isFinite(messageId)) {
189
+ throw new TelegramAdapterError(`invalid chat/message id for reaction: ${args.event.workspaceId}/${args.event.ts}`);
190
+ }
191
+ const emoji = TELEGRAM_REACTION_EMOJI[args.emoji] ?? args.emoji;
192
+ const res = await doFetch(url, {
193
+ method: "POST",
194
+ headers: { "Content-Type": "application/json" },
195
+ body: JSON.stringify({
196
+ chat_id: chatId,
197
+ message_id: messageId,
198
+ reaction: [{ type: "emoji", emoji }],
199
+ }),
200
+ });
201
+ // Reactions are best-effort — the session-router catches and continues so
202
+ // a flaky/disallowed reaction never aborts message processing.
203
+ if (!res.ok) {
204
+ throw new TelegramAdapterError(`setMessageReaction failed: ${res.status} ${res.statusText}`);
205
+ }
206
+ const ct = res.headers.get("content-type") ?? "";
207
+ if (ct.includes("application/json")) {
208
+ const json = (await res.json());
209
+ if (json.ok === false) {
210
+ throw new TelegramAdapterError(`setMessageReaction error: ${json.description ?? "unknown"}`);
211
+ }
212
+ }
213
+ },
214
+ };
215
+ }
@@ -1,5 +1,3 @@
1
- import { timingSafeEqual } from "node:crypto";
2
-
3
1
  /**
4
2
  * Telegram webhook verification.
5
3
  *
@@ -14,16 +12,7 @@ import { timingSafeEqual } from "node:crypto";
14
12
  * 1–256 chars, A–Z / a–z / 0–9 / _ / -.
15
13
  */
16
14
  export type TelegramVerifyArgs = {
17
- readonly headers: Headers;
18
- readonly secretToken: string;
15
+ readonly headers: Headers;
16
+ readonly secretToken: string;
19
17
  };
20
-
21
- export function verifyTelegramSecret(args: TelegramVerifyArgs): boolean {
22
- const supplied = args.headers.get("x-telegram-bot-api-secret-token");
23
- if (!supplied) return false;
24
- if (supplied.length !== args.secretToken.length) return false;
25
- const a = Buffer.from(supplied, "utf8");
26
- const b = Buffer.from(args.secretToken, "utf8");
27
- if (a.length !== b.length) return false;
28
- return timingSafeEqual(a, b);
29
- }
18
+ export declare function verifyTelegramSecret(args: TelegramVerifyArgs): boolean;
package/dist/verify.js ADDED
@@ -0,0 +1,13 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ export function verifyTelegramSecret(args) {
3
+ const supplied = args.headers.get("x-telegram-bot-api-secret-token");
4
+ if (!supplied)
5
+ return false;
6
+ if (supplied.length !== args.secretToken.length)
7
+ return false;
8
+ const a = Buffer.from(supplied, "utf8");
9
+ const b = Buffer.from(args.secretToken, "utf8");
10
+ if (a.length !== b.length)
11
+ return false;
12
+ return timingSafeEqual(a, b);
13
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/channel-adapter-telegram",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
@@ -1,10 +0,0 @@
1
- {
2
- "update_id": 100006,
3
- "message": {
4
- "message_id": 21,
5
- "from": { "id": 4242, "is_bot": false, "username": "alice" },
6
- "chat": { "id": -100456, "type": "supergroup" },
7
- "text": "@crewhaus_bot summarize this thread",
8
- "entities": [{ "type": "mention", "offset": 0, "length": 14 }]
9
- }
10
- }
@@ -1,9 +0,0 @@
1
- {
2
- "update_id": 100009,
3
- "message": {
4
- "message_id": 90,
5
- "from": { "id": 9999, "is_bot": true, "username": "crewhaus_bot" },
6
- "chat": { "id": -100789, "type": "supergroup" },
7
- "text": "I am the bot"
8
- }
9
- }
@@ -1,12 +0,0 @@
1
- {
2
- "update_id": 100005,
3
- "callback_query": {
4
- "id": "cq-001",
5
- "from": { "id": 4242, "is_bot": false, "username": "alice" },
6
- "data": "approve:123",
7
- "message": {
8
- "message_id": 50,
9
- "chat": { "id": 4242, "type": "private" }
10
- }
11
- }
12
- }
@@ -1,9 +0,0 @@
1
- {
2
- "update_id": 100004,
3
- "edited_message": {
4
- "message_id": 7,
5
- "from": { "id": 4242, "is_bot": false, "username": "alice" },
6
- "chat": { "id": 4242, "type": "private" },
7
- "text": "hello bot (edited)"
8
- }
9
- }
@@ -1,9 +0,0 @@
1
- {
2
- "update_id": 100002,
3
- "message": {
4
- "message_id": 12,
5
- "from": { "id": 4242, "is_bot": false, "username": "alice" },
6
- "chat": { "id": -100123, "type": "supergroup" },
7
- "text": "hi everyone"
8
- }
9
- }
@@ -1,10 +0,0 @@
1
- {
2
- "update_id": 100003,
3
- "message": {
4
- "message_id": 99,
5
- "from": { "id": 4242, "is_bot": false, "username": "alice" },
6
- "chat": { "id": -100123, "type": "supergroup" },
7
- "message_thread_id": 17,
8
- "text": "topic-scoped reply"
9
- }
10
- }
@@ -1,8 +0,0 @@
1
- {
2
- "update_id": 100010,
3
- "message": {
4
- "message_id": 200,
5
- "from": { "id": 4242, "is_bot": false, "username": "alice" },
6
- "text": "no chat field"
7
- }
8
- }
@@ -1,7 +0,0 @@
1
- {
2
- "update_id": 100011,
3
- "poll": {
4
- "id": "p-1",
5
- "question": "best agent framework?"
6
- }
7
- }
@@ -1,9 +0,0 @@
1
- {
2
- "update_id": 100008,
3
- "message": {
4
- "message_id": 33,
5
- "from": { "id": 4242, "is_bot": false, "username": "alice" },
6
- "chat": { "id": 4242, "type": "private" },
7
- "caption": "look at this graph"
8
- }
9
- }
@@ -1,9 +0,0 @@
1
- {
2
- "update_id": 100001,
3
- "message": {
4
- "message_id": 7,
5
- "from": { "id": 4242, "is_bot": false, "username": "alice" },
6
- "chat": { "id": 4242, "type": "private" },
7
- "text": "hello bot"
8
- }
9
- }
@@ -1,8 +0,0 @@
1
- {
2
- "update_id": 100007,
3
- "message": {
4
- "message_id": 88,
5
- "from": { "id": 4242, "is_bot": false, "username": "alice" },
6
- "chat": { "id": 4242, "type": "private" }
7
- }
8
- }