@nubemclaw/channel-telegram 1.2.2

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,287 @@
1
+ import type {
2
+ ChannelDeps,
3
+ ChannelInboundEvent,
4
+ ChannelLogger,
5
+ ChannelStateStore,
6
+ } from "@nubemclaw/channel-sdk";
7
+ import { Bot } from "grammy";
8
+ import { describe, expect, it, vi } from "vitest";
9
+
10
+ import { createTelegramChannel } from "./channel.js";
11
+
12
+ /**
13
+ * The channel-level tests focus on the **inbound normalization** —
14
+ * allowlist drops, non-private chats, missing text, etc. The grammy
15
+ * Bot itself is exercised by grammy's own tests; we only need to feed
16
+ * a constructed update into our handler and verify what reaches
17
+ * `onInbound`. The `botFactory` seam lets us swap in a fake Bot that
18
+ * exposes `.on("message", handler)` so we can drive updates directly.
19
+ *
20
+ * The polling/webhook lifecycle (`start`, `stop`, `webhookRegistration`)
21
+ * is covered by the live test (#150). Unit-testing grammy's polling
22
+ * loop here would just reimplement grammy's own tests.
23
+ */
24
+
25
+ const silentLogger: ChannelLogger = {
26
+ info: () => {},
27
+ warn: () => {},
28
+ error: () => {},
29
+ debug: () => {},
30
+ };
31
+
32
+ const stubState: ChannelStateStore = {
33
+ async get() {
34
+ return undefined;
35
+ },
36
+ async set() {},
37
+ async delete() {},
38
+ };
39
+
40
+ interface BotStub {
41
+ readonly api: {
42
+ config: { use: ReturnType<typeof vi.fn> };
43
+ sendMessage: ReturnType<typeof vi.fn>;
44
+ setWebhook: ReturnType<typeof vi.fn>;
45
+ deleteWebhook: ReturnType<typeof vi.fn>;
46
+ };
47
+ messageHandler: ((ctx: unknown) => Promise<void>) | null;
48
+ on(event: string, handler: (ctx: unknown) => Promise<void>): void;
49
+ start: ReturnType<typeof vi.fn>;
50
+ stop: ReturnType<typeof vi.fn>;
51
+ }
52
+
53
+ const makeBotStub = (): BotStub => {
54
+ const stub: BotStub = {
55
+ api: {
56
+ config: { use: vi.fn() },
57
+ sendMessage: vi.fn(async () => ({})),
58
+ setWebhook: vi.fn(async () => true),
59
+ deleteWebhook: vi.fn(async () => true),
60
+ },
61
+ messageHandler: null,
62
+ on(event, handler) {
63
+ if (event === "message") stub.messageHandler = handler;
64
+ },
65
+ start: vi.fn(async () => {}),
66
+ stop: vi.fn(async () => {}),
67
+ };
68
+ return stub;
69
+ };
70
+
71
+ const fakeBotFactory = (stub: BotStub) =>
72
+ ((_token: string, _fetcher: typeof globalThis.fetch | undefined) => stub) as unknown as (
73
+ token: string,
74
+ fetcher: typeof globalThis.fetch | undefined,
75
+ ) => Bot;
76
+
77
+ const baseConfig = {
78
+ allowedUsers: ["100"],
79
+ mode: "polling" as const,
80
+ polling: { timeoutSec: 50 },
81
+ };
82
+
83
+ describe("TelegramChannel inbound normalization", () => {
84
+ const setup = async (): Promise<{
85
+ stub: BotStub;
86
+ received: ChannelInboundEvent[];
87
+ feedMessage: (update: unknown) => Promise<void>;
88
+ }> => {
89
+ const stub = makeBotStub();
90
+ const received: ChannelInboundEvent[] = [];
91
+ const deps: ChannelDeps = {
92
+ logger: silentLogger,
93
+ state: stubState,
94
+ allowlist: { allows: () => true, size: 0 }, // unused — channel uses its own allowlist
95
+ onInbound: async (event) => {
96
+ received.push(event);
97
+ },
98
+ };
99
+ const channel = createTelegramChannel({
100
+ token: "x:y",
101
+ config: baseConfig,
102
+ botFactory: fakeBotFactory(stub),
103
+ });
104
+ await channel.init(deps);
105
+ if (stub.messageHandler === null) {
106
+ throw new Error("channel never registered a message handler");
107
+ }
108
+ const handler = stub.messageHandler;
109
+ return {
110
+ stub,
111
+ received,
112
+ feedMessage: async (update: unknown) => {
113
+ await handler({ update });
114
+ },
115
+ };
116
+ };
117
+
118
+ const buildUpdate = (overrides: Record<string, unknown> = {}): unknown => ({
119
+ update_id: 1,
120
+ message: {
121
+ message_id: 1,
122
+ date: 1,
123
+ chat: { id: 999, type: "private" },
124
+ from: { id: 100, is_bot: false, username: "alice" },
125
+ text: "hello",
126
+ ...overrides,
127
+ },
128
+ });
129
+
130
+ it("delivers a private-chat text message from an allowlisted sender", async () => {
131
+ const { received, feedMessage } = await setup();
132
+ await feedMessage(buildUpdate());
133
+ expect(received).toHaveLength(1);
134
+ expect(received[0]).toMatchObject({
135
+ channel: "telegram",
136
+ target: { kind: "chat", id: "999" },
137
+ sender: { externalId: "100", displayName: "alice" },
138
+ content: { type: "text", text: "hello" },
139
+ });
140
+ });
141
+
142
+ it("drops messages from non-allowlisted senders silently", async () => {
143
+ const { received, feedMessage } = await setup();
144
+ await feedMessage(buildUpdate({ from: { id: 200, is_bot: false, username: "mallory" } }));
145
+ expect(received).toEqual([]);
146
+ });
147
+
148
+ it("drops messages from group chats (chat.type !== 'private') silently", async () => {
149
+ const { received, feedMessage } = await setup();
150
+ await feedMessage(
151
+ buildUpdate({
152
+ chat: { id: -1001, type: "supergroup", title: "Team" },
153
+ }),
154
+ );
155
+ expect(received).toEqual([]);
156
+ });
157
+
158
+ it("drops messages without `text` silently", async () => {
159
+ const { received, feedMessage } = await setup();
160
+ const update = buildUpdate();
161
+ const msg = (update as { message: Record<string, unknown> }).message;
162
+ delete msg["text"];
163
+ await feedMessage(update);
164
+ expect(received).toEqual([]);
165
+ });
166
+
167
+ it("drops schema-invalid updates silently (does not crash the handler)", async () => {
168
+ const { received, feedMessage } = await setup();
169
+ // Garbage shape — should be parsed by zod and rejected.
170
+ await feedMessage({ not_an_update: true });
171
+ expect(received).toEqual([]);
172
+ });
173
+ });
174
+
175
+ describe("TelegramChannel apiThrottler integration", () => {
176
+ it("composes apiThrottler on bot.api.config during init (rate-limit applied transparently)", async () => {
177
+ const stub = makeBotStub();
178
+ const deps: ChannelDeps = {
179
+ logger: silentLogger,
180
+ state: stubState,
181
+ allowlist: { allows: () => true, size: 0 },
182
+ onInbound: async () => {},
183
+ };
184
+ const channel = createTelegramChannel({
185
+ token: "x:y",
186
+ config: baseConfig,
187
+ botFactory: fakeBotFactory(stub),
188
+ });
189
+ await channel.init(deps);
190
+ expect(stub.api.config.use).toHaveBeenCalledOnce();
191
+ });
192
+ });
193
+
194
+ describe("TelegramChannel send", () => {
195
+ it("forwards to bot.api.sendMessage with the parsed chat id", async () => {
196
+ const stub = makeBotStub();
197
+ const deps: ChannelDeps = {
198
+ logger: silentLogger,
199
+ state: stubState,
200
+ allowlist: { allows: () => true, size: 0 },
201
+ onInbound: async () => {},
202
+ };
203
+ const channel = createTelegramChannel({
204
+ token: "x:y",
205
+ config: baseConfig,
206
+ botFactory: fakeBotFactory(stub),
207
+ });
208
+ await channel.init(deps);
209
+ await channel.send({ kind: "chat", id: "42" }, { type: "text", text: "hello" });
210
+ expect(stub.api.sendMessage).toHaveBeenCalledWith(42, "hello");
211
+ });
212
+
213
+ it("rejects non-numeric chat id with a clear error", async () => {
214
+ const stub = makeBotStub();
215
+ const deps: ChannelDeps = {
216
+ logger: silentLogger,
217
+ state: stubState,
218
+ allowlist: { allows: () => true, size: 0 },
219
+ onInbound: async () => {},
220
+ };
221
+ const channel = createTelegramChannel({
222
+ token: "x:y",
223
+ config: baseConfig,
224
+ botFactory: fakeBotFactory(stub),
225
+ });
226
+ await channel.init(deps);
227
+ await expect(
228
+ channel.send({ kind: "chat", id: "not-a-number" }, { type: "text", text: "x" }),
229
+ ).rejects.toThrow(/invalid chat id/);
230
+ });
231
+ });
232
+
233
+ describe("TelegramChannel webhook mode", () => {
234
+ it("returns a WebhookRegistration that verifies the secret token header", async () => {
235
+ const stub = makeBotStub();
236
+ const deps: ChannelDeps = {
237
+ logger: silentLogger,
238
+ state: stubState,
239
+ allowlist: { allows: () => true, size: 0 },
240
+ onInbound: async () => {},
241
+ };
242
+ const channel = createTelegramChannel({
243
+ token: "x:y",
244
+ config: {
245
+ ...baseConfig,
246
+ mode: "webhook",
247
+ webhook: { publicUrl: "https://example.test/wh", secretToken: "s3cr3t" },
248
+ },
249
+ botFactory: fakeBotFactory(stub),
250
+ });
251
+ await channel.init(deps);
252
+ const reg = channel.webhookRegistration?.();
253
+ expect(reg).toBeDefined();
254
+ expect(reg?.path).toBe("/v1/channels/telegram/webhook");
255
+
256
+ // Wrong secret → 401.
257
+ const bad = await reg?.process({
258
+ headers: { "x-telegram-bot-api-secret-token": "wrong" },
259
+ rawBody: Buffer.from("{}", "utf8"),
260
+ });
261
+ expect(bad?.status).toBe(401);
262
+
263
+ // Right secret → 200.
264
+ const good = await reg?.process({
265
+ headers: { "x-telegram-bot-api-secret-token": "s3cr3t" },
266
+ rawBody: Buffer.from("{}", "utf8"),
267
+ });
268
+ expect(good?.status).toBe(200);
269
+ });
270
+
271
+ it("polling mode returns no webhook registration", async () => {
272
+ const stub = makeBotStub();
273
+ const deps: ChannelDeps = {
274
+ logger: silentLogger,
275
+ state: stubState,
276
+ allowlist: { allows: () => true, size: 0 },
277
+ onInbound: async () => {},
278
+ };
279
+ const channel = createTelegramChannel({
280
+ token: "x:y",
281
+ config: baseConfig, // polling
282
+ botFactory: fakeBotFactory(stub),
283
+ });
284
+ await channel.init(deps);
285
+ expect(channel.webhookRegistration?.()).toBeUndefined();
286
+ });
287
+ });
package/src/channel.ts ADDED
@@ -0,0 +1,345 @@
1
+ import { apiThrottler } from "@grammyjs/transformer-throttler";
2
+ import {
3
+ createAllowlist,
4
+ createWebhookTransport,
5
+ webhookVerifyOk,
6
+ type Channel,
7
+ type ChannelCapabilities,
8
+ type ChannelDeps,
9
+ type ChannelInboundEvent,
10
+ type ChannelMessage,
11
+ type ChannelTarget,
12
+ type WebhookInbound,
13
+ type WebhookRegistration,
14
+ } from "@nubemclaw/channel-sdk";
15
+ import { err, nubemClawError } from "@nubemclaw/core";
16
+ import { Bot, type Context } from "grammy";
17
+
18
+ import { TelegramUpdateSchema, type TelegramUpdate } from "./api/schemas.js";
19
+ import type { TelegramChannelConfig } from "./config.js";
20
+ import { createTelegramTransport, type TelegramTransport } from "./transport.js";
21
+
22
+ /**
23
+ * Telegram channel implementation (Phase 13, ADR-0020 §1 — library
24
+ * route). Wraps **grammy** for the Bot API + long-polling loop, and
25
+ * `@grammyjs/transformer-throttler` for outbound rate-limit. The
26
+ * channel does NOT use the SDK's `PollingTransport` because grammy
27
+ * already owns the polling lifecycle (via `bot.start()`).
28
+ *
29
+ * Precedent: OpenClaw `extensions/telegram/` uses the same stack
30
+ * (`grammy@1.42.0` + `@grammyjs/transformer-throttler@1.2.1`); pinned
31
+ * to the same versions to share the upstream rodaje. Patterns adapted
32
+ * from OpenClaw `bot.runtime.ts:1-4` and `account-throttler.ts:1-21`.
33
+ *
34
+ * Three behaviours the channel guarantees (unchanged from the original
35
+ * raw implementation):
36
+ *
37
+ * 1. **Inbound is private-chat, text-only, allowlisted.** Updates
38
+ * from groups (chat.type !== "private"), media-only messages
39
+ * (no `text`), or senders not in `allowedUsers` are silently
40
+ * dropped with a debug log. F13's scope (ADR-0020 §8) is
41
+ * private-chat text; any other shape just doesn't reach the
42
+ * agent loop. Telegram receives `allowed_updates: ["message"]`
43
+ * so non-message updates never reach us in the first place.
44
+ *
45
+ * 2. **Outbound respects Telegram rate limits via apiThrottler.**
46
+ * `@grammyjs/transformer-throttler` is installed on the bot's
47
+ * api config — it honors the `retry_after` Telegram returns on
48
+ * 429 automatically (no manual bucket needed; OpenClaw's
49
+ * `account-throttler.ts` confirms this pattern).
50
+ *
51
+ * 3. **Mode transition is clean.** Polling mode calls
52
+ * `bot.api.deleteWebhook()` on start (Telegram rejects with 409
53
+ * if `getUpdates` runs while a webhook is registered). Webhook
54
+ * mode calls `bot.api.setWebhook(...)` with the secret token we
55
+ * verify against.
56
+ */
57
+
58
+ export interface CreateTelegramChannelOptions {
59
+ readonly token: string;
60
+ readonly config: TelegramChannelConfig;
61
+ /**
62
+ * Test seam: override the Bot constructor (the tests substitute a
63
+ * thin fake that records calls and serves canned `getMe` /
64
+ * `getUpdates` responses). Default is grammy's `Bot` configured
65
+ * with the operational transport (see `createTelegramTransport`).
66
+ */
67
+ readonly botFactory?: (token: string, fetcher: typeof globalThis.fetch | undefined) => Bot;
68
+ /**
69
+ * Test seam: override the operational transport. Tests pass
70
+ * `undefined` to let grammy use its default `globalThis.fetch`
71
+ * (which under Vitest's environment is fine — production paths get
72
+ * the hardened transport).
73
+ */
74
+ readonly transport?: TelegramTransport | null;
75
+ }
76
+
77
+ const CHANNEL_ID = "telegram" as const;
78
+ const ALLOWED_UPDATES = ["message"] as const;
79
+
80
+ const CAPABILITIES: ChannelCapabilities = {
81
+ transports: ["polling", "webhook"],
82
+ mediaTypes: ["text"],
83
+ supportsGroups: false,
84
+ rateLimits: {
85
+ // apiThrottler honors Telegram's documented limits + retry_after
86
+ // automatically — these values stay in capabilities for operator
87
+ // visibility, not for enforcement.
88
+ globalPerSec: 30,
89
+ perTargetPerMin: 20,
90
+ },
91
+ };
92
+
93
+ export const createTelegramChannel = (opts: CreateTelegramChannelOptions): Channel => {
94
+ let deps: ChannelDeps | null = null;
95
+ let bot: Bot | null = null;
96
+ let pollingPromise: Promise<void> | null = null;
97
+ let webhookReg: WebhookRegistration | null = null;
98
+ // The operational transport owns its undici Agents; the channel
99
+ // owns this reference so `stop()` can release dispatchers cleanly.
100
+ let ownedTransport: TelegramTransport | null = null;
101
+ const allowlist = createAllowlist(opts.config.allowedUsers);
102
+
103
+ const requireDeps = (): ChannelDeps => {
104
+ if (deps === null) throw new Error("telegram channel: not initialized — call init() first");
105
+ return deps;
106
+ };
107
+ const requireBot = (): Bot => {
108
+ if (bot === null) throw new Error("telegram channel: not initialized — call init() first");
109
+ return bot;
110
+ };
111
+
112
+ /**
113
+ * Normalize a grammy `Context` (or a raw `TelegramUpdate` from the
114
+ * webhook path) into the SDK's `ChannelInboundEvent`. Returns
115
+ * `undefined` for any update F13 intentionally ignores. The caller
116
+ * logs the drop reason at debug level — no user-facing log at info
117
+ * because a chatty rejected user would spam the logs.
118
+ */
119
+ const normalize = (
120
+ update: TelegramUpdate,
121
+ receivedAt: string,
122
+ ): ChannelInboundEvent | undefined => {
123
+ const msg = update.message;
124
+ if (msg === undefined) return undefined;
125
+ if (msg.chat.type !== "private") {
126
+ requireDeps().logger.debug(
127
+ { channel: CHANNEL_ID, chatType: msg.chat.type },
128
+ "telegram inbound from non-private chat — dropping",
129
+ );
130
+ return undefined;
131
+ }
132
+ if (msg.text === undefined || msg.text === "") return undefined;
133
+ const sender = msg.from;
134
+ if (sender === undefined) return undefined;
135
+ const externalId = String(sender.id);
136
+ if (!allowlist.allows(externalId)) {
137
+ requireDeps().logger.info(
138
+ { channel: CHANNEL_ID },
139
+ "telegram inbound from non-allowed user rejected",
140
+ );
141
+ return undefined;
142
+ }
143
+ return {
144
+ channel: CHANNEL_ID,
145
+ target: { kind: "chat", id: String(msg.chat.id) },
146
+ sender: {
147
+ externalId,
148
+ ...(sender.username !== undefined ? { displayName: sender.username } : {}),
149
+ },
150
+ content: { type: "text", text: msg.text },
151
+ receivedAt,
152
+ raw: update,
153
+ };
154
+ };
155
+
156
+ const dispatchUpdate = async (update: TelegramUpdate): Promise<void> => {
157
+ const event = normalize(update, new Date().toISOString());
158
+ if (event === undefined) return;
159
+ await requireDeps().onInbound(event);
160
+ };
161
+
162
+ return {
163
+ id: CHANNEL_ID,
164
+ capabilities: CAPABILITIES,
165
+
166
+ async init(d) {
167
+ deps = d;
168
+ // Operational transport: opts.transport === undefined → build
169
+ // the hardened default (undici Agent + allowH2:false + bounded
170
+ // pool + IPv4 fallback + proxy support). opts.transport === null
171
+ // → caller explicitly disables it (tests letting grammy use the
172
+ // global fetch). Either way, the result is the fetcher passed
173
+ // to grammy's `Bot` constructor.
174
+ if (opts.transport !== null) {
175
+ ownedTransport = opts.transport ?? createTelegramTransport({ logger: d.logger });
176
+ }
177
+ const fetcher = ownedTransport?.fetch;
178
+ const factory =
179
+ opts.botFactory ??
180
+ ((token: string, f: typeof globalThis.fetch | undefined) =>
181
+ f !== undefined ? new Bot(token, { client: { fetch: f as never } }) : new Bot(token));
182
+ bot = factory(opts.token, fetcher);
183
+ // Compose the throttler transformer on the api so every outbound
184
+ // call (sendMessage, deleteWebhook, setWebhook, etc.) is rate-
185
+ // limited and respects Telegram's retry_after automatically.
186
+ // Pattern adopted from OpenClaw `account-throttler.ts`.
187
+ bot.api.config.use(apiThrottler());
188
+
189
+ // Inbound handler — grammy invokes this for every text message
190
+ // matching `allowed_updates`. We bridge to our normalizer +
191
+ // onInbound callback. The grammy update shape is structurally
192
+ // compatible with our zod-validated `TelegramUpdate`, so we
193
+ // safeParse to keep the boundary explicit.
194
+ bot.on("message", async (ctx: Context) => {
195
+ const parsed = TelegramUpdateSchema.safeParse(ctx.update);
196
+ if (!parsed.success) {
197
+ d.logger.warn(
198
+ { channel: CHANNEL_ID, issues: parsed.error.issues },
199
+ "telegram polled update failed schema validation — dropping",
200
+ );
201
+ return;
202
+ }
203
+ await dispatchUpdate(parsed.data);
204
+ });
205
+
206
+ if (opts.config.mode === "webhook") {
207
+ const secretToken = opts.config.webhook?.secretToken ?? "";
208
+ webhookReg = createWebhookTransport({
209
+ channel: CHANNEL_ID,
210
+ logger: d.logger,
211
+ verify: async (req: WebhookInbound) => {
212
+ const header = req.headers["x-telegram-bot-api-secret-token"];
213
+ const provided = Array.isArray(header) ? header[0] : header;
214
+ if (provided !== secretToken) {
215
+ return err(
216
+ nubemClawError({
217
+ code: "auth.unauthorized",
218
+ message: "telegram webhook secret token mismatch",
219
+ }),
220
+ );
221
+ }
222
+ return webhookVerifyOk();
223
+ },
224
+ handle: async (body: unknown) => {
225
+ const parsed = TelegramUpdateSchema.safeParse(body);
226
+ if (!parsed.success) {
227
+ d.logger.warn(
228
+ { channel: CHANNEL_ID, issues: parsed.error.issues },
229
+ "telegram webhook body failed schema validation — dropping",
230
+ );
231
+ return;
232
+ }
233
+ await dispatchUpdate(parsed.data);
234
+ },
235
+ });
236
+ }
237
+ },
238
+
239
+ async start() {
240
+ const b = requireBot();
241
+ const d = requireDeps();
242
+ if (opts.config.mode === "polling") {
243
+ // Drop any registered webhook so getUpdates does not 409.
244
+ try {
245
+ await b.api.deleteWebhook({ drop_pending_updates: false });
246
+ } catch (err) {
247
+ d.logger.warn(
248
+ { channel: CHANNEL_ID, err },
249
+ "telegram: deleteWebhook on start failed (continuing into polling anyway)",
250
+ );
251
+ }
252
+ // `bot.start()` is the polling loop — the returned promise
253
+ // resolves when the loop terminates cleanly (someone called
254
+ // `bot.stop()`) and rejects if it dies mid-flight (network
255
+ // drop persisting beyond grammy's internal retries, bot
256
+ // kicked, persistent 5xx). The channel propagates this
257
+ // promise upward so the `ChannelManager` can tap it via
258
+ // `.then/.catch/.finally` and trigger restart on mid-flight
259
+ // failure. See `Channel.start()` contract in
260
+ // `@nubemclaw/channel-sdk/types.ts`.
261
+ pollingPromise = b.start({
262
+ allowed_updates: [...ALLOWED_UPDATES],
263
+ onStart: (info) => {
264
+ d.logger.info(
265
+ { channel: CHANNEL_ID, botUsername: info.username },
266
+ "telegram polling started",
267
+ );
268
+ },
269
+ });
270
+ // Return the polling promise so the manager observes the full
271
+ // lifecycle. `stop()` calls `bot.stop()` which resolves this
272
+ // promise cleanly; `bot.stop()` is idempotent so the local
273
+ // `pollingPromise` reference is retained but not re-awaited.
274
+ return pollingPromise;
275
+ } else {
276
+ const webhookCfg = opts.config.webhook;
277
+ if (webhookCfg === undefined) {
278
+ throw new Error("telegram channel: webhook mode requires webhook config");
279
+ }
280
+ await b.api.setWebhook(webhookCfg.publicUrl, {
281
+ secret_token: webhookCfg.secretToken,
282
+ allowed_updates: [...ALLOWED_UPDATES],
283
+ drop_pending_updates: false,
284
+ });
285
+ }
286
+ },
287
+
288
+ async stop() {
289
+ if (bot === null) return;
290
+ // grammy.Bot.stop() unblocks an in-flight getUpdates and lets
291
+ // bot.start() resolve cleanly. Safe to call when no polling
292
+ // session is active — it's a no-op then.
293
+ try {
294
+ await bot.stop();
295
+ } catch (e) {
296
+ requireDeps().logger.warn(
297
+ { channel: CHANNEL_ID, err: e },
298
+ "telegram bot.stop() raised (continuing teardown)",
299
+ );
300
+ }
301
+ if (pollingPromise !== null) {
302
+ try {
303
+ await pollingPromise;
304
+ } catch {
305
+ // bot.start() rejects when stop interrupts it; swallow.
306
+ }
307
+ pollingPromise = null;
308
+ }
309
+ // Release the undici dispatchers owned by the operational
310
+ // transport. Without this, keep-alive sockets to
311
+ // api.telegram.org stay open indefinitely (the openclaw#68128
312
+ // failure mode the transport hardening was originally written
313
+ // to fix). Idempotent — safe to call when there's no transport.
314
+ if (ownedTransport !== null) {
315
+ await ownedTransport.close();
316
+ ownedTransport = null;
317
+ }
318
+ // Webhook mode: we deliberately do NOT call deleteWebhook on
319
+ // stop because the operator may run a short-lived process
320
+ // (deploy, restart) and dropping the webhook each cycle would
321
+ // make Telegram drop in-flight events.
322
+ },
323
+
324
+ async send(target: ChannelTarget, message: ChannelMessage): Promise<void> {
325
+ if (target.kind !== "chat") {
326
+ throw new Error(`telegram channel: unsupported target kind '${target.kind}'`);
327
+ }
328
+ if (message.type !== "text") {
329
+ throw new Error(`telegram channel: unsupported message type '${message.type}'`);
330
+ }
331
+ const chatId = Number.parseInt(target.id, 10);
332
+ if (!Number.isFinite(chatId)) {
333
+ throw new Error(`telegram channel: invalid chat id '${target.id}'`);
334
+ }
335
+ // apiThrottler is composed on bot.api.config — sendMessage goes
336
+ // through it automatically and honors Telegram's retry_after on
337
+ // 429 without manual bucket management.
338
+ await requireBot().api.sendMessage(chatId, message.text);
339
+ },
340
+
341
+ webhookRegistration() {
342
+ return webhookReg ?? undefined;
343
+ },
344
+ };
345
+ };
package/src/config.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Per-channel config schema for Telegram (Phase 13, ADR-0020 §7
5
+ * identity + allowlist).
6
+ *
7
+ * • `allowedUsers` — exact-match deny-default allowlist of
8
+ * `telegram_user_id` values (as strings — Telegram IDs are numbers
9
+ * but config files round-trip JSON cleanly with strings, and the
10
+ * SDK `createAllowlist` expects strings anyway).
11
+ * • `mode` — polling or webhook. ADR-0020 §1 keeps polling
12
+ * as the default because it works behind NAT without a public URL;
13
+ * the operator opts into webhook by setting `mode: "webhook"` and
14
+ * supplying `webhook.publicUrl` + `webhook.secretToken`.
15
+ * • `polling.timeoutSec` — Telegram `getUpdates(timeout=N)` long-poll
16
+ * duration. Default 50 (Telegram's recommended ceiling is 50).
17
+ *
18
+ * `.strict()` so a typo (`allowedUSers`) fails loud at boot.
19
+ */
20
+
21
+ export const TelegramPollingConfigSchema = z
22
+ .object({
23
+ timeoutSec: z.number().int().min(1).max(50).default(50),
24
+ })
25
+ .strict();
26
+ export type TelegramPollingConfig = z.infer<typeof TelegramPollingConfigSchema>;
27
+
28
+ export const TelegramWebhookConfigSchema = z
29
+ .object({
30
+ publicUrl: z.string().url(),
31
+ secretToken: z.string().min(1).max(256),
32
+ })
33
+ .strict();
34
+ export type TelegramWebhookConfig = z.infer<typeof TelegramWebhookConfigSchema>;
35
+
36
+ export const TelegramChannelConfigSchema = z
37
+ .object({
38
+ allowedUsers: z.array(z.string().min(1)).default([]),
39
+ mode: z.enum(["polling", "webhook"]).default("polling"),
40
+ polling: TelegramPollingConfigSchema.default({ timeoutSec: 50 }),
41
+ webhook: TelegramWebhookConfigSchema.optional(),
42
+ })
43
+ .strict()
44
+ .superRefine((cfg, ctx) => {
45
+ if (cfg.mode === "webhook" && cfg.webhook === undefined) {
46
+ ctx.addIssue({
47
+ code: z.ZodIssueCode.custom,
48
+ path: ["webhook"],
49
+ message: "webhook config is required when mode is 'webhook'",
50
+ });
51
+ }
52
+ });
53
+ export type TelegramChannelConfig = z.infer<typeof TelegramChannelConfigSchema>;
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ import pkg from "../package.json" with { type: "json" };
2
+
3
+ export const PACKAGE_NAME = "@nubemclaw/channel-telegram" as const;
4
+ export const PACKAGE_VERSION: string = pkg.version;
5
+
6
+ export * from "./api/schemas.js";
7
+ export * from "./channel.js";
8
+ export * from "./config.js";
9
+ export * from "./setup.js";
10
+ export * from "./transport.js";