openclaw/src/telegram-gramjs/auth.test.ts
spiceoogway 84c1ab4d55 feat(telegram-gramjs): Phase 1 - User account adapter with tests and docs
Implements Telegram user account support via GramJS/MTProto (#937).

## What's New
- Complete GramJS channel adapter for user accounts (not bots)
- Interactive auth flow (phone → SMS → 2FA)
- Session persistence via StringSession
- DM and group message support
- Security policies (allowFrom, dmPolicy, groupPolicy)
- Multi-account configuration

## Files Added

### Core Implementation (src/telegram-gramjs/)
- auth.ts - Interactive authentication flow
- auth.test.ts - Auth flow tests (mocked)
- client.ts - GramJS TelegramClient wrapper
- config.ts - Config adapter for multi-account
- gateway.ts - Gateway adapter (poll/send)
- handlers.ts - Message conversion (GramJS → openclaw)
- handlers.test.ts - Message conversion tests
- setup.ts - CLI setup wizard
- types.ts - TypeScript type definitions
- index.ts - Module exports

### Configuration
- src/config/types.telegram-gramjs.ts - Config schema

### Plugin Extension
- extensions/telegram-gramjs/index.ts - Plugin registration
- extensions/telegram-gramjs/src/channel.ts - Channel plugin implementation
- extensions/telegram-gramjs/openclaw.plugin.json - Plugin manifest
- extensions/telegram-gramjs/package.json - Dependencies

### Documentation
- docs/channels/telegram-gramjs.md - Complete setup guide (14KB)
- GRAMJS-PHASE1-SUMMARY.md - Implementation summary

### Registry
- src/channels/registry.ts - Added telegram-gramjs to CHAT_CHANNEL_ORDER

## Test Coverage
-  Auth flow with phone/SMS/2FA (mocked)
-  Phone number validation
-  Session verification
-  Message conversion (DM, group, reply)
-  Session key routing
-  Command extraction
-  Edge cases (empty messages, special chars)

## Features Implemented (Phase 1)
-  User account authentication via MTProto
-  DM message send/receive
-  Group message send/receive
-  Reply context preservation
-  Security policies (pairing, allowlist)
-  Multi-account support
-  Session persistence
-  Command detection

## Next Steps (Phase 2)
- Media support (photos, videos, files)
- Voice messages and stickers
- Message editing and deletion
- Reactions
- Channel messages

## Documentation Highlights
- Getting API credentials from my.telegram.org
- Interactive setup wizard walkthrough
- DM and group policies configuration
- Multi-account examples
- Rate limits and troubleshooting
- Security best practices
- Migration guide from Bot API

Closes #937 (Phase 1)
2026-01-30 03:03:15 -05:00

256 lines
7.8 KiB
TypeScript

/**
* Tests for Telegram GramJS authentication flow.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthFlow, verifySession } from "./auth.js";
import type { AuthState } from "./types.js";
// Mock readline to avoid stdin/stdout in tests
const mockPrompt = vi.fn();
const mockClose = vi.fn();
vi.mock("readline", () => ({
default: {
createInterface: vi.fn(() => ({
question: (q: string, callback: (answer: string) => void) => {
mockPrompt(q).then((answer: string) => callback(answer));
},
close: mockClose,
})),
},
}));
// Mock GramJSClient
const mockConnect = vi.fn();
const mockDisconnect = vi.fn();
const mockStartWithAuth = vi.fn();
const mockGetConnectionState = vi.fn();
vi.mock("./client.js", () => ({
GramJSClient: vi.fn(() => ({
connect: mockConnect,
disconnect: mockDisconnect,
startWithAuth: mockStartWithAuth,
getConnectionState: mockGetConnectionState,
})),
}));
// Mock logger
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({
info: vi.fn(),
success: vi.fn(),
error: vi.fn(),
verbose: vi.fn(),
}),
}));
describe("AuthFlow", () => {
let authFlow: AuthFlow;
beforeEach(() => {
vi.clearAllMocks();
authFlow = new AuthFlow();
});
afterEach(() => {
mockClose.mockClear();
});
describe("phone number validation", () => {
it("should accept valid phone numbers", async () => {
// Valid formats
const validNumbers = ["+12025551234", "+441234567890", "+8612345678901"];
mockPrompt
.mockResolvedValueOnce(validNumbers[0])
.mockResolvedValueOnce("12345") // SMS code
.mockResolvedValue(""); // No 2FA
mockStartWithAuth.mockResolvedValue("mock_session_string");
await authFlow.authenticate(123456, "test_hash");
expect(mockStartWithAuth).toHaveBeenCalled();
});
it("should reject invalid phone numbers", async () => {
mockPrompt
.mockResolvedValueOnce("1234567890") // Missing +
.mockResolvedValueOnce("+1234") // Too short
.mockResolvedValueOnce("+12025551234") // Valid
.mockResolvedValueOnce("12345") // SMS code
.mockResolvedValue(""); // No 2FA
mockStartWithAuth.mockResolvedValue("mock_session_string");
await authFlow.authenticate(123456, "test_hash");
// Should have prompted 3 times for phone (2 invalid, 1 valid)
expect(mockPrompt).toHaveBeenCalledTimes(4); // 3 phone + 1 SMS
});
});
describe("authentication flow", () => {
it("should complete full auth flow with SMS only", async () => {
const phoneNumber = "+12025551234";
const smsCode = "12345";
const sessionString = "mock_session_string";
mockPrompt.mockResolvedValueOnce(phoneNumber).mockResolvedValueOnce(smsCode);
mockStartWithAuth.mockImplementation(async ({ phoneNumber: phoneFn, phoneCode: codeFn }) => {
expect(await phoneFn()).toBe(phoneNumber);
expect(await codeFn()).toBe(smsCode);
return sessionString;
});
const result = await authFlow.authenticate(123456, "test_hash");
expect(result).toBe(sessionString);
expect(mockDisconnect).toHaveBeenCalled();
expect(mockClose).toHaveBeenCalled();
});
it("should complete full auth flow with 2FA", async () => {
const phoneNumber = "+12025551234";
const smsCode = "12345";
const password = "my2fapassword";
const sessionString = "mock_session_string";
mockPrompt
.mockResolvedValueOnce(phoneNumber)
.mockResolvedValueOnce(smsCode)
.mockResolvedValueOnce(password);
mockStartWithAuth.mockImplementation(
async ({ phoneNumber: phoneFn, phoneCode: codeFn, password: passwordFn }) => {
expect(await phoneFn()).toBe(phoneNumber);
expect(await codeFn()).toBe(smsCode);
expect(await passwordFn()).toBe(password);
return sessionString;
},
);
const result = await authFlow.authenticate(123456, "test_hash");
expect(result).toBe(sessionString);
expect(mockDisconnect).toHaveBeenCalled();
});
it("should handle authentication errors", async () => {
const phoneNumber = "+12025551234";
const errorMessage = "Invalid phone number";
mockPrompt.mockResolvedValueOnce(phoneNumber);
mockStartWithAuth.mockImplementation(async ({ onError }) => {
onError(new Error(errorMessage));
throw new Error(errorMessage);
});
await expect(authFlow.authenticate(123456, "test_hash")).rejects.toThrow(errorMessage);
const state = authFlow.getState();
expect(state.phase).toBe("error");
expect(state.error).toBe(errorMessage);
});
it("should track auth state progression", async () => {
const phoneNumber = "+12025551234";
const smsCode = "12345";
mockPrompt.mockResolvedValueOnce(phoneNumber).mockResolvedValueOnce(smsCode);
mockStartWithAuth.mockResolvedValue("mock_session");
// Check initial state
let state = authFlow.getState();
expect(state.phase).toBe("phone");
// Start auth (don't await yet)
const authPromise = authFlow.authenticate(123456, "test_hash");
// State should progress through phases
// (in real scenario, but hard to test async state)
await authPromise;
// Check final state
state = authFlow.getState();
expect(state.phase).toBe("complete");
expect(state.phoneNumber).toBe(phoneNumber);
});
});
describe("verifySession", () => {
it("should return true for valid session", async () => {
mockConnect.mockResolvedValue(undefined);
mockGetConnectionState.mockResolvedValue({ authorized: true });
mockDisconnect.mockResolvedValue(undefined);
const result = await verifySession(123456, "test_hash", "valid_session");
expect(result).toBe(true);
expect(mockConnect).toHaveBeenCalled();
expect(mockDisconnect).toHaveBeenCalled();
});
it("should return false for invalid session", async () => {
mockConnect.mockResolvedValue(undefined);
mockGetConnectionState.mockResolvedValue({ authorized: false });
mockDisconnect.mockResolvedValue(undefined);
const result = await verifySession(123456, "test_hash", "invalid_session");
expect(result).toBe(false);
});
it("should return false on connection error", async () => {
mockConnect.mockRejectedValue(new Error("Connection failed"));
const result = await verifySession(123456, "test_hash", "bad_session");
expect(result).toBe(false);
});
});
describe("input sanitization", () => {
it("should strip spaces and dashes from SMS code", async () => {
const phoneNumber = "+12025551234";
const smsCodeWithSpaces = "123 45";
const expectedCode = "12345";
mockPrompt.mockResolvedValueOnce(phoneNumber).mockResolvedValueOnce(smsCodeWithSpaces);
mockStartWithAuth.mockImplementation(async ({ phoneCode: codeFn }) => {
const code = await codeFn();
expect(code).toBe(expectedCode);
return "mock_session";
});
await authFlow.authenticate(123456, "test_hash");
});
it("should not modify 2FA password", async () => {
const phoneNumber = "+12025551234";
const smsCode = "12345";
const passwordWithSpaces = "my password 123";
mockPrompt
.mockResolvedValueOnce(phoneNumber)
.mockResolvedValueOnce(smsCode)
.mockResolvedValueOnce(passwordWithSpaces);
mockStartWithAuth.mockImplementation(async ({ password: passwordFn }) => {
const password = await passwordFn();
expect(password).toBe(passwordWithSpaces); // Should NOT strip spaces
return "mock_session";
});
await authFlow.authenticate(123456, "test_hash");
});
});
});