@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,169 @@
1
+ import { isErr, isOk } from "@nubemclaw/core";
2
+ import {
3
+ createScriptedWizard,
4
+ resolveCredentialPaths,
5
+ runChannelSetup,
6
+ } from "@nubemclaw/channel-sdk";
7
+ import { GrammyError, HttpError } from "grammy";
8
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import { createTelegramSetup } from "./setup.js";
14
+
15
+ /**
16
+ * Tests pin the setup flow against a scripted wizard + a stub grammy
17
+ * Bot (only `api.getMe` is needed for validate). The order
18
+ * `prompt → safeParse → validate → persist` and the atomicity
19
+ * guarantee (persist NEVER runs if validate fails) are tested
20
+ * explicitly — the contract is documented in ADR-0020 §2 and the SDK
21
+ * `runChannelSetup` orchestrator owns the order.
22
+ */
23
+
24
+ const silentLogger = {
25
+ info: () => {},
26
+ warn: () => {},
27
+ error: () => {},
28
+ debug: () => {},
29
+ };
30
+
31
+ const fakeBotToken = "123456789:ABCDEFghijklmnop_qrstuv-wxyz0123456";
32
+
33
+ interface FakeBotApi {
34
+ getMe: ReturnType<typeof vi.fn>;
35
+ }
36
+
37
+ const makeBotFactory = (api: FakeBotApi) => (_token: string) => ({ api }) as never;
38
+
39
+ describe("createTelegramSetup", () => {
40
+ let stateDir = "";
41
+
42
+ beforeEach(async () => {
43
+ stateDir = await mkdtemp(join(tmpdir(), "nbc-telegram-setup-"));
44
+ });
45
+
46
+ afterEach(async () => {
47
+ await rm(stateDir, { recursive: true, force: true });
48
+ });
49
+
50
+ it("happy path — prompt → validate → persist writes the token at the canonical path", async () => {
51
+ const getMe = vi.fn(async () => ({
52
+ id: 42,
53
+ is_bot: true,
54
+ first_name: "TestBot",
55
+ username: "test_bot",
56
+ }));
57
+ const setup = createTelegramSetup({
58
+ logger: silentLogger,
59
+ botFactory: makeBotFactory({ getMe }),
60
+ envVars: { NUBEMCLAW_STATE_DIR: stateDir },
61
+ });
62
+ const wizard = createScriptedWizard([{ kind: "value", value: fakeBotToken }]);
63
+
64
+ const result = await runChannelSetup(setup, { wizard });
65
+ expect(isOk(result)).toBe(true);
66
+
67
+ const paths = resolveCredentialPaths(
68
+ { channel: "telegram", role: "bot-token" },
69
+ { NUBEMCLAW_STATE_DIR: stateDir },
70
+ );
71
+ expect(await readFile(paths.credentialFile, "utf8")).toBe(fakeBotToken);
72
+ expect(getMe).toHaveBeenCalledOnce();
73
+ });
74
+
75
+ it("malformed token → validation.failed BEFORE bot.api.getMe is called", async () => {
76
+ const getMe = vi.fn();
77
+ const setup = createTelegramSetup({
78
+ logger: silentLogger,
79
+ botFactory: makeBotFactory({ getMe }),
80
+ envVars: { NUBEMCLAW_STATE_DIR: stateDir },
81
+ });
82
+ const wizard = createScriptedWizard([{ kind: "value", value: "garbage-not-a-token" }]);
83
+
84
+ const result = await runChannelSetup(setup, { wizard });
85
+ expect(isErr(result)).toBe(true);
86
+ if (isErr(result)) expect(result.error.code).toBe("validation.failed");
87
+ expect(getMe).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("getMe throws GrammyError(401) → channel.setup_validation_failed, no file written", async () => {
91
+ const getMe = vi.fn(async () => {
92
+ // GrammyError signature: (message, error_code, method, payload, response?).
93
+ // The constructor is exposed by grammy; we mimic a 401 response.
94
+ throw new GrammyError(
95
+ "Call to 'getMe' failed! Unauthorized",
96
+ // @ts-expect-error — grammy expects a Response shape; tests only use the description + code.
97
+ { ok: false, error_code: 401, description: "Unauthorized" },
98
+ "getMe",
99
+ {},
100
+ );
101
+ });
102
+ const setup = createTelegramSetup({
103
+ logger: silentLogger,
104
+ botFactory: makeBotFactory({ getMe }),
105
+ envVars: { NUBEMCLAW_STATE_DIR: stateDir },
106
+ });
107
+ const wizard = createScriptedWizard([{ kind: "value", value: fakeBotToken }]);
108
+
109
+ const result = await runChannelSetup(setup, { wizard });
110
+ expect(isErr(result)).toBe(true);
111
+ if (isErr(result)) {
112
+ expect(result.error.code).toBe("channel.setup_validation_failed");
113
+ expect(result.error.meta?.["error_code"]).toBe(401);
114
+ }
115
+
116
+ const paths = resolveCredentialPaths(
117
+ { channel: "telegram", role: "bot-token" },
118
+ { NUBEMCLAW_STATE_DIR: stateDir },
119
+ );
120
+ await expect(readFile(paths.credentialFile, "utf8")).rejects.toThrow();
121
+ });
122
+
123
+ it("getMe throws HttpError (network down) → channel.setup_validation_failed, no file written", async () => {
124
+ const getMe = vi.fn(async () => {
125
+ throw new HttpError("ECONNREFUSED", new Error("connect ECONNREFUSED"));
126
+ });
127
+ const setup = createTelegramSetup({
128
+ logger: silentLogger,
129
+ botFactory: makeBotFactory({ getMe }),
130
+ envVars: { NUBEMCLAW_STATE_DIR: stateDir },
131
+ });
132
+ const wizard = createScriptedWizard([{ kind: "value", value: fakeBotToken }]);
133
+ const result = await runChannelSetup(setup, { wizard });
134
+ expect(isErr(result)).toBe(true);
135
+ if (isErr(result)) expect(result.error.code).toBe("channel.setup_validation_failed");
136
+ });
137
+
138
+ it("getMe returns is_bot:false → channel.setup_validation_failed", async () => {
139
+ const getMe = vi.fn(async () => ({
140
+ id: 1,
141
+ is_bot: false,
142
+ first_name: "Human",
143
+ username: "human",
144
+ }));
145
+ const setup = createTelegramSetup({
146
+ logger: silentLogger,
147
+ botFactory: makeBotFactory({ getMe }),
148
+ envVars: { NUBEMCLAW_STATE_DIR: stateDir },
149
+ });
150
+ const wizard = createScriptedWizard([{ kind: "value", value: fakeBotToken }]);
151
+ const result = await runChannelSetup(setup, { wizard });
152
+ expect(isErr(result)).toBe(true);
153
+ if (isErr(result)) expect(result.error.code).toBe("channel.setup_validation_failed");
154
+ });
155
+
156
+ it("operator cancellation → channel.setup_cancelled, getMe never called", async () => {
157
+ const getMe = vi.fn();
158
+ const setup = createTelegramSetup({
159
+ logger: silentLogger,
160
+ botFactory: makeBotFactory({ getMe }),
161
+ envVars: { NUBEMCLAW_STATE_DIR: stateDir },
162
+ });
163
+ const wizard = createScriptedWizard([{ kind: "cancel" }]);
164
+ const result = await runChannelSetup(setup, { wizard });
165
+ expect(isErr(result)).toBe(true);
166
+ if (isErr(result)) expect(result.error.code).toBe("channel.setup_cancelled");
167
+ expect(getMe).not.toHaveBeenCalled();
168
+ });
169
+ });
package/src/setup.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { err, nubemClawError, ok, type NubemClawError, type Result } from "@nubemclaw/core";
2
+ import {
3
+ resolveCredentialPaths,
4
+ writeChannelCredential,
5
+ type ChannelLogger,
6
+ type ChannelSetup,
7
+ type Wizard,
8
+ } from "@nubemclaw/channel-sdk";
9
+ import { Bot, GrammyError, HttpError } from "grammy";
10
+ import { z } from "zod";
11
+
12
+ /**
13
+ * `TelegramSetup` — guided credential capture for the Telegram channel
14
+ * (Phase 13, ADR-0020 §2).
15
+ *
16
+ * Wizard transcript:
17
+ *
18
+ * 1. `secret` — bot token, format `<digits>:<token>` (BotFather).
19
+ *
20
+ * Schema (`credentialSchema`):
21
+ *
22
+ * - `botToken: string` matching `/^\d+:[A-Za-z0-9_-]+$/`.
23
+ *
24
+ * Validate (`validate`):
25
+ *
26
+ * - Instantiates a transient grammy `Bot` and calls `bot.api.getMe()`.
27
+ * grammy maps the Bot API failure modes to typed errors:
28
+ * • `GrammyError` with `error_code: 401` → invalid token.
29
+ * • `HttpError` → transport/network failure.
30
+ * - We translate both into `channel.setup_validation_failed` so the
31
+ * operator sees a clean message; the orchestrator does NOT persist.
32
+ *
33
+ * Persist (`persist`):
34
+ *
35
+ * - Atomic 0o600 write to
36
+ * `~/.nubemclaw-dev/credentials/telegram-bot-token` via
37
+ * `writeChannelCredential` (SDK shared helper).
38
+ *
39
+ * Cancellation at any step is converted by `runChannelSetup` to
40
+ * `channel.setup_cancelled` — see ADR-0020 §2.
41
+ */
42
+
43
+ const TELEGRAM_BOT_TOKEN_REGEX = /^\d+:[A-Za-z0-9_-]+$/;
44
+
45
+ export const TelegramCredentialsSchema = z
46
+ .object({
47
+ botToken: z.string().regex(TELEGRAM_BOT_TOKEN_REGEX, {
48
+ message: "expected BotFather format: <digits>:<token>",
49
+ }),
50
+ })
51
+ .strict();
52
+ export type TelegramCredentials = z.infer<typeof TelegramCredentialsSchema>;
53
+
54
+ export interface CreateTelegramSetupOptions {
55
+ readonly logger: ChannelLogger;
56
+ /**
57
+ * Test seam: override the Bot constructor. Production callers
58
+ * always use grammy's `Bot`; tests inject a fake whose `api.getMe()`
59
+ * is scriptable.
60
+ */
61
+ readonly botFactory?: (token: string) => Pick<Bot, "api">;
62
+ /** Test seam: override env vars for credential path resolution. */
63
+ readonly envVars?: NodeJS.ProcessEnv;
64
+ }
65
+
66
+ export const createTelegramSetup = (
67
+ opts: CreateTelegramSetupOptions,
68
+ ): ChannelSetup<TelegramCredentials> => ({
69
+ channel: "telegram",
70
+ credentialSchema: TelegramCredentialsSchema,
71
+
72
+ async promptOperator(wizard: Wizard): Promise<Result<TelegramCredentials, "cancelled">> {
73
+ const token = await wizard.secret({
74
+ message: "Telegram bot token (BotFather format: 123456:ABC...)",
75
+ validate: (value) => {
76
+ if (value === undefined || value.trim() === "") return "required";
77
+ if (!TELEGRAM_BOT_TOKEN_REGEX.test(value.trim())) {
78
+ return "expected BotFather format: <digits>:<token>";
79
+ }
80
+ return undefined;
81
+ },
82
+ });
83
+ if (!token.ok) return token;
84
+ return ok({ botToken: token.value.trim() });
85
+ },
86
+
87
+ async validate(creds: TelegramCredentials): Promise<Result<void, NubemClawError>> {
88
+ const factory = opts.botFactory ?? ((token: string) => new Bot(token));
89
+ const bot = factory(creds.botToken);
90
+ try {
91
+ const me = await bot.api.getMe();
92
+ if (me.is_bot !== true) {
93
+ return err(
94
+ nubemClawError({
95
+ code: "channel.setup_validation_failed",
96
+ message: "telegram getMe returned a non-bot identity — token is not a bot token",
97
+ }),
98
+ );
99
+ }
100
+ opts.logger.info({ username: me.username, id: me.id }, "telegram bot validated");
101
+ return ok(undefined);
102
+ } catch (cause) {
103
+ if (cause instanceof GrammyError) {
104
+ return err(
105
+ nubemClawError({
106
+ code: "channel.setup_validation_failed",
107
+ message: `telegram getMe rejected the token: ${cause.description}`,
108
+ meta: { error_code: cause.error_code },
109
+ cause,
110
+ }),
111
+ );
112
+ }
113
+ if (cause instanceof HttpError) {
114
+ return err(
115
+ nubemClawError({
116
+ code: "channel.setup_validation_failed",
117
+ message: `telegram getMe could not reach the Bot API: ${cause.message}`,
118
+ cause,
119
+ }),
120
+ );
121
+ }
122
+ return err(
123
+ nubemClawError({
124
+ code: "channel.setup_validation_failed",
125
+ message: `telegram getMe failed: ${String(cause)}`,
126
+ cause,
127
+ }),
128
+ );
129
+ }
130
+ },
131
+
132
+ async persist(creds: TelegramCredentials): Promise<void> {
133
+ const paths = resolveCredentialPaths(
134
+ { channel: "telegram", role: "bot-token" },
135
+ opts.envVars ?? process.env,
136
+ );
137
+ await writeChannelCredential(creds.botToken, paths);
138
+ },
139
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { createTelegramTransport } from "./transport.js";
4
+
5
+ /**
6
+ * Unit tests for the operational transport. The Agent / ProxyAgent
7
+ * internals are exercised in production paths only — the live test
8
+ * (#150) verifies the wrapped fetch against the real Telegram API.
9
+ * Here we pin the public contract:
10
+ *
11
+ * • Returns an object with both `fetch` and `close`.
12
+ * • `close()` is idempotent.
13
+ * • Proxy URL precedence: explicit option > env var > none.
14
+ * • The wrapped fetch resolves against a real URL (we point at a
15
+ * local httpbin-like echo path served by Node's built-in test
16
+ * server in `tests/live/...` later; here we just smoke the
17
+ * wiring with a `data:` URL to avoid real network).
18
+ *
19
+ * Tests do NOT instantiate undici Agents with real connections —
20
+ * the channel-level test (#144 v2) and the live test (#150) cover
21
+ * the end-to-end path.
22
+ */
23
+
24
+ describe("createTelegramTransport", () => {
25
+ it("returns fetch + close from the factory", () => {
26
+ const t = createTelegramTransport();
27
+ expect(typeof t.fetch).toBe("function");
28
+ expect(typeof t.close).toBe("function");
29
+ });
30
+
31
+ it("close() is idempotent (second call resolves immediately, no throw)", async () => {
32
+ const t = createTelegramTransport();
33
+ await t.close();
34
+ await t.close();
35
+ });
36
+
37
+ it("resolves proxy URL from explicit option over env var", () => {
38
+ // Smoke test: the transport accepts the proxyUrl without throwing
39
+ // at construction time. End-to-end behavior is verified in the live
40
+ // test where the proxy is reachable.
41
+ const t = createTelegramTransport({
42
+ proxyUrl: "http://proxy.example.test:8080",
43
+ envVars: { NUBEMCLAW_PROXY_URL: "http://different-proxy.example.test:9090" },
44
+ });
45
+ expect(t.fetch).toBeDefined();
46
+ });
47
+
48
+ it("resolves proxy URL from NUBEMCLAW_PROXY_URL env var when no explicit option", () => {
49
+ const t = createTelegramTransport({
50
+ envVars: { NUBEMCLAW_PROXY_URL: "http://env-proxy.example.test:7070" },
51
+ });
52
+ expect(t.fetch).toBeDefined();
53
+ });
54
+
55
+ it("falls back to direct dispatcher when neither option nor env var is set", () => {
56
+ const t = createTelegramTransport({ envVars: {} });
57
+ expect(t.fetch).toBeDefined();
58
+ });
59
+
60
+ it("ignores empty string proxy URLs (treats as 'not set')", () => {
61
+ const t = createTelegramTransport({
62
+ proxyUrl: " ",
63
+ envVars: { NUBEMCLAW_PROXY_URL: " " },
64
+ });
65
+ expect(t.fetch).toBeDefined();
66
+ });
67
+ });