@crewhaus/channel-adapter-telegram 0.1.3 → 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.
package/src/index.ts DELETED
@@ -1,289 +0,0 @@
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
- */
24
- import { CrewhausError } from "@crewhaus/errors";
25
- import { verifyTelegramSecret } from "./verify.js";
26
-
27
- export { verifyTelegramSecret } from "./verify.js";
28
-
29
- export class TelegramAdapterError extends CrewhausError {
30
- override readonly name = "TelegramAdapterError";
31
- constructor(message: string, cause?: unknown) {
32
- super("channel", message, cause);
33
- }
34
- }
35
-
36
- export type RawRequest = {
37
- readonly headers: Headers;
38
- readonly body: string;
39
- };
40
-
41
- /**
42
- * Channel-generic inbound event. Same shape as the Slack adapter's
43
- * `InboundEvent` so the gateway and session-router stay channel-agnostic.
44
- *
45
- * Mappings:
46
- * - workspaceId → chat.id (Telegram has no "workspace"; we reuse the
47
- * field for chat-level grouping)
48
- * - channelId → chat.id : topicId (group-chat thread scope) or
49
- * chat.id alone for private/group chats
50
- * - userId → from.id (numeric, stringified)
51
- * - threadTs → message_thread_id when present
52
- * - ts → message_id (stringified) — monotonic per chat
53
- * - text → message.text or callback_query.data
54
- * - subtype → "message" | "app_mention"
55
- * - idempotencyKey → update_id (stringified)
56
- */
57
- export type InboundEvent = {
58
- readonly idempotencyKey: string;
59
- readonly workspaceId: string;
60
- readonly channelId: string;
61
- readonly userId: string;
62
- readonly threadTs?: string;
63
- readonly ts: string;
64
- readonly text: string;
65
- readonly subtype: "app_mention" | "message";
66
- };
67
-
68
- export type ParsedInbound =
69
- | { readonly kind: "event"; readonly event: InboundEvent }
70
- | { readonly kind: "skip" };
71
-
72
- export interface ChannelAdapter {
73
- readonly id: string;
74
- verify(req: RawRequest): boolean;
75
- parseInbound(req: RawRequest): ParsedInbound;
76
- sendReply(args: { event: InboundEvent; text: string }): Promise<void>;
77
- setTyping(args: { event: InboundEvent }): Promise<void>;
78
- }
79
-
80
- export type TelegramAdapterConfig = {
81
- readonly botToken: string;
82
- readonly secretToken: string;
83
- };
84
-
85
- export type TelegramAdapterOptions = {
86
- readonly apiBaseUrl?: string;
87
- readonly fetch?: typeof fetch;
88
- readonly selfBotId?: string;
89
- };
90
-
91
- const DEFAULT_API_BASE_URL = "https://api.telegram.org";
92
-
93
- export function createTelegramAdapter(
94
- config: TelegramAdapterConfig,
95
- opts: TelegramAdapterOptions = {},
96
- ): ChannelAdapter {
97
- const apiBaseUrl =
98
- opts.apiBaseUrl ?? process.env["TELEGRAM_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
99
- const doFetch = opts.fetch ?? fetch;
100
- const botEndpoint = (method: string) => `${apiBaseUrl}/bot${config.botToken}/${method}`;
101
-
102
- function inboundFromMessage(
103
- update: TelegramUpdate,
104
- msg: TelegramMessage,
105
- subtype: "message" | "app_mention",
106
- ): ParsedInbound {
107
- const chatId = msg.chat?.id;
108
- const userId = msg.from?.id;
109
- const text = msg.text ?? msg.caption ?? "";
110
- if (chatId === undefined || userId === undefined) return { kind: "skip" };
111
- if (
112
- opts.selfBotId !== undefined &&
113
- msg.from?.is_bot &&
114
- String(msg.from.id) === opts.selfBotId
115
- ) {
116
- return { kind: "skip" };
117
- }
118
- const threadId = msg.message_thread_id;
119
- const channelId = threadId !== undefined ? `${chatId}:${threadId}` : String(chatId);
120
- const event: InboundEvent = {
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,
128
- subtype,
129
- };
130
- return { kind: "event", event };
131
- }
132
-
133
- return {
134
- id: "telegram",
135
-
136
- verify(req: RawRequest): boolean {
137
- return verifyTelegramSecret({ headers: req.headers, secretToken: config.secretToken });
138
- },
139
-
140
- parseInbound(req: RawRequest): ParsedInbound {
141
- let payload: unknown;
142
- try {
143
- payload = JSON.parse(req.body);
144
- } catch {
145
- return { kind: "skip" };
146
- }
147
- if (typeof payload !== "object" || payload === null) return { kind: "skip" };
148
- const update = payload as TelegramUpdate;
149
- if (typeof update.update_id !== "number") return { kind: "skip" };
150
-
151
- // Determine which slot is populated. Telegram updates are mutually
152
- // exclusive over message / edited_message / channel_post / callback_query.
153
- if (update.message) {
154
- const msg = update.message;
155
- // Skip empty messages (sticker-only / photo-only / system messages)
156
- if (!msg.text && !msg.caption) return { kind: "skip" };
157
- // Detect bot mention via entities (`type: "mention"` or `type: "bot_command"`).
158
- const isMention =
159
- msg.entities?.some((e) => e.type === "mention" || e.type === "bot_command") ?? false;
160
- return inboundFromMessage(update, msg, isMention ? "app_mention" : "message");
161
- }
162
-
163
- if (update.edited_message) {
164
- // Treat edits as new inbound events (gateway dedups on update_id).
165
- const msg = update.edited_message;
166
- if (!msg.text && !msg.caption) return { kind: "skip" };
167
- return inboundFromMessage(update, msg, "message");
168
- }
169
-
170
- if (update.callback_query) {
171
- const cq = update.callback_query;
172
- const msg = cq.message;
173
- const userId = cq.from?.id;
174
- const data = cq.data ?? "";
175
- if (!msg || userId === undefined) return { kind: "skip" };
176
- const chatId = msg.chat?.id;
177
- if (chatId === undefined) return { kind: "skip" };
178
- const threadId = msg.message_thread_id;
179
- const channelId = threadId !== undefined ? `${chatId}:${threadId}` : String(chatId);
180
- const event: InboundEvent = {
181
- idempotencyKey: String(update.update_id),
182
- workspaceId: String(chatId),
183
- channelId,
184
- userId: String(userId),
185
- ...(threadId !== undefined ? { threadTs: String(threadId) } : {}),
186
- ts: String(msg.message_id),
187
- text: data,
188
- subtype: "message",
189
- };
190
- return { kind: "event", event };
191
- }
192
-
193
- return { kind: "skip" };
194
- },
195
-
196
- async sendReply(args: { event: InboundEvent; text: string }): Promise<void> {
197
- const url = botEndpoint("sendMessage");
198
- // Reconstruct chat_id from the workspaceId field (always the raw chat.id).
199
- const chatId = Number.parseInt(args.event.workspaceId, 10);
200
- if (!Number.isFinite(chatId)) {
201
- throw new TelegramAdapterError(`invalid workspaceId in event: ${args.event.workspaceId}`);
202
- }
203
- const body: Record<string, unknown> = {
204
- chat_id: chatId,
205
- text: args.text,
206
- };
207
- const threadId = args.event.threadTs;
208
- if (threadId) {
209
- body["message_thread_id"] = Number.parseInt(threadId, 10);
210
- }
211
- const res = await doFetch(url, {
212
- method: "POST",
213
- headers: { "Content-Type": "application/json" },
214
- body: JSON.stringify(body),
215
- });
216
- if (!res.ok) {
217
- throw new TelegramAdapterError(`sendMessage failed: ${res.status} ${res.statusText}`);
218
- }
219
- const ct = res.headers.get("content-type") ?? "";
220
- if (ct.includes("application/json")) {
221
- const json = (await res.json()) as { ok?: boolean; description?: string };
222
- if (json.ok === false) {
223
- throw new TelegramAdapterError(`sendMessage error: ${json.description ?? "unknown"}`);
224
- }
225
- }
226
- },
227
-
228
- async setTyping(args: { event: InboundEvent }): Promise<void> {
229
- const url = botEndpoint("sendChatAction");
230
- const chatId = Number.parseInt(args.event.workspaceId, 10);
231
- if (!Number.isFinite(chatId)) {
232
- throw new TelegramAdapterError(`invalid workspaceId in event: ${args.event.workspaceId}`);
233
- }
234
- const body: Record<string, unknown> = { chat_id: chatId, action: "typing" };
235
- if (args.event.threadTs) {
236
- body["message_thread_id"] = Number.parseInt(args.event.threadTs, 10);
237
- }
238
- await doFetch(url, {
239
- method: "POST",
240
- headers: { "Content-Type": "application/json" },
241
- body: JSON.stringify(body),
242
- });
243
- // setTyping is best-effort; a non-200 here should not fail the run.
244
- },
245
- };
246
- }
247
-
248
- // ─── Telegram Bot API minimal types (no SDK dep) ─────────────────────────────
249
-
250
- export type TelegramUpdate = {
251
- readonly update_id: number;
252
- readonly message?: TelegramMessage;
253
- readonly edited_message?: TelegramMessage;
254
- readonly callback_query?: TelegramCallbackQuery;
255
- };
256
-
257
- export type TelegramMessage = {
258
- readonly message_id: number;
259
- readonly from?: TelegramUser;
260
- readonly chat: TelegramChat;
261
- readonly text?: string;
262
- readonly caption?: string;
263
- readonly message_thread_id?: number;
264
- readonly entities?: ReadonlyArray<TelegramMessageEntity>;
265
- };
266
-
267
- export type TelegramUser = {
268
- readonly id: number;
269
- readonly is_bot?: boolean;
270
- readonly username?: string;
271
- };
272
-
273
- export type TelegramChat = {
274
- readonly id: number;
275
- readonly type?: "private" | "group" | "supergroup" | "channel";
276
- };
277
-
278
- export type TelegramMessageEntity = {
279
- readonly type: string;
280
- readonly offset?: number;
281
- readonly length?: number;
282
- };
283
-
284
- export type TelegramCallbackQuery = {
285
- readonly id: string;
286
- readonly from: TelegramUser;
287
- readonly data?: string;
288
- readonly message?: TelegramMessage;
289
- };