diff --git a/docs/architecture/telegram-integration.md b/docs/architecture/telegram-integration.md new file mode 100644 index 000000000..a5369beef --- /dev/null +++ b/docs/architecture/telegram-integration.md @@ -0,0 +1,1035 @@ +# Telegram Integration Architecture + +## Executive Summary + +This document provides architectural guidance for adding Telegram as a third messaging provider to warelay. Like the WhatsApp Web provider, Telegram uses the user's personal account via **MTProto client** for 1-on-1 conversation automation. + +**Key architecture decisions:** + +1. **MTProto client**: Users log in with their personal Telegram account (phone + 2FA) +2. **Same security model**: `allowFrom` whitelist controls who can trigger auto-replies +3. **Provider abstraction**: Unified interface across Twilio, Web, and Telegram providers +4. **Session storage**: File-based session like WhatsApp Web (`~/.warelay/telegram/session/`) + +--- + +## Table of Contents + +1. [Current Provider Architecture](#1-current-provider-architecture) +2. [Provider Analysis: Web vs Telegram](#2-provider-analysis-web-vs-telegram) +3. [Telegram MTProto Design](#3-telegram-mtproto-design) +4. [Provider Abstraction Improvements](#4-provider-abstraction-improvements) +5. [Configuration Schema Design](#5-configuration-schema-design) +6. [Implementation Roadmap](#6-implementation-roadmap) +7. [User Experience Design](#7-user-experience-design) +8. [Design Decisions](#8-design-decisions) + +--- + +## 1. Current Provider Architecture + +### 1.1 System Context + +```mermaid +flowchart LR + subgraph Warelay["warelay CLI (Container)"] + CLI[[CLI / Commander]] + Send[[Send Command]] + Relay[[Relay Command]] + AutoReply[[Auto-Reply Engine]] + Config[(Config / Sessions)] + end + + User["Developer / Agent"] + Twilio["Twilio API"] + WhatsApp["WhatsApp Web"] + IDP["WhatsApp IDP"] + + User -->|CLI commands| CLI + CLI --> Send + CLI --> Relay + Relay --> AutoReply + Send --> Twilio + Send --> WhatsApp + Relay --> Twilio + Relay --> WhatsApp + AutoReply --> Config + WhatsApp -->|Baileys| IDP +``` + +**Caption:** warelay system context showing CLI entry points and provider connections. + +**Evidence:** `src/cli/program.ts:L1-L50`, `src/commands/send.ts:L1-L150` + +### 1.2 Current Provider Type Definition + +The provider abstraction is currently a simple type union: + +```typescript +// src/utils.ts:L9 +export type Provider = "wa-twilio" | "wa-web"; + +// src/utils.ts:L11-L14 +export function assertProvider(input: string): asserts input is Provider { + if (input !== "wa-twilio" && input !== "wa-web") { + throw new Error("Provider must be 'wa-twilio' or 'wa-web'"); + } +} +``` + +**Evidence:** `src/utils.ts:L9-L14` + +**Interpretation:** There is no formal provider interface or contract. Each provider has its own module structure with different function signatures. + +### 1.3 Provider Module Structure + +```mermaid +flowchart TB + subgraph Providers["Provider Modules"] + subgraph TwilioMod["Twilio Provider"] + TClient[[client.ts]] + TSend[[send.ts]] + TMonitor[[monitor.ts]] + TWebhook[[webhook.ts]] + end + subgraph WebMod["Web Provider"] + WSession[[session.ts]] + WOutbound[[outbound.ts]] + WInbound[[inbound.ts]] + WLogin[[login.ts]] + WMedia[[media.ts]] + WAutoReply[[auto-reply.ts]] + WIPC[[ipc.ts]] + end + end + + CLI[[CLI Commands]] + CLI --> TwilioMod + CLI --> WebMod +``` + +**Caption:** Current provider module organization showing asymmetric implementations. + +**Evidence:** +- Twilio: `src/providers/twilio/index.ts`, `src/twilio/*.ts` +- Web: `src/providers/web/index.ts`, `src/web/*.ts`, `src/provider-web.ts` + +### 1.4 Provider Selection Logic + +```mermaid +flowchart TB + Start([Provider Selection]) + Check{Provider Flag?} + Auto{Auto Mode?} + WebAuth{Web Auth Exists?} + + Start --> Check + Check -->|wa-twilio| UseTwilio[Use WhatsApp Twilio] + Check -->|wa-web| UseWeb[Use WhatsApp Web] + Check -->|auto| Auto + Auto --> WebAuth + WebAuth -->|yes| UseWeb + WebAuth -->|no| UseTwilio +``` + +**Caption:** Provider auto-selection prioritizes Web when credentials exist. + +**Evidence:** `src/web/session.ts:L220-L226` + +```typescript +export async function pickProvider(pref: Provider | "auto"): Promise { + if (pref !== "auto") return pref; + const hasWeb = await webAuthExists(); + if (hasWeb) return "wa-web"; + return "wa-twilio"; +} +``` + +--- + +## 2. Provider Analysis: Web vs Telegram + +### 2.1 Comparison Matrix + +| Aspect | WhatsApp Web Provider | Telegram MTProto Provider | +|--------|----------------------|---------------------------| +| **Auth Model** | QR code scan | Phone + code + 2FA | +| **Connection Type** | Persistent WebSocket | Persistent TCP/WebSocket | +| **Library** | Baileys | GramJS | +| **Session Storage** | `~/.warelay/credentials/` | `~/.warelay/telegram/session/` | +| **Message Send** | Socket message | MTProto request | +| **Inbound Handling** | Event listener | Event listener | +| **Delivery Status** | Limited (receipts) | Full (receipts + read) | +| **Media Handling** | Buffer-based | Buffer-based | +| **Session Management** | Multi-file auth state | String session | +| **Reconnection** | Exponential backoff | Exponential backoff | + +### 2.2 Web Provider Architecture + +```mermaid +sequenceDiagram + participant U as User/Agent + participant C as CLI + participant S as WA Socket + participant WA as WhatsApp + + U->>C: warelay send --provider wa-web + C->>S: createWaSocket() + S->>WA: Connect (auth state) + WA-->>S: Connection open + S->>WA: sendMessage(jid, payload) + WA-->>S: Message key + S-->>C: {messageId, toJid} + C->>S: ws.close() +``` + +**Caption:** Web send flow using persistent Baileys socket. + +**Evidence:** `src/web/outbound.ts:L12-L65` + +**Key characteristics:** +- Persistent connection with session state +- QR-based authentication stored at `~/.warelay/credentials/` +- Media sent as buffers with automatic optimization +- IPC server for relay mode to prevent session corruption + +### 2.3 Telegram MTProto Architecture (Proposed) + +```mermaid +sequenceDiagram + participant U as User/Agent + participant C as CLI + participant T as TelegramClient + participant TG as Telegram + + U->>C: warelay send --provider telegram + C->>T: createTelegramClient() + T->>TG: Connect (session) + TG-->>T: Connection open + T->>TG: sendMessage(peer, payload) + TG-->>T: Message object + T-->>C: {messageId, peerId} + C->>T: disconnect() +``` + +**Caption:** Telegram send flow using GramJS MTProto client. + +**Key characteristics:** +- Persistent connection with session state (like WhatsApp Web) +- Phone-based authentication stored at `~/.warelay/telegram/session/` +- Media sent as buffers +- Same patterns as Baileys for session management + +### 2.4 Inbound Message Handling + +```mermaid +flowchart TB + subgraph Twilio["Twilio Inbound"] + direction TB + TW[Webhook] --> TA[Auto-Reply] + TP[Polling] --> TA + end + + subgraph Web["Web Inbound"] + direction TB + WS[Socket Event] --> WA[Auto-Reply] + WU[messages.upsert] --> WS + end + + subgraph Telegram["Telegram Inbound"] + direction TB + TE[NewMessage Event] --> TGA[Auto-Reply] + end + + AutoReply[[Auto-Reply Engine]] + TA --> AutoReply + WA --> AutoReply + TGA --> AutoReply + AutoReply --> Config[(Config)] + AutoReply --> Session[(Session Store)] +``` + +**Caption:** All providers converge at the provider-agnostic auto-reply engine. + +**Evidence:** +- Twilio: `src/twilio/monitor.ts:L52-L101` +- Web: `src/web/inbound.ts:L43-L274` + +--- + +## 3. Telegram MTProto Design + +### 3.1 MTProto Client Architecture + +```mermaid +flowchart TB + subgraph TelegramProvider["Telegram Provider"] + Client[[GramJS Client]] + Session[[Session Manager]] + Login[[Login Handler]] + Media[[Media Handler]] + Events[[Event Handler]] + end + + subgraph External["Telegram"] + API["Telegram MTProto\nDC Servers"] + end + + subgraph Storage["Local Storage"] + SessionFile[("~/.warelay/telegram/session/")] + end + + Login -->|phone + code| Client + Client --> API + API --> Events + Client --> Session + Session --> SessionFile + Client --> Media +``` + +**Caption:** Telegram provider internal architecture using MTProto. + +### 3.2 Telegram Login Flow + +```mermaid +sequenceDiagram + participant U as User + participant C as CLI + participant T as TelegramClient + participant TG as Telegram + + U->>C: warelay login --provider telegram + C->>T: createClient(apiId, apiHash) + T->>TG: Connect + C->>U: Enter phone number + U->>C: +15551234567 + T->>TG: sendCode(phone) + TG-->>T: Code sent + C->>U: Enter code + U->>C: 12345 + T->>TG: signIn(phone, code) + + alt 2FA Enabled + TG-->>T: Password required + C->>U: Enter 2FA password + U->>C: ******** + T->>TG: checkPassword(hash) + end + + TG-->>T: Authorized + T->>T: saveSession() + C->>U: Logged in as @username +``` + +**Caption:** Interactive Telegram login flow with optional 2FA. + +### 3.3 Telegram Send Flow + +```mermaid +sequenceDiagram + participant C as CLI + participant P as TelegramProvider + participant G as GramJS + participant A as Telegram API + + C->>P: send(userId, text) + P->>P: resolveUser(userId) + P->>G: client.sendMessage() + G->>A: MTProto Request + A-->>G: Message response + G-->>P: Message object + P-->>C: SendResult +``` + +**Caption:** Telegram outbound message sequence. + +### 3.4 Telegram Relay Flow + +```mermaid +sequenceDiagram + participant U as Contact + participant TG as Telegram + participant C as TelegramClient + participant AR as Auto-Reply + participant A as Agent (Claude) + + Note over C: Persistent Connection + + U->>TG: Send message + TG->>C: NewMessage event + C->>C: Check allowFrom + C->>AR: Process message + AR->>A: Execute command + A-->>AR: Response + AR->>C: Reply payload + C->>TG: sendMessage() + TG->>U: Deliver reply +``` + +**Caption:** Telegram relay mode with auto-reply using persistent connection. + +### 3.5 Security Model + +```mermaid +flowchart TB + Inbound[Incoming Message] + Check{Sender in allowFrom?} + Allow[Process Message] + Deny[Ignore Message] + + Inbound --> Check + Check -->|yes| Allow + Check -->|no| Deny + Allow --> AutoReply[Auto-Reply Engine] +``` + +**Caption:** `allowFrom` whitelist filtering for Telegram (same as WhatsApp). + +**Configuration:** +```json5 +{ + telegram: { + allowFrom: ["@alice", "@bob", "123456789"] + } +} +``` + +- Usernames must include `@` prefix +- User IDs are numeric strings +- Empty array `[]` blocks all +- Omit entirely to allow all (use with caution) + +--- + +## 4. Provider Abstraction Improvements + +### 4.1 Proposed Provider Interface + +```typescript +// src/providers/types.ts (proposed) + +export type ProviderKind = "wa-twilio" | "wa-web" | "telegram"; + +export interface ProviderMessage { + id: string; + from: string; // Normalized identifier + to: string; // Normalized identifier + body: string; + timestamp: number; + media?: ProviderMedia[]; + raw?: unknown; // Provider-specific payload +} + +export interface ProviderMedia { + type: "image" | "video" | "audio" | "document" | "voice"; + url?: string; // Remote URL + buffer?: Buffer; // Local buffer + mimeType?: string; + fileName?: string; + size?: number; +} + +export interface SendOptions { + media?: ProviderMedia[]; + replyTo?: string; // Message ID to reply to + typing?: boolean; // Send typing indicator first +} + +export interface SendResult { + messageId: string; + status: "sent" | "queued" | "failed"; + providerMeta?: unknown; // Provider-specific (SID, user_id, etc.) +} + +export interface DeliveryStatus { + messageId: string; + status: "sent" | "delivered" | "read" | "failed" | "unknown"; + timestamp?: number; + error?: string; +} + +export interface ProviderCapabilities { + supportsDeliveryReceipts: boolean; + supportsReadReceipts: boolean; + supportsTypingIndicator: boolean; + supportsReactions: boolean; + supportsReplies: boolean; + supportsEditing: boolean; + supportsDeleting: boolean; + maxMediaSize: number; + supportedMediaTypes: string[]; + canInitiateConversation: boolean; +} + +export interface Provider { + readonly kind: ProviderKind; + readonly capabilities: ProviderCapabilities; + + // Lifecycle + initialize(config: ProviderConfig): Promise; + isConnected(): boolean; + disconnect(): Promise; + + // Outbound + send(to: string, body: string, options?: SendOptions): Promise; + sendTyping(to: string): Promise; + + // Inbound (relay mode) + onMessage(handler: (msg: ProviderMessage) => Promise): void; + startListening(): Promise; + stopListening(): Promise; + + // Status (optional) + getDeliveryStatus?(messageId: string): Promise; +} +``` + +### 4.2 Provider Factory Pattern + +```mermaid +flowchart TB + subgraph Factory["Provider Factory"] + Create[[createProvider]] + Registry[(Provider Registry)] + end + + subgraph Providers["Provider Implementations"] + Twilio[[TwilioProvider]] + Web[[WebProvider]] + Telegram[[TelegramProvider]] + end + + Config[(Config / Env)] + + Config --> Create + Create --> Registry + Registry --> Twilio + Registry --> Web + Registry --> Telegram +``` + +```typescript +// src/providers/factory.ts (proposed) + +import type { Provider, ProviderConfig, ProviderKind } from "./types.js"; + +const providerRegistry = new Map Provider>(); + +export function registerProvider(kind: ProviderKind, ctor: new () => Provider) { + providerRegistry.set(kind, ctor); +} + +export async function createProvider(config: ProviderConfig): Promise { + const ProviderClass = providerRegistry.get(config.kind); + if (!ProviderClass) { + throw new Error(`Unknown provider: ${config.kind}`); + } + const provider = new ProviderClass(); + await provider.initialize(config); + return provider; +} + +export function getAvailableProviders(): ProviderKind[] { + return [...providerRegistry.keys()]; +} +``` + +### 4.3 Unified Message Model + +```mermaid +flowchart LR + subgraph Inbound["Inbound Normalization"] + TwilioIn[Twilio Message] + WebIn[Baileys Message] + TelegramIn[Telegram Message] + end + + Normalize[[Normalize to ProviderMessage]] + + subgraph Unified["Unified Processing"] + AutoReply[[Auto-Reply]] + Session[[Session Management]] + Logging[[Logging]] + end + + TwilioIn --> Normalize + WebIn --> Normalize + TelegramIn --> Normalize + Normalize --> AutoReply + AutoReply --> Session + AutoReply --> Logging +``` + +**Caption:** All provider messages normalized to common format before processing. + +### 4.4 Identifier Normalization + +```typescript +// src/providers/identifiers.ts (proposed) + +export interface NormalizedIdentifier { + provider: ProviderKind; + raw: string; // Original identifier + normalized: string; // Provider-specific normalized form + display: string; // Human-readable form +} + +export function normalizeIdentifier( + provider: ProviderKind, + raw: string +): NormalizedIdentifier { + switch (provider) { + case "wa-twilio": + case "wa-web": + // WhatsApp: E.164 phone number + const e164 = raw.replace(/^whatsapp:/, "").replace(/[^\d+]/g, ""); + return { + provider, + raw, + normalized: e164.startsWith("+") ? e164 : `+${e164}`, + display: e164.startsWith("+") ? e164 : `+${e164}`, + }; + + case "telegram": + // Telegram: numeric user ID or @username + if (/^\d+$/.test(raw)) { + return { + provider, + raw, + normalized: raw, + display: `user:${raw}`, + }; + } + if (raw.startsWith("@")) { + return { + provider, + raw, + normalized: raw.toLowerCase(), + display: raw, + }; + } + return { provider, raw, normalized: raw, display: raw }; + + default: + return { provider, raw, normalized: raw, display: raw }; + } +} +``` + +--- + +## 5. Configuration Schema Design + +### 5.1 Extended Environment Variables + +```bash +# .env.example (extended) + +# ============================================================================= +# TWILIO (WhatsApp Business API) +# ============================================================================= +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_AUTH_TOKEN=your_auth_token_here +TWILIO_WHATSAPP_FROM=whatsapp:+17343367101 + +# ============================================================================= +# TELEGRAM (MTProto Client) +# ============================================================================= +# Get from https://my.telegram.org/apps +TELEGRAM_API_ID=12345678 +TELEGRAM_API_HASH=0123456789abcdef0123456789abcdef +``` + +### 5.2 Extended warelay.json Schema + +```typescript +// Extended WarelayConfig type + +export type TelegramConfig = { + // Security whitelist (same model as WhatsApp) + allowFrom?: string[]; // Usernames (@user) or user IDs (123456789) +}; + +export type WarelayConfig = { + logging?: LoggingConfig; + + // Provider-specific settings + web?: WebConfig; + telegram?: TelegramConfig; + + inbound?: { + allowFrom?: string[]; // WhatsApp E.164 numbers + messagePrefix?: string; + responsePrefix?: string; + timestampPrefix?: boolean | string; + transcribeAudio?: { ... }; + reply?: { ... }; + }; +}; +``` + +### 5.3 Sample warelay.json with Telegram + +```json5 +{ + "logging": { + "level": "info", + "file": "~/.warelay/logs/warelay.log" + }, + + // Telegram-specific settings + "telegram": { + "allowFrom": ["@alice", "@bob", "123456789"] + }, + + // Web relay settings + "wa-web": { + "heartbeatSeconds": 300, + "reconnect": { + "maxAttempts": 10, + "initialMs": 1000, + "maxMs": 30000 + } + }, + + // Shared inbound settings (apply to WhatsApp) + "inbound": { + "allowFrom": ["+15551234567"], + "responsePrefix": "", + "timestampPrefix": "America/Los_Angeles", + "reply": { + "mode": "command", + "command": ["claude", "-p", "{{Body}}"], + "timeoutSeconds": 300, + "session": { + "scope": "per-sender", + "idleMinutes": 30, + "resetTriggers": ["/new", "/reset"] + } + } + } +} +``` + +### 5.4 Provider Selection Logic (Extended) + +```mermaid +flowchart TB + Start([Provider Selection]) + Flag{--provider flag?} + + Flag -->|telegram| CheckTG{Telegram session exists?} + Flag -->|wa-twilio| CheckTwilio{Twilio env set?} + Flag -->|wa-web| CheckWeb{WhatsApp Web auth exists?} + Flag -->|auto| AutoSelect + + CheckTG -->|yes| UseTelegram[Use Telegram] + CheckTG -->|no| Error[Error: Run warelay login --provider telegram] + + CheckTwilio -->|yes| UseTwilio[Use WhatsApp Twilio] + CheckTwilio -->|no| Error2[Error: Set Twilio env] + + CheckWeb -->|yes| UseWeb[Use WhatsApp Web] + CheckWeb -->|no| Error3[Error: Run warelay login] + + AutoSelect --> WebAuth{Web auth exists?} + WebAuth -->|yes| UseWeb + WebAuth -->|no| TelegramAuth{Telegram session?} + TelegramAuth -->|yes| UseTelegram + TelegramAuth -->|no| TwilioAuth{Twilio env?} + TwilioAuth -->|yes| UseTwilio + TwilioAuth -->|no| NoProvider[Error: No provider available] +``` + +**Caption:** Extended provider selection with Telegram priority between Web and Twilio. + +--- + +## 6. Implementation Roadmap + +### 6.1 Phase Overview + +```mermaid +flowchart LR + P1[Phase 1\nAbstraction] + P2[Phase 2\nTelegram MTProto] + P3[Phase 3\nFeature Parity] + P4[Phase 4\nPolish] + + P1 --> P2 --> P3 --> P4 +``` + +### 6.2 Phase 1: Provider Abstraction Refactoring (Week 1-2) + +**Goal:** Introduce formal provider interface without breaking existing functionality. + +#### Tasks + +1. **Create provider interface types** + - `src/providers/types.ts` - Interface definitions + - `src/providers/factory.ts` - Provider factory + - `src/providers/identifiers.ts` - ID normalization + +2. **Wrap existing providers** + - `src/providers/twilio/provider.ts` - TwilioProvider class + - `src/providers/web/provider.ts` - WebProvider class + +3. **Update CLI deps** + - Modify `src/cli/deps.ts` to use provider factory + - Keep backward compatibility during transition + +4. **Add provider tests** + - Unit tests for provider interface compliance + - Integration tests for existing functionality + +#### File Structure After Phase 1 + +``` +src/providers/ + types.ts # NEW: Interface definitions + factory.ts # NEW: Provider factory + identifiers.ts # NEW: ID normalization + twilio/ + index.ts # Existing: Re-exports + provider.ts # NEW: TwilioProvider class + web/ + index.ts # Existing: Re-exports + provider.ts # NEW: WebProvider class +``` + +### 6.3 Phase 2: Telegram MTProto Provider (Week 3-4) + +**Goal:** Implement Telegram MTProto client provider. + +#### Tasks + +1. **Add Telegram dependencies** + ```bash + pnpm add telegram # GramJS + pnpm add input # For interactive login + ``` + +2. **Create Telegram provider** + - `src/providers/telegram/client.ts` - GramJS client wrapper + - `src/providers/telegram/provider.ts` - TelegramProvider class + - `src/providers/telegram/session.ts` - Session management + - `src/providers/telegram/login.ts` - Interactive login flow + +3. **Implement core functionality** + - Send messages (text + media) + - Receive via persistent connection + - Session persistence + +4. **Add Telegram-specific features** + - User ID / username resolution + - `allowFrom` filtering + +#### Telegram Provider Structure + +``` +src/providers/telegram/ + index.ts # Re-exports + provider.ts # TelegramProvider class + client.ts # GramJS wrapper + session.ts # Session storage + login.ts # Interactive login + media.ts # Telegram media handling +``` + +### 6.4 Phase 3: Feature Parity (Week 5) + +**Goal:** Full feature parity with WhatsApp Web provider. + +#### Tasks + +1. **Media support** - Images, audio, video, documents +2. **Auto-reply integration** - Test with Claude agent +3. **Relay mode** - Persistent connection with reconnection +4. **CLI commands** - login, send, relay, status + +### 6.5 Phase 4: Documentation and Polish (Week 6) + +**Goal:** Complete documentation and edge case handling. + +#### Tasks + +1. **Documentation** - User guides, API docs, examples +2. **Error handling** - Session expiry, rate limits, network errors +3. **Testing** - Unit, integration, E2E tests + +--- + +## 7. User Experience Design + +### 7.1 CLI Command Changes + +#### Current Commands (Unchanged) + +```bash +# Send (add --provider telegram) +warelay send --to --message "text" --provider telegram + +# Relay (add --provider telegram) +warelay relay --provider telegram --verbose +``` + +#### New Telegram-Specific Commands + +```bash +# Login with phone + code + 2FA +warelay login --provider telegram +# Interactive: prompts for phone, code, 2FA password +``` + +### 7.2 Example Workflows + +#### Workflow 1: Send via Telegram + +```bash +# One-time setup +export TELEGRAM_API_ID="12345678" +export TELEGRAM_API_HASH="0123456789abcdef..." + +# Login (once) +warelay login --provider telegram +# Enter phone: +15551234567 +# Enter code: 12345 +# Enter 2FA password: ******** + +# Send message +warelay send --provider telegram --to @john_doe --message "Hello from warelay!" + +# Send with media +warelay send --provider telegram --to @john_doe --message "Check this" --media ./photo.jpg +``` + +#### Workflow 2: Telegram Relay Mode + +```bash +# Start relay (persistent connection) +warelay relay --provider telegram --verbose + +# Output: +# warelay 1.4.0 - Telegram @yourusername listening +# logs: /tmp/warelay/warelay.log (level info) +# Ready to receive messages! +``` + +### 7.3 Provider Comparison Guide + +| Scenario | Provider | Command | +|----------|----------|---------| +| Personal WhatsApp | `wa-web` | `--provider wa-web` | +| Business WhatsApp | `wa-twilio` | `--provider wa-twilio` | +| Personal Telegram | `telegram` | `--provider telegram` | +| Auto (prefer personal) | `auto` | `--provider auto` | + +### 7.4 Migration Guide for Existing Users + +1. **No changes required** for existing WhatsApp Twilio or WhatsApp Web users (legacy names `twilio` and `web` still work) +2. **To add Telegram:** + - Get API credentials from https://my.telegram.org/apps + - Set `TELEGRAM_API_ID` and `TELEGRAM_API_HASH` environment variables + - Run `warelay login --provider telegram` + - Use `--provider telegram` flag +3. **Configuration file** is backward compatible +4. **Auto mode** will include Telegram in priority chain + +--- + +## 8. Design Decisions + +### ADR-001: MTProto Client Approach + +**Status:** Accepted + +**Context:** warelay is a personal automation tool - a butler for the user's own account. Users want to automate their personal Telegram conversations, not run a bot. + +**Decision:** Use MTProto client (GramJS) for personal account access. + +**Consequences:** +- Matches WhatsApp Web provider pattern exactly +- Can initiate conversations +- Full access to personal DMs +- Same `allowFrom` security model works +- Requires phone + code + 2FA login + +### ADR-002: GramJS Library Selection + +**Status:** Accepted + +**Context:** Multiple Node.js libraries exist for Telegram MTProto. + +| Library | TypeScript | Active | Pattern | +|---------|------------|--------|---------| +| telegram (GramJS) | Native | Yes | Similar to Baileys | +| mtproto-core | Types available | Moderate | Low-level | + +**Decision:** Use GramJS (`telegram` npm package) for native TypeScript support and similarity to Baileys patterns. + +**Consequences:** +- Clean TypeScript integration +- Familiar patterns for Baileys users +- Active maintenance +- Good documentation + +### ADR-003: Session Storage Location + +**Status:** Accepted + +**Context:** Need to store Telegram session like WhatsApp Web credentials. + +**Decision:** Store at `~/.warelay/telegram/session/` following existing patterns. + +**Consequences:** +- Consistent with `~/.warelay/credentials/` for WhatsApp +- User-specific storage +- Easy to backup/restore +- Clear separation between providers + +### ADR-004: Security Model Consistency + +**Status:** Accepted + +**Context:** WhatsApp uses `allowFrom` whitelist. Telegram needs same model. + +**Decision:** Use `telegram.allowFrom` in config with usernames and user IDs. + +**Consequences:** +- Consistent security model across providers +- Users understand the pattern already +- Supports both `@username` and numeric IDs +- Same behavior: whitelist = only listed users trigger auto-reply + +--- + +## Appendix A: File Evidence Index + +| Section | Files Referenced | +|---------|-----------------| +| Provider Types | `src/utils.ts:L9-L14`, `src/providers/provider.types.ts:L1-L2` | +| Twilio Implementation | `src/twilio/send.ts`, `src/twilio/monitor.ts`, `src/twilio/client.ts` | +| Web Implementation | `src/web/session.ts`, `src/web/outbound.ts`, `src/web/inbound.ts` | +| CLI Commands | `src/cli/program.ts`, `src/commands/send.ts`, `src/commands/status.ts` | +| Configuration | `src/config/config.ts`, `src/env.ts`, `.env.example` | +| Dependencies | `src/cli/deps.ts` | + +## Appendix B: Glossary + +| Term | Definition | +|------|------------| +| Baileys | Open-source WhatsApp Web client library | +| GramJS | TypeScript Telegram MTProto client library | +| MTProto | Telegram's native binary protocol for user clients | +| User ID | Telegram's numeric identifier for users | +| E.164 | International phone number format (+1234567890) | +| JID | Jabber/WhatsApp ID format (number@s.whatsapp.net) | +| Provider | Messaging backend (Twilio, Web, Telegram) | +| Session | Authenticated state stored on disk | + +## Appendix C: Related Documentation + +- [GramJS Documentation](https://gram.js.org/) +- [Telegram API Documentation](https://core.telegram.org/api) +- [Twilio WhatsApp API](https://www.twilio.com/docs/whatsapp) +- [Baileys Documentation](https://github.com/WhiskeySockets/Baileys) diff --git a/docs/telegram.md b/docs/telegram.md new file mode 100644 index 000000000..dd221d89a --- /dev/null +++ b/docs/telegram.md @@ -0,0 +1,497 @@ +# Telegram Integration + +## Overview + +warelay now supports Telegram via the MTProto client library (GramJS), allowing you to use your personal Telegram account for automated messaging. This provides the same personal automation capabilities as WhatsApp Web, but for Telegram conversations. + +## Setup + +### 1. Get API Credentials + +Register a new application at **https://my.telegram.org/apps** to get: +- **API ID** (numeric, e.g., `12345678`) +- **API Hash** (hexadecimal string, e.g., `abcdef0123456789abcdef0123456789`) + +**Important:** These credentials are for your personal use only. Never share them publicly or commit them to version control. + +### 2. Configure Environment + +Add to `.env`: +```bash +TELEGRAM_API_ID=12345678 +TELEGRAM_API_HASH=abcdef0123456789abcdef0123456789 +``` + +### 3. Login + +```bash +warelay login --provider telegram +``` + +You'll be prompted for: +1. **Phone number** (with country code, e.g., `+15551234567`) +2. **SMS verification code** (sent to your Telegram app or SMS) +3. **2FA password** (if you have two-factor authentication enabled) + +Session is saved to `~/.warelay/telegram/session/` and persists across restarts. + +### 4. Configure Whitelist (Optional) + +In `~/.warelay/warelay.json`: +```json5 +{ + telegram: { + // Only these users can trigger auto-replies + allowFrom: [ + "@username", // Telegram username (with @) + "+1234567890", // Phone number (with +) + "123456789" // User ID (numeric) + ] + } +} +``` + +**Security note:** If `allowFrom` is empty or omitted, all incoming messages will trigger auto-replies. Use a whitelist in production. + +## CLI Usage + +### Send Messages + +**Text message:** +```bash +warelay send --provider telegram --to @username --message "Hello from warelay" +``` + +**To a user by phone number:** +```bash +warelay send --provider telegram --to +15551234567 --message "Hi!" +``` + +**To a user by numeric ID:** +```bash +warelay send --provider telegram --to 123456789 --message "Hi!" +``` + +**With media:** +```bash +warelay send --provider telegram --to @username \ + --message "Check this out" \ + --media ./image.jpg +``` + +**With media URL:** +```bash +warelay send --provider telegram --to @username \ + --message "Look at this" \ + --media https://example.com/image.jpg +``` + +### Start Relay (Auto-Reply) + +```bash +warelay relay --provider telegram --verbose +``` + +The relay will: +- Connect to Telegram via MTProto +- Listen for incoming messages +- Send typing indicators while processing +- Auto-reply based on your configuration +- Persist sessions across conversations + +### Check Status + +```bash +warelay status --provider telegram --limit 20 --lookback 240 +``` + +Shows recent sent/received messages with delivery status. + +### Logout + +```bash +warelay logout --provider telegram +``` + +Removes the saved session from `~/.warelay/telegram/session/`. + +## Features + +| Feature | Supported | Notes | +|---------|-----------|-------| +| Text messages | ✅ | Full UTF-8 support, including emoji | +| Media (images, video, audio) | ⚠️ | Up to 2 GB supported, but files >500MB may cause memory issues (buffers entire file) | +| Typing indicators | ✅ | Shows "typing..." while processing | +| Replies | ✅ | Reply to specific messages | +| Message formatting | ✅ | Markdown and HTML formatting | +| Max media size | 2 GB | Enforced when Content-Length available; ⚠️ large files buffered in memory | +| Delivery receipts | ❌ | MTProto limitation (no sent/delivered/read states) | +| Read receipts | ❌ | Not exposed via Provider interface | +| Reactions | ❌ | Not exposed via Provider interface (requires peer context) | +| Editing | ❌ | Not exposed via Provider interface (requires peer context) | +| Deleting | ❌ | Not exposed via Provider interface (requires peer context) | +| Group chats | ⚠️ | Not yet implemented (planned) | + +**Note on advanced features:** While Telegram's MTProto API supports reactions, editing, and deleting messages, these features require maintaining peer context (chat/user entity references) which the current Provider interface architecture doesn't support. These features may be added in a future Provider interface revision. + +## Security Model + +### Personal Account Automation + +Telegram integration uses **MTProto client** (not Bot API), which means: +- You're using your personal Telegram account as an automation tool +- All messages appear as coming from you (your name, profile picture) +- You have full access to your conversations and contacts +- No bot limitations (can initiate conversations, see full message history) + +### Whitelist Filtering + +Control who can trigger auto-replies via `allowFrom` config: + +```json5 +{ + telegram: { + allowFrom: ["@alice", "@bob", "123456789"] + } +} +``` + +- **Username** (`@alice`): Match by Telegram username +- **Phone number** (`+15551234567`): Match by phone number +- **User ID** (`123456789`): Match by numeric Telegram user ID + +If `allowFrom` is empty or omitted, **all messages trigger auto-replies** (use with caution). + +### Session Storage + +Session files are stored encrypted at `~/.warelay/telegram/session/`: +- Contains authentication tokens and keys +- Persists across restarts +- Should be treated as sensitive (equivalent to login credentials) +- Backup recommended if running in production + +### MTProto End-to-End Encryption + +- All communication uses Telegram's MTProto protocol +- Messages are encrypted in transit +- Secret chats (end-to-end encrypted) are not supported by the client library + +## Troubleshooting + +### "No Telegram session found" + +**Problem:** You haven't logged in yet. + +**Solution:** +```bash +warelay login --provider telegram +``` + +### "Telegram not configured" + +**Problem:** Missing `TELEGRAM_API_ID` or `TELEGRAM_API_HASH` in `.env`. + +**Solution:** +1. Get credentials from https://my.telegram.org/apps +2. Add them to `.env`: + ```bash + TELEGRAM_API_ID=12345678 + TELEGRAM_API_HASH=your_hash_here + ``` + +### "Could not resolve entity" + +**Problem:** The username, phone number, or user ID is invalid or not found. + +**Solution:** Check the identifier format: +- Usernames must start with `@` (e.g., `@username`) +- Phone numbers must start with `+` (e.g., `+15551234567`) +- User IDs are numeric (e.g., `123456789`) + +**Tip:** You can get a user's ID by sending them a message and checking the logs with `--verbose`. + +### Re-authentication needed + +**Problem:** Session expired or was invalidated. + +**Solution:** +```bash +warelay logout --provider telegram +warelay login --provider telegram +``` + +### "FLOOD_WAIT" error + +**Problem:** You're sending too many requests too quickly (rate limited by Telegram). + +**Solution:** +- Wait the specified number of seconds before retrying +- Reduce message frequency +- Implement delays between sends + +### Session corruption + +**Problem:** Session file is corrupted or invalid. + +**Solution:** +```bash +# Remove corrupted session +rm -rf ~/.warelay/telegram/session/ + +# Re-login +warelay login --provider telegram +``` + +## Configuration Examples + +### Simple Text Auto-Reply + +```json5 +{ + telegram: { + allowFrom: ["@alice", "@bob"] + }, + inbound: { + reply: { + mode: "text", + text: "Thanks for your message! I'll get back to you soon." + } + } +} +``` + +### Claude-Powered Assistant + +```json5 +{ + telegram: { + allowFrom: ["@alice", "+15551234567"] + }, + inbound: { + reply: { + mode: "command", + bodyPrefix: "You are a helpful assistant on Telegram. Be concise.\n\n", + command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"], + claudeOutputFormat: "text", + session: { + scope: "per-sender", + resetTriggers: ["/new"], + idleMinutes: 60 + } + } + } +} +``` + +### Per-Sender Sessions with Heartbeats + +```json5 +{ + telegram: { + allowFrom: ["@alice", "@bob", "@charlie"] + }, + inbound: { + reply: { + mode: "command", + command: ["claude", "{{BodyStripped}}"], + claudeOutputFormat: "text", + session: { + scope: "per-sender", + resetTriggers: ["/new", "/reset"], + idleMinutes: 120, + heartbeatIdleMinutes: 10 + }, + heartbeatMinutes: 15 + } + } +} +``` + +## Comparison with WhatsApp + +| Feature | WhatsApp Web | WhatsApp Twilio | Telegram | +|---------|--------------|-----------------|----------| +| **Authentication** | QR code scan | API credentials | Phone + SMS + 2FA | +| **Account Type** | Personal | Business | Personal | +| **Protocol** | WebSocket (Baileys) | HTTP (Twilio API) | MTProto (GramJS) | +| **Max file size** | 100 MB | 5 MB | 2 GB | +| **Typing indicators** | ✅ | ✅ | ✅ | +| **Read receipts** | ✅ | ❌ | ❌ | +| **Delivery tracking** | Limited | Full | Limited | +| **Group chats** | ✅ | ✅ | ⚠️ (planned) | +| **Reactions** | ❌ | ❌ | ❌ | +| **Edit messages** | ❌ | ❌ | ❌ | +| **Delete messages** | ✅ | ✅ | ❌ | +| **Cost** | Free | Pay per message | Free | + +**Note:** Telegram's MTProto API technically supports reactions, edits, and deletes, but these are not exposed via the Provider interface (requires peer context architecture changes). + +## Best Practices + +### 1. Use a Dedicated Account + +Consider using a separate Telegram account for automation: +- Reduces risk to your primary account +- Easier to manage rate limits +- Clearer separation of personal and automated messages + +### 2. Implement Rate Limiting + +Telegram has rate limits for personal accounts: +- Avoid sending bursts of messages +- Space sends by a few seconds +- Handle `FLOOD_WAIT` errors gracefully + +### 3. Backup Your Session + +Session files contain authentication tokens: +```bash +# Backup +cp -r ~/.warelay/telegram/session/ ~/backups/warelay-telegram-session/ + +# Restore +cp -r ~/backups/warelay-telegram-session/ ~/.warelay/telegram/session/ +``` + +### 4. Monitor Logs + +Run with `--verbose` to see detailed activity: +```bash +warelay relay --provider telegram --verbose +``` + +Logs include: +- Connection status +- Inbound/outbound messages +- Session management +- Error details + +### 5. Secure Your Credentials + +- Never commit `.env` to version control +- Treat `TELEGRAM_API_ID` and `TELEGRAM_API_HASH` as secrets +- Store session backups securely +- Use `allowFrom` whitelist in production + +## Advanced Usage + +### Running Multiple Providers + +You can run WhatsApp and Telegram relays simultaneously: + +**Terminal 1 (WhatsApp):** +```bash +tmux new -s warelay-whatsapp -d "warelay relay --provider wa-web --verbose" +``` + +**Terminal 2 (Telegram):** +```bash +tmux new -s warelay-telegram -d "warelay relay --provider telegram --verbose" +``` + +### Custom Session Storage + +Override session path in config: +```json5 +{ + telegram: { + sessionPath: "/custom/path/to/session/" + } +} +``` + +### Verbose Output + +Get detailed logs for debugging: +```bash +warelay relay --provider telegram --verbose +``` + +Output includes: +- MTProto connection events +- Message send/receive details +- Session state changes +- Error stack traces + +## Limitations + +### Current Limitations + +1. **Group chats not yet supported** - Only 1-on-1 conversations work currently (group support planned) +2. **No delivery receipts** - MTProto doesn't provide sent/delivered/read states like Twilio +3. **No secret chats** - End-to-end encrypted "Secret Chats" are not supported by GramJS +4. **Rate limits** - Personal accounts have rate limits (use with moderation) + +### Media Handling + +**Streaming Implementation** + +Media downloads use streaming to temporary files, eliminating memory buffering: + +- Files downloaded to `~/.warelay/telegram-temp` +- No memory spike regardless of file size +- Automatic cleanup after send (success or failure) +- Orphaned files cleaned on process restart (1 hour TTL) + +**Disk Usage:** +- Temp file created during download +- Cleaned immediately after send +- Max disk usage: size of largest concurrent download + +**Performance:** +- No memory overhead for large files +- Same download speed as before +- Proper backpressure handling via Node.js streams + +**Production Safety:** +Set `TELEGRAM_MAX_MEDIA_MB` to limit disk usage: +```bash +# Limit to 500MB for production +TELEGRAM_MAX_MEDIA_MB=500 warelay relay --provider telegram +``` + +**Note:** The limit is read at process startup. Changing the env var requires restarting the relay. + +### Known Issues + +- Session may expire if not used for extended periods (re-login required) +- Username changes won't be reflected in `allowFrom` until relay restart + +## Resources + +- **Get API credentials:** https://my.telegram.org/apps +- **Telegram API documentation:** https://core.telegram.org/api +- **GramJS library:** https://gram.js.org/ +- **MTProto protocol:** https://core.telegram.org/mtproto + +## Migration from Other Providers + +### From WhatsApp Web + +1. Keep your WhatsApp Web configuration +2. Add Telegram credentials to `.env` +3. Run `warelay login --provider telegram` +4. Start Telegram relay alongside WhatsApp: + ```bash + # WhatsApp relay (terminal 1) + warelay relay --provider wa-web --verbose + + # Telegram relay (terminal 2) + warelay relay --provider telegram --verbose + ``` + +### From WhatsApp Twilio + +Similar steps as above - both providers can coexist. + +## Getting Help + +If you encounter issues: + +1. **Check logs:** Run with `--verbose` flag +2. **Verify credentials:** Ensure API ID/Hash are correct +3. **Test login:** Try `warelay login --provider telegram` manually +4. **Check session:** Verify `~/.warelay/telegram/session/` exists and is readable +5. **Review config:** Ensure `~/.warelay/warelay.json` is valid JSON5 + +For bugs or feature requests, file an issue on GitHub. diff --git a/package.json b/package.json index 2118ca5e4..e882b065e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "pino": "^10.1.0", "qrcode-terminal": "^0.12.0", "sharp": "^0.33.5", + "telegram": "^2.26.22", "twilio": "^5.10.6", "zod": "^4.1.13" }, diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 51c428175..fe061c9f9 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -11,6 +11,8 @@ import { sendMessageWeb, } from "../providers/web/index.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { createTelegramClient } from "../telegram/client.js"; +import { monitorTelegramProvider } from "../telegram/monitor.js"; import { createClient } from "../twilio/client.js"; import { listRecentMessages } from "../twilio/messages.js"; import { monitorTwilio as monitorTwilioImpl } from "../twilio/monitor.js"; @@ -27,7 +29,9 @@ export type CliDeps = { waitForFinalStatus: typeof waitForFinalStatus; assertProvider: typeof assertProvider; createClient?: typeof createClient; + createTelegramClient: typeof createTelegramClient; monitorTwilio: typeof monitorTwilio; + monitorTelegramProvider: typeof monitorTelegramProvider; listRecentMessages: typeof listRecentMessages; ensurePortAvailable: typeof ensurePortAvailable; startWebhook: typeof startWebhook; @@ -75,7 +79,9 @@ export function createDefaultDeps(): CliDeps { waitForFinalStatus, assertProvider, createClient, + createTelegramClient, monitorTwilio, + monitorTelegramProvider, listRecentMessages, ensurePortAvailable, startWebhook, diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index c0a883e4f..1085ea609 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -60,7 +60,7 @@ describe("cli program", () => { program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }), ).rejects.toThrow("exit"); expect(runtime.error).toHaveBeenCalledWith( - "--provider must be auto, web, or twilio", + "--provider must be auto, web, twilio, or telegram", ); }); diff --git a/src/cli/program.ts b/src/cli/program.ts index 111ec46ca..87f4f23e6 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -6,7 +6,7 @@ import { statusCommand } from "../commands/status.js"; import { webhookCommand } from "../commands/webhook.js"; import { loadConfig } from "../config/config.js"; import { ensureTwilioEnv } from "../env.js"; -import { danger, info, setVerbose, setYes } from "../globals.js"; +import { danger, info, setVerbose, setYes, success } from "../globals.js"; import { getResolvedLoggerSettings } from "../logging.js"; import { loginWeb, @@ -18,6 +18,12 @@ import { type WebMonitorTuning, } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; +import { + clearSession, + loginTelegram, + monitorTelegramProvider, + telegramAuthExists, +} from "../telegram/index.js"; import { runTwilioHeartbeatOnce } from "../twilio/heartbeat.js"; import type { Provider } from "../utils.js"; import { VERSION } from "../version.js"; @@ -112,23 +118,44 @@ export function buildProgram() { program .command("login") - .description("Link your personal WhatsApp via QR (web provider)") + .description("Link your personal WhatsApp via QR (web provider) or Telegram") + .option("--provider ", "Provider: web | telegram", "web") .option("--verbose", "Verbose connection logs", false) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); + const provider = String(opts.provider ?? "web"); + if (!["web", "telegram"].includes(provider)) { + defaultRuntime.error("--provider must be web or telegram"); + defaultRuntime.exit(1); + } try { + if (provider === "telegram") { + await loginTelegram(Boolean(opts.verbose), defaultRuntime); + return; + } await loginWeb(Boolean(opts.verbose)); } catch (err) { - defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); + defaultRuntime.error(danger(`${provider} login failed: ${String(err)}`)); defaultRuntime.exit(1); } }); program .command("logout") - .description("Clear cached WhatsApp Web credentials") - .action(async () => { + .description("Clear cached WhatsApp Web or Telegram credentials") + .option("--provider ", "Provider: web | telegram", "web") + .action(async (opts) => { + const provider = String(opts.provider ?? "web"); + if (!["web", "telegram"].includes(provider)) { + defaultRuntime.error("--provider must be web or telegram"); + defaultRuntime.exit(1); + } try { + if (provider === "telegram") { + await clearSession(); + console.log(success("Cleared Telegram credentials.")); + return; + } await logoutWeb(defaultRuntime); } catch (err) { defaultRuntime.error(danger(`Logout failed: ${String(err)}`)); @@ -159,7 +186,7 @@ export function buildProgram() { "20", ) .option("-p, --poll ", "Polling interval while waiting", "2") - .option("--provider ", "Provider: twilio | web", "twilio") + .option("--provider ", "Provider: twilio | web | telegram", "twilio") .option("--dry-run", "Print payload and skip sending", false) .option("--json", "Output result as JSON", false) .option("--verbose", "Verbose logging", false) @@ -349,7 +376,7 @@ Examples: program .command("relay") .description("Auto-reply to inbound messages (auto-selects web or twilio)") - .option("--provider ", "auto | web | twilio", "auto") + .option("--provider ", "auto | web | twilio | telegram", "auto") .option("-i, --interval ", "Polling interval for twilio mode", "5") .option( "-l, --lookback ", @@ -391,8 +418,8 @@ Examples: const { file: logFile, level: logLevel } = getResolvedLoggerSettings(); defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`)); const providerPref = String(opts.provider ?? "auto"); - if (!["auto", "web", "twilio"].includes(providerPref)) { - defaultRuntime.error("--provider must be auto, web, or twilio"); + if (!["auto", "web", "twilio", "telegram"].includes(providerPref)) { + defaultRuntime.error("--provider must be auto, web, twilio, or telegram"); defaultRuntime.exit(1); } const intervalSeconds = Number.parseInt(opts.interval, 10); @@ -470,6 +497,12 @@ Examples: webTuning.reconnect = reconnect; } + // Handle telegram explicitly (not in auto picker) + if (providerPref === "telegram") { + await monitorTelegramProvider(Boolean(opts.verbose), defaultRuntime); + return; + } + const provider = await pickProvider(providerPref as Provider | "auto"); if (provider === "web") { @@ -591,6 +624,14 @@ Examples: setVerbose(Boolean(opts.verbose)); const deps = createDefaultDeps(); try { + // Show provider auth status before message listing + if (!opts.json) { + const hasTelegram = await telegramAuthExists(); + defaultRuntime.log( + `Telegram: ${hasTelegram ? success("✓ logged in") : info("✗ not logged in")}`, + ); + defaultRuntime.log(""); // Empty line before messages + } await statusCommand(opts, deps, defaultRuntime); } catch (err) { defaultRuntime.error(String(err)); diff --git a/src/commands/send.ts b/src/commands/send.ts index 30b99f057..59cafa645 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -1,6 +1,10 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import type { CliDeps } from "../cli/deps.js"; -import { info, success } from "../globals.js"; +import { danger, info, success } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; +import { sendMediaMessage, sendTextMessage } from "../telegram/outbound.js"; +import { loadSession } from "../telegram/session.js"; import type { Provider } from "../utils.js"; import { sendViaIpc } from "../web/ipc.js"; @@ -15,6 +19,7 @@ export async function sendCommand( dryRun?: boolean; media?: string; serveMedia?: boolean; + verbose?: boolean; }, deps: CliDeps, runtime: RuntimeEnv, @@ -101,6 +106,98 @@ export async function sendCommand( return; } + if (opts.provider === "telegram") { + if (opts.dryRun) { + runtime.log( + `[dry-run] would send via telegram -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`, + ); + return; + } + + // Load saved session + const session = await loadSession(); + if (!session) { + runtime.error( + danger( + "No Telegram session found. Run: warelay login --provider telegram", + ), + ); + throw new Error("Not logged in to Telegram"); + } + + // Create and connect client + const client = await deps.createTelegramClient( + session, + Boolean(opts.verbose), + runtime, + ); + + try { + await client.connect(); + + if (!client.connected) { + throw new Error("Failed to connect to Telegram"); + } + + let result; + if (opts.media) { + // Determine media type from file extension + const ext = opts.media.toLowerCase().split(".").pop() || ""; + const imageExts = ["jpg", "jpeg", "png", "gif", "webp"]; + const videoExts = ["mp4", "mov", "avi", "mkv"]; + const audioExts = ["mp3", "wav", "ogg", "m4a"]; + + let type: "image" | "video" | "audio" | "document" = "document"; + if (imageExts.includes(ext)) type = "image"; + else if (videoExts.includes(ext)) type = "video"; + else if (audioExts.includes(ext)) type = "audio"; + + // Check if media is a URL or local file + const isUrl = /^https?:\/\//i.test(opts.media); + if (isUrl) { + // Send URL directly + result = await sendMediaMessage(client, opts.to, opts.message, { + type, + url: opts.media, + }); + } else { + // Load local file as buffer + const buffer = await fs.readFile(opts.media); + result = await sendMediaMessage(client, opts.to, opts.message, { + type, + buffer, + fileName: path.basename(opts.media), + }); + } + } else { + result = await sendTextMessage(client, opts.to, opts.message); + } + + if (opts.json) { + runtime.log( + JSON.stringify( + { + provider: "telegram", + to: opts.to, + messageId: result.messageId, + mediaUrl: opts.media ?? null, + }, + null, + 2, + ), + ); + } else { + runtime.log( + success(`✅ Sent to ${opts.to} via Telegram (id ${result.messageId})`), + ); + } + } finally { + // Always disconnect client + await client.disconnect(); + } + return; + } + if (opts.dryRun) { runtime.log( `[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`, diff --git a/src/commands/up.ts b/src/commands/up.ts index 3003f98fd..9f4a2a6cc 100644 --- a/src/commands/up.ts +++ b/src/commands/up.ts @@ -54,7 +54,7 @@ export async function upCommand( const twilioClient = deps.createClient(env); const senderSid = await deps.findWhatsappSenderSid( twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient, - env.whatsappFrom, + env.whatsappFrom!, env.whatsappSenderSid, runtime, ); diff --git a/src/config/config.ts b/src/config/config.ts index d75f5f282..a34a928cc 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -43,6 +43,10 @@ export type WebConfig = { reconnect?: WebReconnectConfig; }; +export type TelegramConfig = { + allowFrom?: string[]; // @username or user IDs +}; + export type GroupChatConfig = { requireMention?: boolean; mentionPatterns?: string[]; @@ -86,6 +90,7 @@ export type WarelayConfig = { }; }; web?: WebConfig; + telegram?: TelegramConfig; }; // New branding path (preferred) @@ -233,6 +238,11 @@ const WarelaySchema = z.object({ .optional(), }) .optional(), + telegram: z + .object({ + allowFrom: z.array(z.string()).optional(), + }) + .optional(), }); export function loadConfig(): WarelayConfig { diff --git a/src/env.test.ts b/src/env.test.ts index 7c1502096..86b4dc5fa 100644 --- a/src/env.test.ts +++ b/src/env.test.ts @@ -40,7 +40,7 @@ describe("env helpers", () => { const cfg = readEnv(runtime); expect(cfg.accountSid).toBe("AC123"); expect(cfg.whatsappFrom).toBe("whatsapp:+1555"); - if ("authToken" in cfg.auth) { + if (cfg.auth && "authToken" in cfg.auth) { expect(cfg.auth.authToken).toBe("token"); } else { throw new Error("Expected auth token"); @@ -55,7 +55,7 @@ describe("env helpers", () => { TWILIO_API_SECRET: "secret", }); const cfg = readEnv(runtime); - if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) { + if (cfg.auth && "apiKey" in cfg.auth && "apiSecret" in cfg.auth) { expect(cfg.auth.apiKey).toBe("key"); expect(cfg.auth.apiSecret).toBe("secret"); } else { @@ -63,9 +63,20 @@ describe("env helpers", () => { } }); - it("fails fast on invalid env", () => { + it("passes when no Twilio vars present (Telegram-only mode)", () => { setEnv({ - TWILIO_ACCOUNT_SID: "", + TELEGRAM_API_ID: "12345", + TELEGRAM_API_HASH: "abcdef", + }); + const cfg = readEnv(runtime); + expect(cfg.telegram).toEqual({ apiId: "12345", apiHash: "abcdef" }); + expect(cfg.accountSid).toBeUndefined(); + expect(cfg.auth).toBeUndefined(); + }); + + it("fails when Twilio vars partially set", () => { + setEnv({ + TWILIO_ACCOUNT_SID: "AC123", TWILIO_WHATSAPP_FROM: "", TWILIO_AUTH_TOKEN: undefined, TWILIO_API_KEY: undefined, diff --git a/src/env.ts b/src/env.ts index 9061ff948..225a57f4a 100644 --- a/src/env.ts +++ b/src/env.ts @@ -8,22 +8,56 @@ export type AuthMode = | { accountSid: string; apiKey: string; apiSecret: string }; export type EnvConfig = { - accountSid: string; - whatsappFrom: string; + accountSid?: string; + whatsappFrom?: string; whatsappSenderSid?: string; - auth: AuthMode; + auth?: AuthMode; + telegram?: { + apiId: string; + apiHash: string; + }; }; const EnvSchema = z .object({ - TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"), - TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"), + TWILIO_ACCOUNT_SID: z.string().optional(), + TWILIO_WHATSAPP_FROM: z.string().optional(), TWILIO_SENDER_SID: z.string().optional(), TWILIO_AUTH_TOKEN: z.string().optional(), TWILIO_API_KEY: z.string().optional(), TWILIO_API_SECRET: z.string().optional(), + TELEGRAM_API_ID: z.string().optional(), + TELEGRAM_API_HASH: z.string().optional(), }) .superRefine((val, ctx) => { + // Only validate Twilio auth if any Twilio vars are present + const hasTwilioVars = + val.TWILIO_ACCOUNT_SID || + val.TWILIO_WHATSAPP_FROM || + val.TWILIO_AUTH_TOKEN || + val.TWILIO_API_KEY || + val.TWILIO_API_SECRET; + + if (!hasTwilioVars) { + // No Twilio vars present, skip Twilio validation (Telegram-only mode) + return; + } + + // If any Twilio vars are present, enforce required fields + if (!val.TWILIO_ACCOUNT_SID) { + ctx.addIssue({ + code: "custom", + message: "TWILIO_ACCOUNT_SID required when using Twilio", + }); + } + if (!val.TWILIO_WHATSAPP_FROM) { + ctx.addIssue({ + code: "custom", + message: "TWILIO_WHATSAPP_FROM required when using Twilio", + }); + } + + // Validate Twilio auth pairs if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) { ctx.addIssue({ code: "custom", @@ -49,7 +83,7 @@ const EnvSchema = z }); export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig { - // Load and validate Twilio auth + sender configuration from env. + // Load and validate environment configuration (supports Twilio, Telegram, or both). const parsed = EnvSchema.safeParse(process.env); if (!parsed.success) { runtime.error("Invalid environment configuration:"); @@ -66,24 +100,31 @@ export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig { TWILIO_AUTH_TOKEN: authToken, TWILIO_API_KEY: apiKey, TWILIO_API_SECRET: apiSecret, + TELEGRAM_API_ID: telegramApiId, + TELEGRAM_API_HASH: telegramApiHash, } = parsed.data; - let auth: AuthMode; - if (apiKey && apiSecret) { - auth = { accountSid, apiKey, apiSecret }; - } else if (authToken) { - auth = { accountSid, authToken }; - } else { - runtime.error("Missing Twilio auth configuration"); - runtime.exit(1); - throw new Error("unreachable"); + // Build Twilio auth if credentials are present + let auth: AuthMode | undefined; + if (accountSid) { + if (apiKey && apiSecret) { + auth = { accountSid, apiKey, apiSecret }; + } else if (authToken) { + auth = { accountSid, authToken }; + } } + const telegram = + telegramApiId && telegramApiHash + ? { apiId: telegramApiId, apiHash: telegramApiHash } + : undefined; + return { accountSid, whatsappFrom, whatsappSenderSid, auth, + telegram, }; } diff --git a/src/providers/base/index.ts b/src/providers/base/index.ts new file mode 100644 index 000000000..7abbd6f9b --- /dev/null +++ b/src/providers/base/index.ts @@ -0,0 +1,8 @@ +/** + * Provider Base Module + * + * Exports the core provider interface and types for all messaging providers. + */ + +export * from "./interface.js"; +export * from "./types.js"; diff --git a/src/providers/base/interface.ts b/src/providers/base/interface.ts new file mode 100644 index 000000000..f738a9896 --- /dev/null +++ b/src/providers/base/interface.ts @@ -0,0 +1,120 @@ +/** + * Core Provider Interface + * + * All messaging providers must implement this interface. + */ + +import type { + DeliveryStatus, + MessageHandler, + ProviderCapabilities, + ProviderConfig, + ProviderKind, + SendOptions, + SendResult, +} from "./types.js"; + +export interface Provider { + /** Provider type identifier */ + readonly kind: ProviderKind; + + /** Declared capabilities */ + readonly capabilities: ProviderCapabilities; + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + /** + * Initialize the provider with configuration. + * Must be called before any other methods. + */ + initialize(config: ProviderConfig): Promise; + + /** + * Check if the provider is connected and ready. + */ + isConnected(): boolean; + + /** + * Gracefully disconnect and cleanup resources. + */ + disconnect(): Promise; + + // --------------------------------------------------------------------------- + // Outbound Messaging + // --------------------------------------------------------------------------- + + /** + * Send a message to a recipient. + * + * @param to - Recipient identifier (phone, user ID, username) + * @param body - Message text + * @param options - Optional send options + * @returns Send result with message ID + */ + send(to: string, body: string, options?: SendOptions): Promise; + + /** + * Send typing indicator to a chat. + * + * @param to - Recipient identifier + */ + sendTyping(to: string): Promise; + + /** + * Query delivery status of a sent message. + * + * @param messageId - Message identifier returned from send() + * @returns Current delivery status + */ + getDeliveryStatus(messageId: string): Promise; + + // --------------------------------------------------------------------------- + // Inbound Messaging + // --------------------------------------------------------------------------- + + /** + * Register a handler for inbound messages. + * The provider will call this handler when new messages arrive. + * + * @param handler - Function to handle incoming messages + */ + onMessage(handler: MessageHandler): void; + + /** + * Start listening for inbound messages. + * This starts the message polling/monitoring loop. + */ + startListening(): Promise; + + /** + * Stop listening for inbound messages. + */ + stopListening(): Promise; + + // --------------------------------------------------------------------------- + // Authentication & Session Management + // --------------------------------------------------------------------------- + + /** + * Check if provider has valid authentication. + */ + isAuthenticated(): Promise; + + /** + * Interactive login flow (QR code, phone + 2FA, etc.). + * Implementation is provider-specific. + */ + login(): Promise; + + /** + * Clear authentication and session data. + */ + logout(): Promise; + + /** + * Get current session identifier (phone, user ID, etc.). + */ + getSessionId(): Promise; +} diff --git a/src/providers/base/types.ts b/src/providers/base/types.ts new file mode 100644 index 000000000..be91e2c4c --- /dev/null +++ b/src/providers/base/types.ts @@ -0,0 +1,249 @@ +/** + * Provider Interface Types for warelay + * + * Unified interfaces for all messaging providers (WhatsApp, Telegram). + * All providers follow the same model: + * - Personal account automation for 1-on-1 conversations + * - `allowFrom` whitelist security model + * - Unified message format + */ + +// ============================================================================= +// PROVIDER TYPES +// ============================================================================= + +/** + * Supported provider kinds. + */ +export type ProviderKind = "twilio" | "web" | "telegram"; + +// ============================================================================= +// MESSAGE TYPES +// ============================================================================= + +/** + * Media attachment for messages. + */ +export interface ProviderMedia { + /** Media type category */ + type: "image" | "video" | "audio" | "document" | "voice"; + + /** Remote URL (for Twilio or download) */ + url?: string; + + /** Local buffer (for Web/Telegram direct send) */ + buffer?: Buffer; + + /** MIME type (e.g., "image/jpeg", "audio/ogg") */ + mimeType?: string; + + /** Original filename (for documents) */ + fileName?: string; + + /** File size in bytes */ + size?: number; + + /** Thumbnail buffer (for video/document previews) */ + thumbnail?: Buffer; +} + +/** + * Normalized inbound message from any provider. + */ +export interface ProviderMessage { + /** Unique message identifier */ + id: string; + + /** Sender identifier */ + from: string; + + /** Recipient identifier */ + to: string; + + /** Message text body */ + body: string; + + /** Unix timestamp in milliseconds */ + timestamp: number; + + /** Sender's display name if available */ + displayName?: string; + + /** Attached media */ + media?: ProviderMedia[]; + + /** Provider-specific raw payload */ + raw?: unknown; + + /** Which provider this message came from */ + provider: ProviderKind; +} + +// ============================================================================= +// SEND TYPES +// ============================================================================= + +/** + * Options for sending a message. + */ +export interface SendOptions { + /** Attach media to the message */ + media?: ProviderMedia[]; + + /** Message ID to reply to (creates a threaded reply) */ + replyTo?: string; + + /** Send typing indicator before message */ + typing?: boolean; + + /** Provider-specific options */ + providerOptions?: { + /** Twilio: Messaging Service SID override */ + twilioMessagingServiceSid?: string; + }; +} + +/** + * Result of sending a message. + */ +export interface SendResult { + /** Message identifier from the provider */ + messageId: string; + + /** Immediate send status */ + status: "sent" | "queued" | "failed"; + + /** Error message if status is "failed" */ + error?: string; + + /** Provider-specific metadata */ + providerMeta?: { + sid?: string; + accountSid?: string; + userId?: number; + jid?: string; + }; +} + +/** + * Delivery status for a sent message. + */ +export interface DeliveryStatus { + /** Message identifier */ + messageId: string; + + /** Current delivery status */ + status: "sent" | "delivered" | "read" | "failed" | "unknown"; + + /** Status update timestamp */ + timestamp?: number; + + /** Error details if failed */ + error?: string; + + /** Provider-specific status code */ + providerStatusCode?: string | number; +} + +// ============================================================================= +// PROVIDER CAPABILITIES +// ============================================================================= + +/** + * Declares what features a provider supports. + */ +export interface ProviderCapabilities { + supportsDeliveryReceipts: boolean; + supportsReadReceipts: boolean; + supportsTypingIndicator: boolean; + supportsReactions: boolean; + supportsReplies: boolean; + supportsEditing: boolean; + supportsDeleting: boolean; + maxMediaSize: number; + supportedMediaTypes: string[]; + canInitiateConversation: boolean; +} + +// ============================================================================= +// PROVIDER CONFIGURATION +// ============================================================================= + +/** + * Base configuration shared by all providers. + */ +export interface BaseProviderConfig { + verbose?: boolean; + logger?: unknown; +} + +/** + * Twilio WhatsApp provider configuration. + */ +export interface TwilioProviderConfig extends BaseProviderConfig { + kind: "wa-twilio"; + accountSid: string; + authToken?: string; + apiKey?: string; + apiSecret?: string; + whatsappFrom: string; + messagingServiceSid?: string; +} + +/** + * WhatsApp Web provider configuration. + */ +export interface WebProviderConfig extends BaseProviderConfig { + kind: "wa-web"; + authDir?: string; + printQr?: boolean; + reconnect?: { + initialMs?: number; + maxMs?: number; + factor?: number; + jitter?: number; + maxAttempts?: number; + }; +} + +/** + * Telegram MTProto provider configuration. + */ +export interface TelegramProviderConfig extends BaseProviderConfig { + kind: "telegram"; + apiId: number; + apiHash: string; + sessionDir?: string; + allowFrom?: string[]; +} + +/** + * Union of all provider configurations. + */ +export type ProviderConfig = + | TwilioProviderConfig + | WebProviderConfig + | TelegramProviderConfig; + +// ============================================================================= +// INBOUND MESSAGE HANDLER +// ============================================================================= + +/** + * Handler function for inbound messages. + */ +export type MessageHandler = (message: ProviderMessage) => Promise; + +/** + * Handler context with reply helpers. + */ +export interface MessageContext extends ProviderMessage { + sendTyping(): Promise; + reply(text: string): Promise; + replyWithMedia(text: string, media: ProviderMedia[]): Promise; +} + +/** + * Enhanced message handler with context. + */ +export type MessageContextHandler = (ctx: MessageContext) => Promise; diff --git a/src/providers/factory.ts b/src/providers/factory.ts new file mode 100644 index 000000000..f92e2ced9 --- /dev/null +++ b/src/providers/factory.ts @@ -0,0 +1,50 @@ +/** + * Provider Factory + * + * Factory functions for creating and initializing messaging providers. + */ + +import { TelegramProvider } from "../telegram/index.js"; +import type { Provider, ProviderConfig, ProviderKind } from "./base/index.js"; + +/** + * Create a provider instance by kind. + * + * The provider is created but NOT initialized. Call initialize() before using. + * + * @param kind - Provider type to create + * @returns Uninitialized provider instance + * @throws Error if provider kind is unknown or not yet implemented + */ +export function createProvider(kind: ProviderKind): Provider { + switch (kind) { + case "web": + case "twilio": + throw new Error( + `Provider ${kind} not yet implemented in abstraction layer - use direct CLI commands`, + ); + case "telegram": + return new TelegramProvider(); + default: + throw new Error(`Unknown provider kind: ${kind}`); + } +} + +/** + * Create and initialize a provider in one step. + * + * Convenience function that combines createProvider() and initialize(). + * + * @param kind - Provider type to create + * @param config - Configuration for the provider + * @returns Initialized and ready-to-use provider + * @throws Error if provider creation or initialization fails + */ +export async function createInitializedProvider( + kind: ProviderKind, + config: ProviderConfig, +): Promise { + const provider = createProvider(kind); + await provider.initialize(config); + return provider; +} diff --git a/src/telegram/capabilities.test.ts b/src/telegram/capabilities.test.ts new file mode 100644 index 000000000..01692b0a8 --- /dev/null +++ b/src/telegram/capabilities.test.ts @@ -0,0 +1,35 @@ +/** + * Capabilities Tests + * + * Note: These tests verify the CURRENT capability values. + * Testing TELEGRAM_MAX_MEDIA_MB env var requires subprocess tests + * since capabilities are evaluated at module load time. + */ + +import { describe, expect, it } from "vitest"; +import { capabilities } from "./capabilities.js"; + +describe("capabilities", () => { + it("has correct basic capabilities", () => { + expect(capabilities.supportsDeliveryReceipts).toBe(false); + expect(capabilities.supportsReadReceipts).toBe(false); + expect(capabilities.supportsTypingIndicator).toBe(true); + expect(capabilities.supportsReplies).toBe(true); + expect(capabilities.canInitiateConversation).toBe(true); + expect(capabilities.supportsReactions).toBe(false); + expect(capabilities.supportsEditing).toBe(false); + expect(capabilities.supportsDeleting).toBe(false); + }); + + it("has max media size set", () => { + // Default is 2GB unless TELEGRAM_MAX_MEDIA_MB is set + expect(capabilities.maxMediaSize).toBeGreaterThan(0); + expect(capabilities.maxMediaSize).toBeLessThanOrEqual( + 2 * 1024 * 1024 * 1024, + ); + }); + + it("supports all media types", () => { + expect(capabilities.supportedMediaTypes).toContain("*/*"); + }); +}); diff --git a/src/telegram/capabilities.ts b/src/telegram/capabilities.ts new file mode 100644 index 000000000..4bbb87932 --- /dev/null +++ b/src/telegram/capabilities.ts @@ -0,0 +1,72 @@ +import type { ProviderCapabilities } from "../providers/base/types.js"; + +/** + * Get the maximum media size for Telegram, respecting user overrides. + * Defaults to 2GB (Telegram's technical limit), but can be lowered via + * TELEGRAM_MAX_MEDIA_MB env var for production safety. + * + * NOTE: This is evaluated once at module load time. Changing the env var + * requires restarting the process to take effect. + */ +function getMaxMediaSize(): number { + const defaultMax = 2 * 1024 * 1024 * 1024; // 2GB + const envOverride = process.env.TELEGRAM_MAX_MEDIA_MB; + + if (envOverride) { + const overrideMB = Number.parseInt(envOverride, 10); + if (Number.isNaN(overrideMB) || overrideMB <= 0) { + console.warn( + `⚠️ Invalid TELEGRAM_MAX_MEDIA_MB="${envOverride}" (must be positive number). Using default 2048MB.`, + ); + return defaultMax; + } + const overrideBytes = overrideMB * 1024 * 1024; + if (overrideBytes > defaultMax) { + console.warn( + `⚠️ TELEGRAM_MAX_MEDIA_MB=${overrideMB} exceeds Telegram's 2048MB limit. Using 2048MB.`, + ); + return defaultMax; + } + return overrideBytes; + } + + return defaultMax; +} + +/** + * Telegram MTProto Provider Capabilities + * + * Declares what features the Telegram provider supports through the Provider interface. + * Note: Telegram's API supports many features that are not yet exposed via our Provider interface. + */ +export const capabilities: ProviderCapabilities = { + // Telegram MTProto doesn't provide reliable delivery/read receipt tracking + // Messages are sent optimistically without guaranteed delivery confirmation + supportsDeliveryReceipts: false, + supportsReadReceipts: false, // Not exposed via Provider interface + + // Typing indicator is supported + supportsTypingIndicator: true, + + // Advanced features not yet exposed via Provider interface + // (These require peer context which the current architecture doesn't maintain) + supportsReactions: false, + supportsReplies: true, // Basic reply support via send() + supportsEditing: false, + supportsDeleting: false, + + // Telegram supports 2GB files with streaming downloads (no memory buffering). + // Downloads stream to ~/.warelay/telegram-temp and are automatically cleaned up. + // For safety, set TELEGRAM_MAX_MEDIA_MB to limit disk usage and download time. + // Orphaned files cleaned on process restart (1 hour TTL). + // + // PRODUCTION TIP: Set TELEGRAM_MAX_MEDIA_MB to a lower value (e.g., 500) to limit + // disk usage and download time. Example: TELEGRAM_MAX_MEDIA_MB=500 warelay relay + maxMediaSize: getMaxMediaSize(), + + // Telegram supports virtually all file types + supportedMediaTypes: ["*/*"], + + // Telegram allows initiating conversations with any user + canInitiateConversation: true, +}; diff --git a/src/telegram/client.test.ts b/src/telegram/client.test.ts new file mode 100644 index 000000000..038be8917 --- /dev/null +++ b/src/telegram/client.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; + +const MockTelegramClient = vi.fn(); + +vi.mock("telegram", () => ({ + TelegramClient: class { + connected = false; + constructor(...args: unknown[]) { + MockTelegramClient(...args); + this.connected = false; + } + }, +})); +vi.mock("telegram/sessions/index.js", () => ({ + StringSession: class { + _sessionString: string; + constructor(sessionString = "") { + this._sessionString = sessionString; + } + save() { + return this._sessionString || "mock-session-string"; + } + }, +})); + +const { createTelegramClient, isClientConnected } = await import("./client.js"); +const { StringSession } = await import("telegram/sessions/index.js"); + +describe("telegram client", () => { + const mockRuntime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + }; + + beforeEach(() => { + vi.clearAllMocks(); + MockTelegramClient.mockClear(); + process.env = {}; + }); + + describe("createTelegramClient", () => { + it("creates client with null session", async () => { + process.env.TELEGRAM_API_ID = "12345"; + process.env.TELEGRAM_API_HASH = "abcdef"; + process.env.TWILIO_ACCOUNT_SID = "AC123"; + process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555"; + process.env.TWILIO_AUTH_TOKEN = "token"; + + await createTelegramClient(null, false, mockRuntime); + + expect(MockTelegramClient).toHaveBeenCalledWith( + expect.anything(), // StringSession + 12345, + "abcdef", + expect.objectContaining({ + connectionRetries: 5, + useWSS: true, + }), + ); + }); + + it("creates client with existing session", async () => { + process.env.TELEGRAM_API_ID = "12345"; + process.env.TELEGRAM_API_HASH = "abcdef"; + process.env.TWILIO_ACCOUNT_SID = "AC123"; + process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555"; + process.env.TWILIO_AUTH_TOKEN = "token"; + + const mockSession = { + save: vi.fn(() => "existing-session"), + _sessionString: "existing-session", + }; + + await createTelegramClient(mockSession as never, false, mockRuntime); + + expect(MockTelegramClient).toHaveBeenCalledWith( + mockSession, + 12345, + "abcdef", + expect.objectContaining({ + connectionRetries: 5, + useWSS: true, + }), + ); + }); + + it("throws error when Telegram credentials not configured", async () => { + // Both credentials missing - readEnv will allow this but createTelegramClient should throw + process.env.TWILIO_ACCOUNT_SID = "AC123"; + process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555"; + process.env.TWILIO_AUTH_TOKEN = "token"; + + try { + await createTelegramClient(null, false, mockRuntime); + throw new Error("Should have thrown"); + } catch (err) { + expect((err as Error).message).toContain( + "Telegram API credentials not configured", + ); + } + }); + + it("includes helpful URL in error message", async () => { + process.env.TWILIO_ACCOUNT_SID = "AC123"; + process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555"; + process.env.TWILIO_AUTH_TOKEN = "token"; + + try { + await createTelegramClient(null, false, mockRuntime); + throw new Error("Should have thrown"); + } catch (err) { + expect((err as Error).message).toContain( + "https://my.telegram.org/apps", + ); + } + }); + }); + + describe("isClientConnected", () => { + it("returns connection status", () => { + const connectedClient = { connected: true }; + const disconnectedClient = { connected: false }; + + expect(isClientConnected(connectedClient as never)).toBe(true); + expect(isClientConnected(disconnectedClient as never)).toBe(false); + }); + }); +}); diff --git a/src/telegram/client.ts b/src/telegram/client.ts new file mode 100644 index 000000000..48f58a53d --- /dev/null +++ b/src/telegram/client.ts @@ -0,0 +1,48 @@ +import { TelegramClient } from "telegram"; +import { StringSession } from "telegram/sessions/index.js"; +import { readEnv } from "../env.js"; +import { getChildLogger } from "../logging.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; + +/** + * Create a Telegram client with the given session. + * If session is null, creates a new unauthenticated session. + */ +export async function createTelegramClient( + session: StringSession | null, + verbose: boolean, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + const env = readEnv(runtime); + + if (!env.telegram?.apiId || !env.telegram?.apiHash) { + throw new Error( + "Telegram API credentials not configured. Set TELEGRAM_API_ID and TELEGRAM_API_HASH environment variables. " + + "Get credentials from https://my.telegram.org/apps", + ); + } + + const _logger = getChildLogger( + { module: "telegram-client" }, + { level: verbose ? "info" : "silent" }, + ); + + const client = new TelegramClient( + session || new StringSession(""), + Number.parseInt(env.telegram.apiId, 10), + env.telegram.apiHash, + { + connectionRetries: 5, + useWSS: true, // Use WebSocket for better reliability + }, + ); + + return client; +} + +/** + * Check if the given client is connected. + */ +export function isClientConnected(client: TelegramClient): boolean { + return client.connected ?? false; +} diff --git a/src/telegram/download.test.ts b/src/telegram/download.test.ts new file mode 100644 index 000000000..b255357c5 --- /dev/null +++ b/src/telegram/download.test.ts @@ -0,0 +1,335 @@ +/** + * Download Tests + */ + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + cleanOrphanedTempFiles, + ensureTempDir, + getTelegramTempDir, + streamDownloadToTemp, +} from "./download.js"; + +// Mock global fetch +global.fetch = vi.fn(); + +describe("download", () => { + const testTempDir = path.join( + os.tmpdir(), + `warelay-test-${process.pid}-download`, + ); + + beforeEach(async () => { + // Clean test directory before each test + await fs.rm(testTempDir, { recursive: true, force: true }).catch(() => {}); + vi.clearAllMocks(); + }); + + describe("getTelegramTempDir", () => { + it("returns correct path", () => { + const dir = getTelegramTempDir(); + // Should contain either .clawdis or .warelay (depending on which exists) + const hasCorrectDir = + dir.includes(".clawdis") || dir.includes(".warelay"); + expect(hasCorrectDir).toBe(true); + expect(dir).toContain("telegram-temp"); + expect(path.isAbsolute(dir)).toBe(true); + }); + }); + + describe("ensureTempDir", () => { + it("creates directory if not exists", async () => { + // Use the real temp dir for this test + const dir = getTelegramTempDir(); + + // Clean it first + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + + // Verify it doesn't exist + const existsBefore = await fs + .access(dir) + .then(() => true) + .catch(() => false); + expect(existsBefore).toBe(false); + + // Create it + await ensureTempDir(); + + // Verify it exists + const existsAfter = await fs + .access(dir) + .then(() => true) + .catch(() => false); + expect(existsAfter).toBe(true); + }); + + it("succeeds if already exists", async () => { + const dir = getTelegramTempDir(); + + // Create it twice + await ensureTempDir(); + await ensureTempDir(); + + // Should not throw + const exists = await fs + .access(dir) + .then(() => true) + .catch(() => false); + expect(exists).toBe(true); + }); + }); + + describe("streamDownloadToTemp", () => { + it("downloads small file to temp directory", async () => { + const testData = Buffer.from("test file content"); + + // Mock fetch response with readable stream + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + headers: { + get: (name: string) => + name === "content-type" ? "text/plain" : null, + }, + body: Readable.from([testData]), + } as any); + + const result = await streamDownloadToTemp( + "https://example.com/test.txt", + 1024 * 1024, // 1MB max + ); + + try { + // Verify file exists and has correct content + const content = await fs.readFile(result.tempPath); + expect(content.equals(testData)).toBe(true); + expect(result.size).toBe(testData.length); + expect(result.contentType).toBe("text/plain"); + + // Verify path is in temp directory + expect(result.tempPath).toContain("telegram-temp"); + expect(result.tempPath).toMatch(/telegram-dl-.*\.tmp$/); + } finally { + await result.cleanup(); + } + }); + + it("throws on HTTP error (ok: false)", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + statusText: "Not Found", + } as any); + + await expect( + streamDownloadToTemp("https://example.com/missing.txt", 1024), + ).rejects.toThrow(/Failed to download media.*Not Found/); + }); + + it("throws when size exceeds maxSize", async () => { + // Create data larger than maxSize + const largeData = Buffer.alloc(1024 * 10); // 10KB + + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + headers: { + get: () => null, + }, + body: Readable.from([largeData]), + } as any); + + await expect( + streamDownloadToTemp( + "https://example.com/large.bin", + 1024, // Only allow 1KB + ), + ).rejects.toThrow(/Download size.*exceeds maximum/); + }); + + it("cleans up temp file on error", async () => { + const largeData = Buffer.alloc(1024 * 10); + + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + headers: { + get: () => null, + }, + body: Readable.from([largeData]), + } as any); + + let _tempPath: string | undefined; + try { + await streamDownloadToTemp("https://example.com/large.bin", 1024); + } catch { + // Expected to fail + } + + // Give cleanup a moment to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify no temp files left + const tempDir = getTelegramTempDir(); + const files = await fs.readdir(tempDir).catch(() => []); + const tempFiles = files.filter((f) => f.startsWith("telegram-dl-")); + expect(tempFiles.length).toBe(0); + }); + + it("cleanup() removes temp file", async () => { + const testData = Buffer.from("cleanup test"); + + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + headers: { + get: () => null, + }, + body: Readable.from([testData]), + } as any); + + const result = await streamDownloadToTemp( + "https://example.com/test.txt", + 1024, + ); + + // Verify file exists + const existsBefore = await fs + .access(result.tempPath) + .then(() => true) + .catch(() => false); + expect(existsBefore).toBe(true); + + // Cleanup + await result.cleanup(); + + // Verify file removed + const existsAfter = await fs + .access(result.tempPath) + .then(() => true) + .catch(() => false); + expect(existsAfter).toBe(false); + }); + + it("handles missing response body (body: null)", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + body: null, + } as any); + + await expect( + streamDownloadToTemp("https://example.com/empty.txt", 1024), + ).rejects.toThrow(/No response body/); + }); + }); + + describe("cleanOrphanedTempFiles", () => { + it("removes old files (>TTL)", async () => { + const tempDir = getTelegramTempDir(); + await ensureTempDir(); + + // Create a temp file + const oldFile = path.join(tempDir, "telegram-dl-old.tmp"); + await fs.writeFile(oldFile, "old content"); + + // Mock stat to return old mtime + const statSpy = vi.spyOn(fs, "stat"); + const oldDate = Date.now() - 2 * 60 * 60 * 1000; // 2 hours ago + statSpy.mockResolvedValue({ + mtimeMs: oldDate, + } as any); + + // Clean with 1 hour TTL + await cleanOrphanedTempFiles(60 * 60 * 1000); + + // File should be removed + const exists = await fs + .access(oldFile) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + + statSpy.mockRestore(); + }); + + it("preserves recent files", async () => { + const tempDir = getTelegramTempDir(); + await ensureTempDir(); + + // Create a recent file + const recentFile = path.join(tempDir, "telegram-dl-recent.tmp"); + await fs.writeFile(recentFile, "recent content"); + + // Mock stat to return recent mtime + const statSpy = vi.spyOn(fs, "stat"); + const recentDate = Date.now() - 10 * 60 * 1000; // 10 minutes ago + statSpy.mockResolvedValue({ + mtimeMs: recentDate, + } as any); + + // Clean with 1 hour TTL + await cleanOrphanedTempFiles(60 * 60 * 1000); + + // File should still exist + const exists = await fs + .access(recentFile) + .then(() => true) + .catch(() => false); + expect(exists).toBe(true); + + // Clean up + await fs.rm(recentFile, { force: true }); + statSpy.mockRestore(); + }); + + it("handles missing temp directory", async () => { + const tempDir = getTelegramTempDir(); + + // Delete temp directory + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + + // Should not throw + await expect(cleanOrphanedTempFiles()).resolves.not.toThrow(); + }); + + it("continues on individual file errors", async () => { + const tempDir = getTelegramTempDir(); + await ensureTempDir(); + + // Create multiple files + const file1 = path.join(tempDir, "telegram-dl-1.tmp"); + const file2 = path.join(tempDir, "telegram-dl-2.tmp"); + await fs.writeFile(file1, "content 1"); + await fs.writeFile(file2, "content 2"); + + // Mock stat to return old dates + const statSpy = vi.spyOn(fs, "stat"); + const oldDate = Date.now() - 2 * 60 * 60 * 1000; + statSpy.mockResolvedValue({ + mtimeMs: oldDate, + } as any); + + // Mock rm to fail on first file + const rmSpy = vi.spyOn(fs, "rm"); + let callCount = 0; + rmSpy.mockImplementation((async (filePath: string, options?: any) => { + callCount++; + if (callCount === 1) { + throw new Error("Permission denied"); + } + // Use actual implementation for other calls + return rmSpy.getMockImplementation + ? Promise.resolve() + : fs.rm(filePath, options); + }) as any); + + // Should not throw, continues processing + await expect(cleanOrphanedTempFiles()).resolves.not.toThrow(); + + // Clean up + await fs.rm(file1, { force: true }).catch(() => {}); + await fs.rm(file2, { force: true }).catch(() => {}); + statSpy.mockRestore(); + rmSpy.mockRestore(); + }); + }); +}); diff --git a/src/telegram/download.ts b/src/telegram/download.ts new file mode 100644 index 000000000..20a08fea4 --- /dev/null +++ b/src/telegram/download.ts @@ -0,0 +1,174 @@ +import crypto from "node:crypto"; +import { createWriteStream } from "node:fs"; +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +// Prefer ~/.clawdis/telegram-temp, but fall back to ~/.warelay for compatibility +const TEMP_DIR_CLAWDIS = path.join(os.homedir(), ".clawdis", "telegram-temp"); +const TEMP_DIR_LEGACY = path.join(os.homedir(), ".warelay", "telegram-temp"); + +function resolveTempDir(): string { + // Use CLAWDIS path if the main config directory exists, otherwise legacy + const clawdisConfigExists = fsSync.existsSync( + path.join(os.homedir(), ".clawdis"), + ); + return clawdisConfigExists ? TEMP_DIR_CLAWDIS : TEMP_DIR_LEGACY; +} + +const TEMP_DIR = resolveTempDir(); +const DEFAULT_ORPHAN_TTL_MS = 60 * 60 * 1000; // 1 hour + +/** + * Result of a streaming download operation. + * Caller must clean up tempPath after use. + */ +export interface DownloadResult { + /** Absolute path to downloaded temp file */ + tempPath: string; + + /** Total bytes downloaded */ + size: number; + + /** Content-Type header from response (if available) */ + contentType?: string; + + /** Cleanup function - MUST be called to remove temp file */ + cleanup: () => Promise; +} + +/** + * Get temp directory for Telegram downloads. + * Uses ~/.warelay/telegram-temp for consistency with media store. + */ +export function getTelegramTempDir(): string { + return TEMP_DIR; +} + +/** + * Ensure temp directory exists. + */ +export async function ensureTempDir(): Promise { + await fs.mkdir(TEMP_DIR, { recursive: true }); + return TEMP_DIR; +} + +/** + * Download URL to temporary file using Node.js streams. + * Eliminates memory buffering for large files. + * + * IMPORTANT: Caller MUST call result.cleanup() after use. + * Use try-finally pattern to ensure cleanup on errors. + * + * @param url - URL to download + * @param maxSize - Maximum size in bytes (throws if exceeded) + * @returns DownloadResult with tempPath and cleanup function + * @throws Error if download fails, size exceeds maxSize, or disk write fails + * + * @example + * const download = await streamDownloadToTemp(url, maxSize); + * try { + * await client.sendFile(entity, { file: download.tempPath }); + * } finally { + * await download.cleanup(); + * } + */ +export async function streamDownloadToTemp( + url: string, + maxSize: number, +): Promise { + await ensureTempDir(); + + // Generate unique temp file name + const filename = `telegram-dl-${crypto.randomUUID()}.tmp`; + const tempPath = path.join(TEMP_DIR, filename); + + // Fetch response + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to download media from ${url}: ${response.statusText}`, + ); + } + + if (!response.body) { + throw new Error(`No response body for ${url}`); + } + + // Track size during download + let totalSize = 0; + const writeStream = createWriteStream(tempPath); + + try { + // Use pipeline for automatic backpressure handling + // Transform stream to track size and enforce limit + const trackingStream = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + totalSize += chunk.length; + if (totalSize > maxSize) { + callback( + new Error( + `Download size ${totalSize} exceeds maximum ${maxSize} bytes`, + ), + ); + return; + } + callback(null, chunk); + }, + }); + + // Pipeline: fetch response -> size tracker -> file write + await pipeline(response.body, trackingStream, writeStream); + + const contentType = response.headers.get("content-type") || undefined; + + return { + tempPath, + size: totalSize, + contentType, + cleanup: async () => { + await fs.rm(tempPath, { force: true }).catch(() => { + // Suppress cleanup errors (file may already be deleted) + }); + }, + }; + } catch (error) { + // Clean up temp file on failure + await fs.rm(tempPath, { force: true }).catch(() => {}); + throw error; + } +} + +/** + * Clean up orphaned temp files older than TTL. + * Run on process start to handle crash recovery. + * + * @param ttlMs - Time-to-live in milliseconds (default: 1 hour) + */ +export async function cleanOrphanedTempFiles( + ttlMs = DEFAULT_ORPHAN_TTL_MS, +): Promise { + try { + await ensureTempDir(); + const entries = await fs.readdir(TEMP_DIR).catch(() => []); + const now = Date.now(); + + await Promise.all( + entries.map(async (file) => { + const fullPath = path.join(TEMP_DIR, file); + const stat = await fs.stat(fullPath).catch(() => null); + if (!stat) return; + + if (now - stat.mtimeMs > ttlMs) { + await fs.rm(fullPath, { force: true }).catch(() => {}); + } + }), + ); + } catch (error) { + // Non-fatal: log but don't throw + console.warn("Failed to clean orphaned temp files:", error); + } +} diff --git a/src/telegram/inbound.test.ts b/src/telegram/inbound.test.ts new file mode 100644 index 000000000..7bd03754f --- /dev/null +++ b/src/telegram/inbound.test.ts @@ -0,0 +1,876 @@ +/** + * Telegram Inbound Message Tests + */ + +import type { TelegramClient } from "telegram"; +import type { NewMessageEvent } from "telegram/events"; +import { Api } from "telegram/tl"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + MessageHandler, + ProviderMessage, +} from "../providers/base/types.js"; +import { + convertTelegramMessage, + isAllowedSender, + startMessageListener, +} from "./inbound.js"; + +// Mock telegram events +vi.mock("telegram/events", () => ({ + NewMessage: class NewMessage {}, +})); + +describe("convertTelegramMessage", () => { + let mockClient: Partial; + let mockEvent: Partial; + + beforeEach(() => { + mockClient = { + downloadMedia: vi.fn(), + }; + }); + + it("converts text message successfully", async () => { + const mockSender = { + username: "testuser", + firstName: "Test", + lastName: "User", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Hello, world!", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: null, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + + expect(result).toEqual({ + id: "999", + from: "@testuser", + to: "me", + body: "Hello, world!", + timestamp: 1234567890000, + displayName: "Test User", + media: undefined, + raw: mockMessage, + provider: "telegram", + }); + }); + + it("returns null for outgoing messages", async () => { + const mockMessage = { + id: 999, + message: "Hello!", + out: true, + }; + + mockEvent = { + message: mockMessage as any, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + expect(result).toBeNull(); + }); + + it("returns null when sender is not available", async () => { + const mockMessage = { + id: 999, + message: "Hello!", + out: false, + getSender: vi.fn().mockResolvedValue(null), + }; + + mockEvent = { + message: mockMessage as any, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + expect(result).toBeNull(); + }); + + it("uses phone number when username not available", async () => { + const mockSender = { + phone: "+1234567890", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Hello!", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: null, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + expect(result?.from).toBe("+1234567890"); + }); + + it("uses ID when neither username nor phone available", async () => { + const mockSender = { + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Hello!", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: null, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + expect(result?.from).toBe("12345"); + }); + + it("uses Unknown when no identifiable info available", async () => { + const mockSender = {}; + + const mockMessage = { + id: 999, + message: "Hello!", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: null, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + expect(result?.from).toBe("unknown"); + expect(result?.displayName).toBe("Unknown"); + }); + + it("uses title for chat display name", async () => { + const mockSender = { + title: "Test Group", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Hello!", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: null, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + expect(result?.displayName).toBe("Test Group"); + }); + + it("extracts photo media", async () => { + const mockBuffer = Buffer.from("fake-image-data"); + mockClient.downloadMedia = vi.fn().mockResolvedValue(mockBuffer); + + const mockPhoto = new Api.MessageMediaPhoto({ + photo: new Api.Photo({ + id: BigInt(123), + accessHash: BigInt(456), + fileReference: Buffer.from([]), + date: 1234567890, + sizes: [], + dcId: 1, + }), + }); + + const mockSender = { + username: "testuser", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Check this out", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: mockPhoto, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + + expect(result?.media).toHaveLength(1); + expect(result?.media?.[0]).toEqual({ + type: "image", + buffer: mockBuffer, + mimeType: "image/jpeg", + }); + }); + + it("extracts document media with voice attribute", async () => { + const mockBuffer = Buffer.from("fake-audio-data"); + mockClient.downloadMedia = vi.fn().mockResolvedValue(mockBuffer); + + // Create mock document with voice attribute + const mockDocument = { + className: "MessageMediaDocument", + document: { + id: BigInt(123), + mimeType: "audio/ogg", + size: BigInt(1024), + attributes: [ + { + className: "DocumentAttributeVoice", + }, + ], + }, + } as any; + + const mockSender = { + username: "testuser", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: mockDocument, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + + expect(result?.media).toHaveLength(1); + expect(result?.media?.[0]?.type).toBe("voice"); + expect(result?.media?.[0]?.mimeType).toBe("audio/ogg"); + }); + + it("extracts document media with video attribute", async () => { + const mockBuffer = Buffer.from("fake-video-data"); + mockClient.downloadMedia = vi.fn().mockResolvedValue(mockBuffer); + + const mockDocument = { + className: "MessageMediaDocument", + document: { + id: BigInt(123), + mimeType: "video/mp4", + size: BigInt(2048), + attributes: [ + { + className: "DocumentAttributeVideo", + duration: 10, + w: 1920, + h: 1080, + }, + ], + }, + } as any; + + const mockSender = { + username: "testuser", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: mockDocument, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + + expect(result?.media).toHaveLength(1); + expect(result?.media?.[0]?.type).toBe("video"); + expect(result?.media?.[0]?.mimeType).toBe("video/mp4"); + }); + + it("extracts document media with audio attribute", async () => { + const mockBuffer = Buffer.from("fake-audio-data"); + mockClient.downloadMedia = vi.fn().mockResolvedValue(mockBuffer); + + const mockDocument = { + className: "MessageMediaDocument", + document: { + id: BigInt(123), + mimeType: "audio/mp3", + size: BigInt(1024), + attributes: [ + { + className: "DocumentAttributeAudio", + duration: 180, + }, + ], + }, + } as any; + + const mockSender = { + username: "testuser", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: mockDocument, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + + expect(result?.media).toHaveLength(1); + expect(result?.media?.[0]?.type).toBe("audio"); + expect(result?.media?.[0]?.mimeType).toBe("audio/mp3"); + }); + + it("extracts document media with filename", async () => { + const mockBuffer = Buffer.from("fake-doc-data"); + mockClient.downloadMedia = vi.fn().mockResolvedValue(mockBuffer); + + const mockDocument = { + className: "MessageMediaDocument", + document: { + id: BigInt(123), + mimeType: "application/pdf", + size: BigInt(4096), + attributes: [ + { + className: "DocumentAttributeFilename", + fileName: "test.pdf", + }, + ], + }, + } as any; + + const mockSender = { + username: "testuser", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Here's a document", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: mockDocument, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + + expect(result?.media).toHaveLength(1); + expect(result?.media?.[0]?.type).toBe("document"); + expect(result?.media?.[0]?.fileName).toBe("test.pdf"); + expect(result?.media?.[0]?.size).toBe(4096); + }); + + it("handles media download failure gracefully", async () => { + mockClient.downloadMedia = vi + .fn() + .mockRejectedValue(new Error("Download failed")); + + const mockPhoto = new Api.MessageMediaPhoto({ + photo: new Api.Photo({ + id: BigInt(123), + accessHash: BigInt(456), + fileReference: Buffer.from([]), + date: 1234567890, + sizes: [], + dcId: 1, + }), + }); + + const mockSender = { + username: "testuser", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Check this out", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: mockPhoto, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + // Should not throw, just omit media + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + expect(result?.media).toBeUndefined(); + }); + + it("uses current timestamp when message date is missing", async () => { + const mockSender = { + username: "testuser", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Hello!", + date: 0, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: null, + }; + + mockEvent = { + message: mockMessage as any, + client: mockClient as TelegramClient, + }; + + const beforeTime = Date.now(); + const result = await convertTelegramMessage(mockEvent as NewMessageEvent); + const afterTime = Date.now(); + + expect(result?.timestamp).toBeGreaterThanOrEqual(beforeTime); + expect(result?.timestamp).toBeLessThanOrEqual(afterTime); + }); +}); + +describe("isAllowedSender", () => { + it("allows all senders when no whitelist provided", () => { + const message: ProviderMessage = { + id: "1", + from: "@testuser", + to: "me", + body: "Hello", + timestamp: Date.now(), + provider: "telegram", + }; + + expect(isAllowedSender(message, undefined)).toBe(true); + expect(isAllowedSender(message, [])).toBe(true); + }); + + it("allows sender with matching username", () => { + const message: ProviderMessage = { + id: "1", + from: "@testuser", + to: "me", + body: "Hello", + timestamp: Date.now(), + provider: "telegram", + }; + + expect(isAllowedSender(message, ["@testuser"])).toBe(true); + expect(isAllowedSender(message, ["testuser"])).toBe(true); // Without @ + }); + + it("allows sender with matching phone", () => { + const message: ProviderMessage = { + id: "1", + from: "+1234567890", + to: "me", + body: "Hello", + timestamp: Date.now(), + provider: "telegram", + }; + + expect(isAllowedSender(message, ["+1234567890"])).toBe(true); + }); + + it("rejects sender not in whitelist", () => { + const message: ProviderMessage = { + id: "1", + from: "@unauthorized", + to: "me", + body: "Hello", + timestamp: Date.now(), + provider: "telegram", + }; + + expect(isAllowedSender(message, ["@testuser", "+1234567890"])).toBe(false); + }); + + it("is case insensitive", () => { + const message: ProviderMessage = { + id: "1", + from: "@TestUser", + to: "me", + body: "Hello", + timestamp: Date.now(), + provider: "telegram", + }; + + expect(isAllowedSender(message, ["@testuser"])).toBe(true); + expect(isAllowedSender(message, ["TESTUSER"])).toBe(true); + }); + + it("trims whitespace from whitelist entries", () => { + const message: ProviderMessage = { + id: "1", + from: "@testuser", + to: "me", + body: "Hello", + timestamp: Date.now(), + provider: "telegram", + }; + + expect(isAllowedSender(message, [" @testuser "])).toBe(true); + }); + + it("allows sender in multi-entry whitelist", () => { + const message: ProviderMessage = { + id: "1", + from: "@user2", + to: "me", + body: "Hello", + timestamp: Date.now(), + provider: "telegram", + }; + + expect(isAllowedSender(message, ["@user1", "@user2", "+1234567890"])).toBe( + true, + ); + }); +}); + +describe("startMessageListener", () => { + let mockClient: Partial; + let mockHandler: MessageHandler; + + beforeEach(() => { + mockClient = { + addEventHandler: vi.fn(), + removeEventHandler: vi.fn(), + downloadMedia: vi.fn(), + }; + mockHandler = vi.fn().mockResolvedValue(undefined); + }); + + it("registers event handler on client", async () => { + await startMessageListener(mockClient as TelegramClient, mockHandler); + + expect(mockClient.addEventHandler).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Object), + ); + }); + + it("returns cleanup function that removes event handler", async () => { + const cleanup = await startMessageListener( + mockClient as TelegramClient, + mockHandler, + ); + + expect(typeof cleanup).toBe("function"); + + cleanup(); + + expect(mockClient.removeEventHandler).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Object), + ); + }); + + it("calls handler for valid incoming messages", async () => { + let capturedHandler: ((event: NewMessageEvent) => Promise) | null = + null; + + mockClient.addEventHandler = vi.fn().mockImplementation((handler) => { + capturedHandler = handler; + }); + + await startMessageListener(mockClient as TelegramClient, mockHandler); + + expect(capturedHandler).not.toBeNull(); + + // Simulate incoming message + const mockSender = { + username: "testuser", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Hello!", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: null, + }; + + const mockEvent = { + message: mockMessage, + client: mockClient, + } as any; + + await capturedHandler?.(mockEvent); + + expect(mockHandler).toHaveBeenCalledWith( + expect.objectContaining({ + id: "999", + from: "@testuser", + body: "Hello!", + }), + ); + }); + + it("does not call handler for outgoing messages", async () => { + let capturedHandler: ((event: NewMessageEvent) => Promise) | null = + null; + + mockClient.addEventHandler = vi.fn().mockImplementation((handler) => { + capturedHandler = handler; + }); + + await startMessageListener(mockClient as TelegramClient, mockHandler); + + // Simulate outgoing message + const mockMessage = { + id: 999, + message: "Hello!", + out: true, + }; + + const mockEvent = { + message: mockMessage, + client: mockClient, + } as any; + + await capturedHandler?.(mockEvent); + + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it("filters messages by allowFrom whitelist", async () => { + let capturedHandler: ((event: NewMessageEvent) => Promise) | null = + null; + + mockClient.addEventHandler = vi.fn().mockImplementation((handler) => { + capturedHandler = handler; + }); + + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await startMessageListener(mockClient as TelegramClient, mockHandler, [ + "@allowed", + ]); + + // Simulate message from non-whitelisted user + const mockSender = { + username: "unauthorized", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Hello!", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: null, + }; + + const mockEvent = { + message: mockMessage, + client: mockClient, + } as any; + + await capturedHandler?.(mockEvent); + + expect(mockHandler).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith( + "Ignored message from @unauthorized (not in allowFrom list)", + ); + + consoleLogSpy.mockRestore(); + }); + + it("allows messages from whitelisted users", async () => { + let capturedHandler: ((event: NewMessageEvent) => Promise) | null = + null; + + mockClient.addEventHandler = vi.fn().mockImplementation((handler) => { + capturedHandler = handler; + }); + + await startMessageListener(mockClient as TelegramClient, mockHandler, [ + "@testuser", + ]); + + // Simulate message from whitelisted user + const mockSender = { + username: "testuser", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Hello!", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: null, + }; + + const mockEvent = { + message: mockMessage, + client: mockClient, + } as any; + + await capturedHandler?.(mockEvent); + + expect(mockHandler).toHaveBeenCalled(); + }); + + it("handles errors in message handler gracefully", async () => { + let capturedHandler: ((event: NewMessageEvent) => Promise) | null = + null; + + mockClient.addEventHandler = vi.fn().mockImplementation((handler) => { + capturedHandler = handler; + }); + + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const errorHandler = vi.fn().mockRejectedValue(new Error("Handler failed")); + + await startMessageListener(mockClient as TelegramClient, errorHandler); + + // Simulate incoming message + const mockSender = { + username: "testuser", + firstName: "Test", + id: BigInt(12345), + }; + + const mockMessage = { + id: 999, + message: "Hello!", + date: 1234567890, + out: false, + getSender: vi.fn().mockResolvedValue(mockSender), + media: null, + }; + + const mockEvent = { + message: mockMessage, + client: mockClient, + } as any; + + // Should not throw + await expect(capturedHandler?.(mockEvent)).resolves.not.toThrow(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error handling Telegram message"), + ); + + consoleErrorSpy.mockRestore(); + }); + + it("uses same NewMessage instance for add and remove", async () => { + let addedFilter: any = null; + let removedFilter: any = null; + + mockClient.addEventHandler = vi + .fn() + .mockImplementation((_handler, filter) => { + addedFilter = filter; + }); + + mockClient.removeEventHandler = vi + .fn() + .mockImplementation((_handler, filter) => { + removedFilter = filter; + }); + + const cleanup = await startMessageListener( + mockClient as TelegramClient, + mockHandler, + ); + + cleanup(); + + // Verify same instance used for both add and remove (strict equality) + expect(addedFilter).toBe(removedFilter); + expect(addedFilter).not.toBeNull(); + }); + + it("cleanup can be called multiple times safely", async () => { + const cleanup = await startMessageListener( + mockClient as TelegramClient, + mockHandler, + ); + + cleanup(); + cleanup(); // Should not throw + + expect(mockClient.removeEventHandler).toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/inbound.ts b/src/telegram/inbound.ts new file mode 100644 index 000000000..a9d440ff1 --- /dev/null +++ b/src/telegram/inbound.ts @@ -0,0 +1,250 @@ +import type { TelegramClient } from "telegram"; +import { Api } from "telegram"; +import type { NewMessageEvent } from "telegram/events/NewMessage.js"; +import { NewMessage } from "telegram/events/NewMessage.js"; +import type { + MessageHandler, + ProviderMedia, + ProviderMessage, +} from "../providers/base/types.js"; +import { normalizeAllowFromEntry } from "../utils.js"; + +/** + * Convert Telegram message to ProviderMessage format. + */ +export async function convertTelegramMessage( + event: NewMessageEvent, +): Promise { + const msg = event.message; + + // Only process incoming messages (not outgoing) + if (msg.out) { + return null; + } + + // Extract sender info + const sender = await event.message.getSender(); + if (!sender) { + return null; + } + + const from = extractSenderIdentifier(sender as any); + const displayName = extractDisplayName(sender as any); + + // Extract message body + const body = msg.message || ""; + + // Extract media if present + const media: ProviderMedia[] = []; + if (msg.media && event.client) { + const mediaItem = await extractMedia(msg.media, event.client); + if (mediaItem) { + media.push(mediaItem); + } + } + + return { + id: msg.id.toString(), + from, + to: "me", // Always "me" for personal account + body, + timestamp: msg.date ? msg.date * 1000 : Date.now(), + displayName, + media: media.length > 0 ? media : undefined, + raw: msg, + provider: "telegram", + }; +} + +/** + * Extract sender identifier (@username or phone). + */ +function extractSenderIdentifier(sender: Api.User | Api.Chat): string { + if ("username" in sender && sender.username) { + return `@${sender.username}`; + } + if ("phone" in sender && sender.phone) { + return sender.phone; + } + if ("id" in sender && sender.id) { + return sender.id.toString(); + } + return "unknown"; +} + +/** + * Extract display name from sender. + */ +function extractDisplayName(sender: Api.User | Api.Chat): string { + if ("firstName" in sender && sender.firstName) { + const lastName = + "lastName" in sender && sender.lastName ? ` ${sender.lastName}` : ""; + return `${sender.firstName}${lastName}`; + } + if ("title" in sender && sender.title) { + return sender.title; + } + return "Unknown"; +} + +/** + * Extract media from Telegram message. + */ +async function extractMedia( + media: Api.TypeMessageMedia, + client: TelegramClient, +): Promise { + try { + // Check for photo media (by instanceof or className for test compatibility) + const isPhoto = + media instanceof Api.MessageMediaPhoto || + (media as any).className === "MessageMediaPhoto"; + if (isPhoto && (media as any).photo) { + const buffer = await client.downloadMedia(media, { + outputFile: undefined, + }); + if (buffer instanceof Buffer) { + return { + type: "image", + buffer, + mimeType: "image/jpeg", + }; + } + } + + // Check for document media (by instanceof or className for test compatibility) + const isDocument = + media instanceof Api.MessageMediaDocument || + (media as any).className === "MessageMediaDocument"; + if (isDocument && (media as any).document) { + const doc = (media as any).document as Api.Document; + const buffer = await client.downloadMedia(media, { + outputFile: undefined, + }); + + if (buffer instanceof Buffer) { + // Detect media type from attributes + const attrs = doc.attributes || []; + + // Helper to check attribute type safely (handles test env where Api classes may not exist) + const isAttrType = (a: any, className: string) => { + try { + switch (className) { + case "DocumentAttributeVideo": + return ( + a instanceof Api.DocumentAttributeVideo || + a.className === className + ); + case "DocumentAttributeAudio": + return ( + a instanceof Api.DocumentAttributeAudio || + a.className === className + ); + case "DocumentAttributeFilename": + return ( + a instanceof Api.DocumentAttributeFilename || + a.className === className + ); + default: + return a.className === className; + } + } catch { + // If instanceof fails (test env), fallback to className + return a.className === className; + } + }; + + const isVideo = attrs.some((a: any) => + isAttrType(a, "DocumentAttributeVideo"), + ); + const isAudio = attrs.some((a: any) => + isAttrType(a, "DocumentAttributeAudio"), + ); + + // Check if it's a voice message by mime type (e.g., audio/ogg with opus codec) + const isVoice = + doc.mimeType === "audio/ogg" || doc.mimeType === "audio/opus"; + + let type: ProviderMedia["type"] = "document"; + if (isVoice) type = "voice"; + else if (isVideo) type = "video"; + else if (isAudio) type = "audio"; + else if (doc.mimeType?.startsWith("image/")) type = "image"; + + const fileName = attrs + .filter((a: any) => isAttrType(a, "DocumentAttributeFilename")) + .map((a: any) => a.fileName)[0]; + + return { + type, + buffer, + mimeType: doc.mimeType || "application/octet-stream", + fileName, + size: Number(doc.size), + }; + } + } + } catch (err) { + console.warn(`Failed to download media: ${String(err)}`); + } + + return null; +} + +/** + * Check if message sender is in allowFrom whitelist. + */ +export function isAllowedSender( + message: ProviderMessage, + allowFrom?: string[], +): boolean { + if (!allowFrom || allowFrom.length === 0) { + return true; // No whitelist = allow all + } + + const normalizedFrom = normalizeAllowFromEntry(message.from, "telegram"); + const normalizedAllowList = allowFrom.map((e) => + normalizeAllowFromEntry(e, "telegram"), + ); + return normalizedAllowList.includes(normalizedFrom); +} + +/** + * Start listening for inbound messages with allowFrom filtering. + */ +export async function startMessageListener( + client: TelegramClient, + handler: MessageHandler, + allowFrom?: string[], +): Promise<() => void> { + const eventHandler = async (event: NewMessageEvent) => { + try { + const message = await convertTelegramMessage(event); + if (!message) { + return; // Outgoing or invalid message + } + + // Check allowFrom whitelist + if (!isAllowedSender(message, allowFrom)) { + console.log( + `Ignored message from ${message.from} (not in allowFrom list)`, + ); + return; + } + + // Call handler + await handler(message); + } catch (err) { + console.error(`Error handling Telegram message: ${String(err)}`); + } + }; + + // Create event filter instance and reuse for both add and remove + const eventFilter = new NewMessage({}); + client.addEventHandler(eventHandler, eventFilter); + + // Return cleanup function + return () => { + client.removeEventHandler(eventHandler, eventFilter); + }; +} diff --git a/src/telegram/index.ts b/src/telegram/index.ts new file mode 100644 index 000000000..5d2384608 --- /dev/null +++ b/src/telegram/index.ts @@ -0,0 +1,16 @@ +/** + * Telegram Provider Module + * + * Exports all Telegram-related functionality for warelay. + */ + +export * from "./capabilities.js"; +export * from "./client.js"; +export * from "./inbound.js"; +export * from "./login.js"; +export * from "./monitor.js"; +export * from "./outbound.js"; +export * from "./prompts.js"; +export * from "./provider.js"; +export * from "./session.js"; +export * from "./utils.js"; diff --git a/src/telegram/login.test.ts b/src/telegram/login.test.ts new file mode 100644 index 000000000..04075467b --- /dev/null +++ b/src/telegram/login.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; + +// Mock all dependencies before imports +const mockStart = vi.fn(); +const mockGetMe = vi.fn(); +const mockDisconnect = vi.fn(); +const mockConnect = vi.fn(); +const mockInvoke = vi.fn(); + +vi.mock("telegram", () => ({ + Api: { + auth: { + LogOut: class {}, + }, + }, +})); + +vi.mock("./client.js", () => ({ + createTelegramClient: vi.fn().mockResolvedValue({ + start: mockStart, + getMe: mockGetMe, + disconnect: mockDisconnect, + connect: mockConnect, + invoke: mockInvoke, + connected: true, + session: { + save: vi.fn(() => "mock-saved-session"), + }, + }), +})); + +vi.mock("./session.js", () => ({ + loadSession: vi.fn(), + saveSession: vi.fn(), + clearSession: vi.fn(), +})); + +vi.mock("./prompts.js", () => ({ + promptPhone: vi.fn(), + promptSMSCode: vi.fn(), + prompt2FA: vi.fn(), +})); + +const { loginTelegram, logoutTelegram } = await import("./login.js"); +const { loadSession, saveSession, clearSession } = await import("./session.js"); +const { promptPhone, promptSMSCode, prompt2FA } = await import("./prompts.js"); +const { createTelegramClient } = await import("./client.js"); + +describe("telegram login", () => { + const mockRuntime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockStart.mockClear(); + mockGetMe.mockClear(); + mockDisconnect.mockClear(); + mockConnect.mockClear(); + mockInvoke.mockClear(); + }); + + describe("loginTelegram", () => { + it("successfully logs in with phone and SMS code", async () => { + vi.mocked(loadSession).mockResolvedValue(null); + vi.mocked(promptPhone).mockResolvedValue("+1234567890"); + vi.mocked(promptSMSCode).mockResolvedValue("12345"); + vi.mocked(prompt2FA).mockResolvedValue(""); + + mockStart.mockImplementation(async (opts: unknown) => { + // Simulate successful login + const { phoneNumber, phoneCode, password } = opts as { + phoneNumber: () => Promise; + phoneCode: () => Promise; + password: () => Promise; + }; + await phoneNumber(); + await phoneCode(); + await password(); + }); + + mockGetMe.mockResolvedValue({ + firstName: "John", + username: "johndoe", + }); + + await loginTelegram(false, mockRuntime); + + expect(createTelegramClient).toHaveBeenCalledWith( + null, + false, + mockRuntime, + ); + expect(mockStart).toHaveBeenCalled(); + expect(mockGetMe).toHaveBeenCalled(); + expect(saveSession).toHaveBeenCalled(); + expect(mockRuntime.log).toHaveBeenCalledWith( + expect.stringContaining("Logged in as: John (@johndoe)"), + ); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it("logs in with 2FA password", async () => { + vi.mocked(loadSession).mockResolvedValue(null); + vi.mocked(promptPhone).mockResolvedValue("+1234567890"); + vi.mocked(promptSMSCode).mockResolvedValue("12345"); + vi.mocked(prompt2FA).mockResolvedValue("my2fapassword"); + + mockStart.mockImplementation(async (opts: unknown) => { + const { phoneNumber, phoneCode, password } = opts as { + phoneNumber: () => Promise; + phoneCode: () => Promise; + password: () => Promise; + }; + await phoneNumber(); + await phoneCode(); + const pwd = await password(); + expect(pwd).toBe("my2fapassword"); + }); + + mockGetMe.mockResolvedValue({ + firstName: "Jane", + username: null, + }); + + await loginTelegram(false, mockRuntime); + + expect(mockStart).toHaveBeenCalled(); + expect(mockGetMe).toHaveBeenCalled(); + expect(saveSession).toHaveBeenCalled(); + expect(mockRuntime.log).toHaveBeenCalledWith( + expect.stringContaining("Logged in as: Jane"), + ); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it("handles login failure and exits", async () => { + vi.mocked(loadSession).mockResolvedValue(null); + mockStart.mockRejectedValue(new Error("Invalid phone number")); + + try { + await loginTelegram(false, mockRuntime); + throw new Error("Should have thrown"); + } catch (err) { + expect((err as Error).message).toBe("exit"); + } + + expect(mockRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("Login failed: Error: Invalid phone number"), + ); + expect(mockRuntime.exit).toHaveBeenCalledWith(1); + expect(saveSession).not.toHaveBeenCalled(); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it("handles connection failure", async () => { + vi.mocked(loadSession).mockResolvedValue(null); + mockStart.mockResolvedValue(undefined); + + // Mock client with connected = false + vi.mocked(createTelegramClient).mockResolvedValue({ + start: mockStart, + getMe: mockGetMe, + disconnect: mockDisconnect, + connect: mockConnect, + invoke: mockInvoke, + connected: false, + session: { + save: vi.fn(() => "mock-saved-session"), + }, + } as never); + + try { + await loginTelegram(false, mockRuntime); + throw new Error("Should have thrown"); + } catch (err) { + expect((err as Error).message).toBe("exit"); + } + + expect(mockRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to connect to Telegram"), + ); + expect(mockRuntime.exit).toHaveBeenCalledWith(1); + expect(saveSession).not.toHaveBeenCalled(); + }); + + it("uses existing session when available", async () => { + const mockSession = { + save: vi.fn(() => "existing-session"), + }; + vi.mocked(loadSession).mockResolvedValue(mockSession as never); + + // Reset mock to return connected client + vi.mocked(createTelegramClient).mockResolvedValue({ + start: mockStart, + getMe: mockGetMe, + disconnect: mockDisconnect, + connect: mockConnect, + invoke: mockInvoke, + connected: true, + session: { + save: vi.fn(() => "mock-saved-session"), + }, + } as never); + + mockStart.mockImplementation(async (opts: unknown) => { + // Simulate successful login + const { phoneNumber, phoneCode, password } = opts as { + phoneNumber: () => Promise; + phoneCode: () => Promise; + password: () => Promise; + }; + await phoneNumber(); + await phoneCode(); + await password(); + }); + mockGetMe.mockResolvedValue({ + firstName: "Alice", + username: "alice", + }); + + await loginTelegram(false, mockRuntime); + + expect(createTelegramClient).toHaveBeenCalledWith( + mockSession, + false, + mockRuntime, + ); + expect(mockStart).toHaveBeenCalled(); + }); + }); + + describe("logoutTelegram", () => { + beforeEach(() => { + // Reset the mock to return default client + vi.mocked(createTelegramClient).mockResolvedValue({ + start: mockStart, + getMe: mockGetMe, + disconnect: mockDisconnect, + connect: mockConnect, + invoke: mockInvoke, + connected: true, + session: { + save: vi.fn(() => "mock-saved-session"), + }, + } as never); + }); + + it("successfully logs out and clears session", async () => { + const mockSession = { + save: vi.fn(() => "existing-session"), + }; + vi.mocked(loadSession).mockResolvedValue(mockSession as never); + + await logoutTelegram(false, mockRuntime); + + expect(mockConnect).toHaveBeenCalled(); + expect(mockInvoke).toHaveBeenCalled(); + expect(clearSession).toHaveBeenCalled(); + expect(mockRuntime.log).toHaveBeenCalledWith( + expect.stringContaining("Logged out from Telegram"), + ); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it("does nothing when no session exists", async () => { + vi.mocked(loadSession).mockResolvedValue(null); + + await logoutTelegram(false, mockRuntime); + + expect(mockRuntime.log).toHaveBeenCalledWith( + expect.stringContaining("No Telegram session found"), + ); + expect(mockConnect).not.toHaveBeenCalled(); + expect(mockInvoke).not.toHaveBeenCalled(); + expect(clearSession).not.toHaveBeenCalled(); + }); + + it("handles logout failure and exits", async () => { + const mockSession = { + save: vi.fn(() => "existing-session"), + }; + vi.mocked(loadSession).mockResolvedValue(mockSession as never); + mockInvoke.mockRejectedValue(new Error("Network error")); + + try { + await logoutTelegram(false, mockRuntime); + throw new Error("Should have thrown"); + } catch (err) { + expect((err as Error).message).toBe("exit"); + } + + expect(mockRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("Logout failed: Error: Network error"), + ); + expect(mockRuntime.exit).toHaveBeenCalledWith(1); + expect(clearSession).not.toHaveBeenCalled(); + expect(mockDisconnect).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/telegram/login.ts b/src/telegram/login.ts new file mode 100644 index 000000000..033a6eae0 --- /dev/null +++ b/src/telegram/login.ts @@ -0,0 +1,94 @@ +import { Api } from "telegram"; +import type { StringSession } from "telegram/sessions/index.js"; +import { danger, info, success } from "../globals.js"; +import { logInfo } from "../logger.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { createTelegramClient } from "./client.js"; +import { prompt2FA, promptPhone, promptSMSCode } from "./prompts.js"; +import { clearSession, loadSession, saveSession } from "./session.js"; + +/** + * Interactive login flow for Telegram. + * Prompts for phone, SMS code, and optionally 2FA password. + */ +export async function loginTelegram( + verbose: boolean, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + runtime.log(info("🔐 Telegram Login")); + runtime.log( + info("This will connect your personal Telegram account to warelay."), + ); + + const session = await loadSession(); + const client = await createTelegramClient(session, verbose, runtime); + + try { + await client.start({ + phoneNumber: async () => await promptPhone(runtime), + phoneCode: async () => await promptSMSCode(runtime), + password: async () => await prompt2FA(runtime), + onError: (err) => { + runtime.error(danger(`Login error: ${String(err)}`)); + }, + }); + + if (!client.connected) { + throw new Error("Failed to connect to Telegram"); + } + + // Get user info for confirmation + const me = await client.getMe(); + const username = + "username" in me && typeof me.username === "string" ? me.username : null; + const displayName = + "firstName" in me && typeof me.firstName === "string" + ? me.firstName + : "Unknown"; + + // Save session to disk + await saveSession(client.session as StringSession); + + runtime.log( + success( + `✅ Logged in as: ${displayName}${username ? ` (@${username})` : ""}`, + ), + ); + runtime.log(info("Session saved to ~/.clawdis/telegram/session/")); + + logInfo("Telegram login successful", runtime); + } catch (err) { + runtime.error(danger(`Login failed: ${String(err)}`)); + runtime.exit(1); + } finally { + await client.disconnect(); + } +} + +/** + * Logout from Telegram and clear session. + */ +export async function logoutTelegram( + verbose: boolean, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + const session = await loadSession(); + if (!session) { + runtime.log(info("No Telegram session found.")); + return; + } + + const client = await createTelegramClient(session, verbose, runtime); + + try { + await client.connect(); + await client.invoke(new Api.auth.LogOut()); + await clearSession(); + runtime.log(success("✅ Logged out from Telegram")); + } catch (err) { + runtime.error(danger(`Logout failed: ${String(err)}`)); + runtime.exit(1); + } finally { + await client.disconnect(); + } +} diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts new file mode 100644 index 000000000..52f78d0f5 --- /dev/null +++ b/src/telegram/monitor.test.ts @@ -0,0 +1,338 @@ +/** + * Tests for Telegram monitor + */ + +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; +import type { Provider } from "../providers/base/interface.js"; +import type { + MessageHandler, + ProviderMessage, +} from "../providers/base/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { monitorTelegramProvider } from "./monitor.js"; + +// Mock dependencies +vi.mock("../env.js", () => ({ + readEnv: vi.fn(() => ({ + accountSid: "test-sid", + whatsappFrom: "+1234567890", + auth: { accountSid: "test-sid", authToken: "test-token" }, + telegram: { + apiId: 12345, + apiHash: "test-hash", + }, + })), +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({ + telegram: { + allowFrom: ["@testuser"], + }, + inbound: { + reply: { + mode: "text", + text: "Auto-reply test", + }, + }, + })), +})); + +vi.mock("../auto-reply/reply.js", () => ({ + getReplyFromConfig: vi.fn(async () => ({ + text: "Test reply", + })), +})); + +vi.mock("../providers/factory.js", () => ({ + createInitializedProvider: vi.fn(), +})); + +describe("monitorTelegramProvider", () => { + let mockRuntime: RuntimeEnv; + let mockProvider: Provider; + let messageHandler: MessageHandler | null = null; + + beforeEach(async () => { + vi.clearAllMocks(); + messageHandler = null; + + mockRuntime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + env: {}, + }; + + mockProvider = { + kind: "telegram", + capabilities: { + supportsDeliveryReceipts: false, + supportsReadReceipts: false, + supportsTypingIndicator: true, + supportsReactions: false, + supportsReplies: true, + supportsEditing: true, + supportsDeleting: true, + maxMediaSize: 50 * 1024 * 1024, + supportedMediaTypes: ["image", "video", "audio", "document"], + canInitiateConversation: true, + }, + initialize: vi.fn(), + isConnected: vi.fn(() => true), + disconnect: vi.fn(), + send: vi.fn(async () => ({ + messageId: "msg-123", + status: "sent" as const, + })), + sendTyping: vi.fn(), + getDeliveryStatus: vi.fn(async () => ({ + messageId: "msg-123", + status: "unknown" as const, + timestamp: Date.now(), + })), + onMessage: vi.fn((handler: MessageHandler) => { + messageHandler = handler; + }), + startListening: vi.fn(), + stopListening: vi.fn(), + isAuthenticated: vi.fn(async () => true), + login: vi.fn(), + logout: vi.fn(), + getSessionId: vi.fn(async () => "@testuser"), + }; + + const { createInitializedProvider } = await import( + "../providers/factory.js" + ); + (createInitializedProvider as Mock).mockResolvedValue(mockProvider); + }); + + it("should throw if Telegram is not configured", async () => { + const { readEnv } = await import("../env.js"); + (readEnv as Mock).mockReturnValueOnce({ + accountSid: "test-sid", + whatsappFrom: "+1234567890", + auth: { accountSid: "test-sid", authToken: "test-token" }, + // No telegram config + }); + + await expect(monitorTelegramProvider(false, mockRuntime)).rejects.toThrow( + "Telegram not configured", + ); + }); + + it("should create and initialize provider with correct config", async () => { + const { createInitializedProvider } = await import( + "../providers/factory.js" + ); + + // Mock startListening to return immediately for test + (mockProvider.startListening as Mock).mockImplementationOnce(async () => { + // Immediately resolve to avoid hanging + }); + + // Start monitor in background + const _monitorPromise = monitorTelegramProvider(true, mockRuntime); + + // Wait a bit for initialization + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(createInitializedProvider).toHaveBeenCalledWith("telegram", { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + sessionDir: undefined, + allowFrom: ["@testuser"], + verbose: true, + }); + + expect(mockProvider.onMessage).toHaveBeenCalled(); + expect(mockProvider.startListening).toHaveBeenCalled(); + + // monitorPromise will hang indefinitely, but we've verified the setup + }); + + it("should register message handler and handle inbound messages", async () => { + const { getReplyFromConfig } = await import("../auto-reply/reply.js"); + + // Mock startListening to call handler with test message + (mockProvider.startListening as Mock).mockImplementationOnce(async () => { + // Simulate receiving a message + if (messageHandler) { + const testMessage: ProviderMessage = { + id: "test-msg-123", + from: "@testuser", + to: "@botuser", + body: "Hello bot", + timestamp: Date.now(), + provider: "telegram", + }; + await messageHandler(testMessage); + } + }); + + // Start monitor + const _monitorPromise = monitorTelegramProvider(false, mockRuntime); + + // Wait for message to be processed + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify reply was requested + expect(getReplyFromConfig).toHaveBeenCalledWith( + expect.objectContaining({ + Body: "Hello bot", + From: "@testuser", + To: "@botuser", + }), + expect.objectContaining({ + onReplyStart: expect.any(Function), + }), + expect.anything(), + ); + + // Verify reply was sent + expect(mockProvider.send).toHaveBeenCalledWith( + "@testuser", + "Test reply", + expect.anything(), + ); + + // monitorPromise will hang, but we've verified message handling + }); + + it("should filter messages based on allowFrom config", async () => { + const { loadConfig } = await import("../config/config.js"); + const { getReplyFromConfig } = await import("../auto-reply/reply.js"); + + // Set allowFrom filter + (loadConfig as Mock).mockReturnValueOnce({ + telegram: { + allowFrom: ["@alloweduser"], + }, + inbound: { + reply: { + mode: "text", + text: "Auto-reply test", + }, + }, + }); + + // Mock startListening to call handler with message from non-allowed user + (mockProvider.startListening as Mock).mockImplementationOnce(async () => { + if (messageHandler) { + const testMessage: ProviderMessage = { + id: "test-msg-123", + from: "@notallowed", + to: "@botuser", + body: "Hello", + timestamp: Date.now(), + provider: "telegram", + }; + await messageHandler(testMessage); + } + }); + + // Start monitor + const _monitorPromise = monitorTelegramProvider(true, mockRuntime); + + // Wait for processing + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify reply was NOT attempted (filtered out) + expect(getReplyFromConfig).not.toHaveBeenCalled(); + expect(mockProvider.send).not.toHaveBeenCalled(); + }); + + it("should handle media messages", async () => { + const { getReplyFromConfig } = await import("../auto-reply/reply.js"); + + // Mock reply with media + (getReplyFromConfig as Mock).mockResolvedValueOnce({ + text: "Here's an image", + mediaUrl: "https://example.com/image.jpg", + }); + + // Mock startListening to send message with media + (mockProvider.startListening as Mock).mockImplementationOnce(async () => { + if (messageHandler) { + const testMessage: ProviderMessage = { + id: "test-msg-456", + from: "@testuser", + to: "@botuser", + body: "Send me a picture", + timestamp: Date.now(), + provider: "telegram", + media: [ + { + type: "image", + url: "https://example.com/input.jpg", + mimeType: "image/jpeg", + }, + ], + }; + await messageHandler(testMessage); + } + }); + + // Start monitor + const _monitorPromise = monitorTelegramProvider(false, mockRuntime); + + // Wait for processing + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify reply with media was sent + expect(mockProvider.send).toHaveBeenCalledWith( + "@testuser", + "Here's an image", + expect.objectContaining({ + media: expect.arrayContaining([ + expect.objectContaining({ + type: "image", + url: "https://example.com/image.jpg", + }), + ]), + }), + ); + }); + + it("should send typing indicator when onReplyStart is called", async () => { + const { getReplyFromConfig } = await import("../auto-reply/reply.js"); + let capturedOnReplyStart: (() => Promise) | null = null; + + // Capture onReplyStart callback + (getReplyFromConfig as Mock).mockImplementationOnce(async (_ctx, opts) => { + capturedOnReplyStart = opts?.onReplyStart; + return { text: "Test reply" }; + }); + + // Mock startListening + (mockProvider.startListening as Mock).mockImplementationOnce(async () => { + if (messageHandler) { + const testMessage: ProviderMessage = { + id: "test-msg-789", + from: "@testuser", + to: "@botuser", + body: "Hello", + timestamp: Date.now(), + provider: "telegram", + }; + await messageHandler(testMessage); + } + }); + + // Start monitor + const _monitorPromise = monitorTelegramProvider(false, mockRuntime); + + // Wait for processing + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Call captured onReplyStart + if (capturedOnReplyStart) { + await capturedOnReplyStart(); + } + + // Verify typing indicator was sent + expect(mockProvider.sendTyping).toHaveBeenCalledWith("@testuser"); + }); +}); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts new file mode 100644 index 000000000..dad0051a5 --- /dev/null +++ b/src/telegram/monitor.ts @@ -0,0 +1,264 @@ +/** + * Telegram Relay Monitor + * + * Monitors incoming Telegram messages and handles auto-reply via the Provider interface. + */ + +import { chunkText } from "../auto-reply/chunk.js"; +import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js"; +import type { MsgContext } from "../auto-reply/templating.js"; +import { loadConfig } from "../config/config.js"; +import { readEnv } from "../env.js"; +import { danger, info, isVerbose, logVerbose, success } from "../globals.js"; +import type { Provider } from "../providers/base/interface.js"; +import type { + ProviderMessage, + TelegramProviderConfig, +} from "../providers/base/types.js"; +import { createInitializedProvider } from "../providers/factory.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { normalizeAllowFromEntry } from "../utils.js"; + +const TELEGRAM_TEXT_LIMIT = 4096; // Telegram's message length limit + +/** + * Convert ProviderMessage to MsgContext for auto-reply system. + */ +function providerMessageToContext(message: ProviderMessage): MsgContext { + return { + Body: message.body, + From: message.from, + To: message.to, + MessageSid: message.id, + MediaUrl: message.media?.[0]?.url, + MediaType: message.media?.[0]?.mimeType, + MediaPath: message.media?.[0]?.fileName, + }; +} + +/** + * Send a reply payload via the provider. + */ +async function sendReply( + provider: Provider, + replyTo: string, + payload: ReplyPayload, + runtime: RuntimeEnv, +): Promise { + const mediaList = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + + const text = payload.text ?? ""; + const chunks = chunkText(text, TELEGRAM_TEXT_LIMIT); + if (chunks.length === 0 && mediaList.length === 0) { + return; // Nothing to send + } + + // Send first chunk with first media (if any) + if (chunks.length > 0 || mediaList.length > 0) { + const firstChunk = chunks.length > 0 ? chunks[0] : ""; + const firstMedia = mediaList[0]; + + try { + const result = await provider.send(replyTo, firstChunk, { + media: firstMedia ? [{ type: "image", url: firstMedia }] : undefined, + }); + + if (isVerbose()) { + runtime.log( + success( + `↩️ Auto-replied to ${replyTo} via Telegram (id ${result.messageId})`, + ), + ); + } + } catch (err) { + runtime.error(danger(`Failed to send Telegram reply: ${String(err)}`)); + throw err; + } + } + + // Send remaining text chunks (no media) + for (let i = 1; i < chunks.length; i++) { + try { + await provider.send(replyTo, chunks[i]); + } catch (err) { + runtime.error( + danger(`Failed to send Telegram reply chunk ${i}: ${String(err)}`), + ); + } + } + + // Send remaining media (without text) + for (let i = 1; i < mediaList.length; i++) { + try { + await provider.send(replyTo, "", { + media: [{ type: "image", url: mediaList[i] }], + }); + } catch (err) { + runtime.error( + danger(`Failed to send Telegram media ${i}: ${String(err)}`), + ); + } + } +} + +/** + * Handle an inbound message with auto-reply logic. + */ +async function handleInboundMessage( + message: ProviderMessage, + provider: Provider, + runtime: RuntimeEnv, +): Promise { + const ctx = providerMessageToContext(message); + const config = loadConfig(); + + // Check allowFrom filter + const allowFrom = config.telegram?.allowFrom; + if (Array.isArray(allowFrom) && allowFrom.length > 0) { + if (!allowFrom.includes("*")) { + const normalizedFrom = normalizeAllowFromEntry(message.from, "telegram"); + const normalizedAllowList = allowFrom.map((e) => + normalizeAllowFromEntry(e, "telegram"), + ); + if (!normalizedAllowList.includes(normalizedFrom)) { + if (isVerbose()) { + logVerbose( + `Skipping auto-reply: sender ${message.from} not in telegram.allowFrom list`, + ); + } + return; + } + } + } + + // Log inbound message + const timestamp = new Date(message.timestamp).toISOString(); + runtime.log( + `\n[${timestamp}] ${message.from} -> ${message.to}: ${message.body}`, + ); + + // Get reply from config + const replyResult = await getReplyFromConfig( + ctx, + { + onReplyStart: async () => { + try { + await provider.sendTyping(message.from); + } catch { + // Typing indicator is optional + } + }, + }, + config, + ); + + // Handle replies + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + + if (replies.length === 0) { + logVerbose("No auto-reply configured or reply was empty"); + return; + } + + // Send each reply + for (const payload of replies) { + await sendReply(provider, message.from, payload, runtime); + } +} + +/** + * Start monitoring Telegram for inbound messages with auto-reply. + * + * This function: + * - Reads Telegram config from environment + * - Creates and initializes a TelegramProvider + * - Registers message handler with auto-reply logic + * - Starts listening for messages + * - Runs indefinitely until interrupted or abort signal fires + * + * @param verbose - Enable verbose logging + * @param runtime - Runtime environment (for testing) + * @param abortSignal - Optional AbortSignal to stop monitoring gracefully + */ +export async function monitorTelegramProvider( + verbose: boolean, + runtime: RuntimeEnv = defaultRuntime, + abortSignal?: AbortSignal, + suppressStartMessage = false, +): Promise { + const env = readEnv(runtime); + const config = loadConfig(); + + if (!env.telegram?.apiId || !env.telegram?.apiHash) { + throw new Error( + "Telegram not configured. Set TELEGRAM_API_ID and TELEGRAM_API_HASH in .env", + ); + } + + const providerConfig: TelegramProviderConfig = { + kind: "telegram", + apiId: Number.parseInt(env.telegram.apiId, 10), + apiHash: env.telegram.apiHash, + sessionDir: undefined, // Uses default ~/.warelay/telegram + allowFrom: config.telegram?.allowFrom, + verbose, + }; + + runtime.log(info("📡 Starting Telegram relay...")); + + // Create and initialize provider + const provider = await createInitializedProvider("telegram", providerConfig); + + if (!provider.isConnected()) { + throw new Error("Failed to connect to Telegram"); + } + + const sessionId = await provider.getSessionId(); + runtime.log(info(`✅ Connected as: ${sessionId ?? "unknown"}`)); + + // Set up message handler with auto-reply + provider.onMessage(async (message: ProviderMessage) => { + try { + await handleInboundMessage(message, provider, runtime); + } catch (err) { + runtime.error(danger(`Error handling Telegram message: ${String(err)}`)); + } + }); + + // Start listening + await provider.startListening(); + + if (!suppressStartMessage) { + runtime.log( + info( + "✅ Telegram relay active. Listening for messages... (Ctrl+C to stop)", + ), + ); + } + + // Keep process alive until abort signal or forever + if (abortSignal) { + await new Promise((resolve) => { + if (abortSignal.aborted) { + resolve(); + return; + } + abortSignal.addEventListener("abort", () => resolve()); + }); + runtime.log(info("📡 Telegram relay stopping...")); + } else { + // No abort signal: run indefinitely + await new Promise(() => { + // Never resolves - process runs until interrupted + }); + } +} diff --git a/src/telegram/outbound.test.ts b/src/telegram/outbound.test.ts new file mode 100644 index 000000000..fe9948c35 --- /dev/null +++ b/src/telegram/outbound.test.ts @@ -0,0 +1,758 @@ +/** + * Outbound Tests + */ + +import type { TelegramClient } from "telegram"; +import { Api } from "telegram"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderMedia, SendOptions } from "../providers/base/types.js"; +import * as downloadModule from "./download.js"; +import { + sendMediaMessage, + sendTextMessage, + sendTypingIndicator, +} from "./outbound.js"; +import * as utilsModule from "./utils.js"; + +// Mock utils +vi.mock("./utils.js"); + +// Mock download module +vi.mock("./download.js"); + +// Mock global fetch +global.fetch = vi.fn(); + +describe("outbound", () => { + let mockClient: Partial; + let mockEntity: Api.User; + + beforeEach(() => { + mockEntity = new Api.User({ + id: BigInt(12345), + firstName: "Test", + }); + + mockClient = { + sendMessage: vi.fn(), + sendFile: vi.fn(), + invoke: vi.fn(), + }; + + vi.mocked(utilsModule.resolveEntity).mockResolvedValue(mockEntity); + vi.mocked(utilsModule.extractUserId).mockReturnValue("12345"); + + vi.clearAllMocks(); + }); + + describe("sendTextMessage", () => { + it("sends text message with username", async () => { + const mockResult = { + id: 999, + message: "test message", + }; + + vi.mocked(mockClient.sendMessage).mockResolvedValue(mockResult as any); + + const result = await sendTextMessage( + mockClient as TelegramClient, + "@testuser", + "test message", + ); + + expect(utilsModule.resolveEntity).toHaveBeenCalledWith( + mockClient, + "@testuser", + ); + expect(mockClient.sendMessage).toHaveBeenCalledWith(mockEntity, { + message: "test message", + replyTo: undefined, + }); + expect(result).toEqual({ + messageId: "999", + status: "sent", + providerMeta: { + jid: "12345", + }, + }); + }); + + it("sends text message with phone number", async () => { + const mockResult = { + id: 999, + message: "test message", + }; + + vi.mocked(mockClient.sendMessage).mockResolvedValue(mockResult as any); + + const result = await sendTextMessage( + mockClient as TelegramClient, + "+1234567890", + "test message", + ); + + expect(utilsModule.resolveEntity).toHaveBeenCalledWith( + mockClient, + "+1234567890", + ); + expect(result.messageId).toBe("999"); + }); + + it("sends text message with replyTo option", async () => { + const mockResult = { + id: 999, + message: "test message", + }; + + vi.mocked(mockClient.sendMessage).mockResolvedValue(mockResult as any); + + const options: SendOptions = { + replyTo: "123", + }; + + await sendTextMessage( + mockClient as TelegramClient, + "@testuser", + "test message", + options, + ); + + expect(mockClient.sendMessage).toHaveBeenCalledWith(mockEntity, { + message: "test message", + replyTo: 123, + }); + }); + + it("sends text message without options", async () => { + const mockResult = { + id: 999, + message: "test message", + }; + + vi.mocked(mockClient.sendMessage).mockResolvedValue(mockResult as any); + + await sendTextMessage( + mockClient as TelegramClient, + "@testuser", + "test message", + ); + + expect(mockClient.sendMessage).toHaveBeenCalledWith(mockEntity, { + message: "test message", + replyTo: undefined, + }); + }); + }); + + describe("sendMediaMessage", () => { + it("sends image from buffer", async () => { + const mockResult = { + id: 999, + }; + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "image", + buffer: Buffer.from("fake-image-data"), + mimeType: "image/jpeg", + }; + + const result = await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Check this out!", + media, + ); + + expect(mockClient.sendFile).toHaveBeenCalledWith(mockEntity, { + file: media.buffer, + caption: "Check this out!", + replyTo: undefined, + }); + expect(result).toEqual({ + messageId: "999", + status: "sent", + providerMeta: { + jid: "12345", + }, + }); + }); + + it("sends video with attributes", async () => { + const mockResult = { + id: 999, + }; + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "video", + buffer: Buffer.from("fake-video-data"), + mimeType: "video/mp4", + }; + + await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Watch this", + media, + ); + + expect(mockClient.sendFile).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + file: media.buffer, + caption: "Watch this", + attributes: expect.arrayContaining([ + expect.any(Api.DocumentAttributeVideo), + ]), + }), + ); + }); + + it("sends audio file", async () => { + const mockResult = { + id: 999, + }; + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "audio", + buffer: Buffer.from("fake-audio-data"), + mimeType: "audio/mp3", + }; + + await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Listen to this", + media, + ); + + expect(mockClient.sendFile).toHaveBeenCalledWith(mockEntity, { + file: media.buffer, + caption: "Listen to this", + replyTo: undefined, + voiceNote: false, + }); + }); + + it("sends voice note with voiceNote flag", async () => { + const mockResult = { + id: 999, + }; + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "voice", + buffer: Buffer.from("fake-voice-data"), + mimeType: "audio/ogg", + }; + + await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "", + media, + ); + + expect(mockClient.sendFile).toHaveBeenCalledWith(mockEntity, { + file: media.buffer, + caption: undefined, + replyTo: undefined, + voiceNote: true, + }); + }); + + it("sends document with fileName attribute", async () => { + const mockResult = { + id: 999, + }; + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "document", + buffer: Buffer.from("fake-doc-data"), + fileName: "report.pdf", + mimeType: "application/pdf", + }; + + await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Here's the report", + media, + ); + + expect(mockClient.sendFile).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + file: media.buffer, + caption: "Here's the report", + attributes: expect.arrayContaining([ + expect.any(Api.DocumentAttributeFilename), + ]), + }), + ); + }); + + it("sends document without fileName attribute", async () => { + const mockResult = { + id: 999, + }; + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "document", + buffer: Buffer.from("fake-doc-data"), + mimeType: "application/pdf", + }; + + await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Here's a file", + media, + ); + + expect(mockClient.sendFile).toHaveBeenCalledWith(mockEntity, { + file: media.buffer, + caption: "Here's a file", + replyTo: undefined, + attributes: undefined, + }); + }); + + it("downloads and sends media from URL", async () => { + const mockResult = { + id: 999, + }; + + const mockTempPath = "/tmp/test-image.tmp"; + const mockCleanup = vi.fn().mockResolvedValue(undefined); + + // Mock HEAD request for size check + const mockHeadResponse = { + headers: { + get: vi.fn((name: string) => { + if (name === "content-length") return "1024"; + return null; + }), + }, + }; + vi.mocked(global.fetch).mockResolvedValueOnce(mockHeadResponse as any); + + // Mock streamDownloadToTemp + vi.mocked(downloadModule.streamDownloadToTemp).mockResolvedValue({ + tempPath: mockTempPath, + size: 1024, + contentType: "image/jpeg", + cleanup: mockCleanup, + }); + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "image", + url: "https://example.com/image.jpg", + mimeType: "image/jpeg", + }; + + await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Look at this", + media, + ); + + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/image.jpg", + { method: "HEAD" }, + ); + expect(downloadModule.streamDownloadToTemp).toHaveBeenCalledWith( + "https://example.com/image.jpg", + expect.any(Number), + ); + expect(mockClient.sendFile).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + file: mockTempPath, + caption: "Look at this", + }), + ); + expect(mockCleanup).toHaveBeenCalledTimes(1); + }); + + it("warns but proceeds when content-length header is missing", async () => { + const mockResult = { + id: 999, + }; + + const mockTempPath = "/tmp/test-chunked.tmp"; + const mockCleanup = vi.fn().mockResolvedValue(undefined); + + // Mock HEAD request without content-length + const mockHeadResponse = { + headers: { + get: vi.fn(() => null), // No content-length header + }, + }; + vi.mocked(global.fetch).mockResolvedValueOnce(mockHeadResponse as any); + + // Mock streamDownloadToTemp + vi.mocked(downloadModule.streamDownloadToTemp).mockResolvedValue({ + tempPath: mockTempPath, + size: 1024, + contentType: "image/jpeg", + cleanup: mockCleanup, + }); + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "image", + url: "https://example.com/chunked.jpg", + }; + + const result = await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Image without size", + media, + ); + + // No warning expected now - download proceeds without size check + expect(result.messageId).toBe("999"); + expect(mockCleanup).toHaveBeenCalledTimes(1); + }); + + it("falls back to GET when HEAD request fails", async () => { + const mockResult = { + id: 999, + }; + + const mockTempPath = "/tmp/test-no-head.tmp"; + const mockCleanup = vi.fn().mockResolvedValue(undefined); + + // Mock HEAD request failure (host blocks HEAD) + vi.mocked(global.fetch).mockRejectedValueOnce( + new Error("Method Not Allowed"), + ); + + // Mock streamDownloadToTemp + vi.mocked(downloadModule.streamDownloadToTemp).mockResolvedValue({ + tempPath: mockTempPath, + size: 1024, + contentType: "image/jpeg", + cleanup: mockCleanup, + }); + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(); + + const media: ProviderMedia = { + type: "image", + url: "https://example.com/no-head.jpg", + }; + + const result = await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Image from host blocking HEAD", + media, + ); + + // Should warn about HEAD failure + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("HEAD request failed"), + ); + // Should still succeed + expect(result.messageId).toBe("999"); + expect(mockCleanup).toHaveBeenCalledTimes(1); + + consoleWarnSpy.mockRestore(); + }); + + it("throws error when URL download fails", async () => { + // Mock HEAD request success + const mockHeadResponse = { + headers: { + get: vi.fn((name: string) => { + if (name === "content-length") return "1024"; + return null; + }), + }, + }; + vi.mocked(global.fetch).mockResolvedValueOnce(mockHeadResponse as any); + + // Mock streamDownloadToTemp to throw + vi.mocked(downloadModule.streamDownloadToTemp).mockRejectedValue( + new Error( + "Failed to download media from https://example.com/missing.jpg: Not Found", + ), + ); + + const media: ProviderMedia = { + type: "image", + url: "https://example.com/missing.jpg", + }; + + await expect( + sendMediaMessage(mockClient as TelegramClient, "@testuser", "", media), + ).rejects.toThrow( + "Failed to download media from https://example.com/missing.jpg: Not Found", + ); + }); + + it("throws error when media has neither buffer nor URL", async () => { + const media: ProviderMedia = { + type: "image", + mimeType: "image/jpeg", + }; + + await expect( + sendMediaMessage(mockClient as TelegramClient, "@testuser", "", media), + ).rejects.toThrow("Media must have either buffer or url"); + }); + + it("sends media with replyTo option", async () => { + const mockResult = { + id: 999, + }; + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "image", + buffer: Buffer.from("fake-image-data"), + }; + + const options: SendOptions = { + replyTo: "123", + }; + + await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Reply with image", + media, + options, + ); + + expect(mockClient.sendFile).toHaveBeenCalledWith(mockEntity, { + file: media.buffer, + caption: "Reply with image", + replyTo: 123, + }); + }); + + it("uses empty body as undefined caption", async () => { + const mockResult = { + id: 999, + }; + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "image", + buffer: Buffer.from("fake-image-data"), + }; + + await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "", + media, + ); + + expect(mockClient.sendFile).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + caption: undefined, + }), + ); + }); + + describe("streaming downloads", () => { + it("downloads URL to temp file and cleans up after send", async () => { + const mockResult = { + id: 999, + }; + + const mockTempPath = "/tmp/test-file.tmp"; + const mockCleanup = vi.fn().mockResolvedValue(undefined); + + // Mock HEAD request + const mockHeadResponse = { + headers: { + get: vi.fn((name: string) => { + if (name === "content-length") return "1024"; + return null; + }), + }, + }; + vi.mocked(global.fetch).mockResolvedValueOnce(mockHeadResponse as any); + + // Mock streamDownloadToTemp + vi.mocked(downloadModule.streamDownloadToTemp).mockResolvedValue({ + tempPath: mockTempPath, + size: 1024, + contentType: "image/jpeg", + cleanup: mockCleanup, + }); + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "image", + url: "https://example.com/test.jpg", + }; + + await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Streamed image", + media, + ); + + // Verify streamDownloadToTemp called + expect(downloadModule.streamDownloadToTemp).toHaveBeenCalledWith( + "https://example.com/test.jpg", + expect.any(Number), + ); + + // Verify sendFile called with path (not buffer) + expect(mockClient.sendFile).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + file: mockTempPath, // String path, not Buffer + caption: "Streamed image", + }), + ); + + // Verify cleanup was called + expect(mockCleanup).toHaveBeenCalledTimes(1); + }); + + it("cleans up temp file even when sendFile fails", async () => { + const mockTempPath = "/tmp/test-file.tmp"; + const mockCleanup = vi.fn().mockResolvedValue(undefined); + + // Mock HEAD request + const mockHeadResponse = { + headers: { + get: vi.fn(() => "1024"), + }, + }; + vi.mocked(global.fetch).mockResolvedValueOnce(mockHeadResponse as any); + + // Mock streamDownloadToTemp + vi.mocked(downloadModule.streamDownloadToTemp).mockResolvedValue({ + tempPath: mockTempPath, + size: 1024, + contentType: "image/jpeg", + cleanup: mockCleanup, + }); + + // Mock sendFile to fail + vi.mocked(mockClient.sendFile).mockRejectedValue( + new Error("Network error"), + ); + + const media: ProviderMedia = { + type: "image", + url: "https://example.com/test.jpg", + }; + + await expect( + sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Failed send", + media, + ), + ).rejects.toThrow("Network error"); + + // Verify cleanup was still called despite error + expect(mockCleanup).toHaveBeenCalledTimes(1); + }); + + it("preserves buffer-based media path (backward compat)", async () => { + const mockResult = { + id: 999, + }; + + vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any); + + const media: ProviderMedia = { + type: "image", + buffer: Buffer.from("test-buffer"), + mimeType: "image/jpeg", + }; + + await sendMediaMessage( + mockClient as TelegramClient, + "@testuser", + "Buffer image", + media, + ); + + // Verify streamDownloadToTemp NOT called for buffers + expect(downloadModule.streamDownloadToTemp).not.toHaveBeenCalled(); + + // Verify sendFile called with Buffer (not path) + expect(mockClient.sendFile).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + file: media.buffer, + caption: "Buffer image", + }), + ); + }); + }); + }); + + describe("sendTypingIndicator", () => { + it("sends typing indicator to user", async () => { + vi.mocked(mockClient.invoke).mockResolvedValue(undefined as any); + + await sendTypingIndicator(mockClient as TelegramClient, "@testuser"); + + expect(utilsModule.resolveEntity).toHaveBeenCalledWith( + mockClient, + "@testuser", + ); + expect(mockClient.invoke).toHaveBeenCalledWith( + expect.any(Api.messages.SetTyping), + ); + }); + + it("sends typing indicator with phone number", async () => { + vi.mocked(mockClient.invoke).mockResolvedValue(undefined as any); + + await sendTypingIndicator(mockClient as TelegramClient, "+1234567890"); + + expect(utilsModule.resolveEntity).toHaveBeenCalledWith( + mockClient, + "+1234567890", + ); + }); + + it("uses SendMessageTypingAction", async () => { + vi.mocked(mockClient.invoke).mockResolvedValue(undefined as any); + + await sendTypingIndicator(mockClient as TelegramClient, "@testuser"); + + const callArgs = vi.mocked(mockClient.invoke).mock.calls[0][0]; + expect(callArgs).toBeInstanceOf(Api.messages.SetTyping); + expect(callArgs.peer).toBe(mockEntity); + expect(callArgs.action).toBeInstanceOf(Api.SendMessageTypingAction); + }); + }); +}); diff --git a/src/telegram/outbound.ts b/src/telegram/outbound.ts new file mode 100644 index 000000000..02a030dff --- /dev/null +++ b/src/telegram/outbound.ts @@ -0,0 +1,181 @@ +import type { TelegramClient } from "telegram"; +import { Api } from "telegram"; +import type { + ProviderMedia, + SendOptions, + SendResult, +} from "../providers/base/types.js"; +import { capabilities } from "./capabilities.js"; +import { streamDownloadToTemp } from "./download.js"; +import { extractUserId, resolveEntity } from "./utils.js"; + +/** + * Send a text message via Telegram. + */ +export async function sendTextMessage( + client: TelegramClient, + to: string, + body: string, + options?: SendOptions, +): Promise { + const entity = await resolveEntity(client, to); + + const result = await client.sendMessage(entity, { + message: body, + replyTo: options?.replyTo ? Number(options.replyTo) : undefined, + }); + + return { + messageId: result.id.toString(), + status: "sent", + providerMeta: { + jid: extractUserId(entity), + }, + }; +} + +/** + * Send a message with media attachment via Telegram. + * + * Media sources: + * - Buffer: Sent directly from memory (existing behavior) + * - URL: Streamed to temp file (~/.warelay/telegram-temp), sent, then cleaned up + * + * Streaming eliminates OOM risk for large files by avoiding memory buffering. + * Temp files are automatically cleaned up after send (success or failure). + */ +export async function sendMediaMessage( + client: TelegramClient, + to: string, + body: string, + media: ProviderMedia, + options?: SendOptions, +): Promise { + const entity = await resolveEntity(client, to); + + // Determine file source + let file: Buffer | string; + let downloadCleanup: (() => Promise) | undefined; + + try { + if (media.buffer) { + // CASE 1: Buffer-based media (existing path) + file = media.buffer; + } else if (media.url) { + // CASE 2: URL-based media (streaming path) + + // HEAD check for early size validation (best effort) + let contentLength: string | null = null; + try { + const headResponse = await fetch(media.url, { method: "HEAD" }); + contentLength = headResponse.headers.get("content-length"); + } catch (headError) { + // HEAD blocked or failed - warn but proceed with streaming download + console.warn( + `⚠️ HEAD request failed for ${media.url}, proceeding with streaming download: ${headError instanceof Error ? headError.message : String(headError)}`, + ); + } + + const maxSize = capabilities.maxMediaSize; + + if (contentLength) { + const sizeBytes = Number.parseInt(contentLength, 10); + if (sizeBytes > maxSize) { + throw new Error( + `Media size ${(sizeBytes / 1024 / 1024).toFixed(1)}MB exceeds maximum ${(maxSize / 1024 / 1024).toFixed(0)}MB. ` + + "Lower limit with TELEGRAM_MAX_MEDIA_MB env var if needed.", + ); + } + } + + // Stream download to temp file (eliminates memory buffering) + const download = await streamDownloadToTemp(media.url, maxSize); + file = download.tempPath; + downloadCleanup = download.cleanup; + } else { + throw new Error("Media must have either buffer or url"); + } + + // Send based on media type + let result: { id: number }; + const caption = body || undefined; + + switch (media.type) { + case "image": + result = await client.sendFile(entity, { + file, + caption, + replyTo: options?.replyTo ? Number(options.replyTo) : undefined, + }); + break; + + case "video": + result = await client.sendFile(entity, { + file, + caption, + replyTo: options?.replyTo ? Number(options.replyTo) : undefined, + attributes: [ + new Api.DocumentAttributeVideo({ + duration: 0, + w: 0, + h: 0, + }), + ], + }); + break; + + case "audio": + case "voice": + result = await client.sendFile(entity, { + file, + caption, + replyTo: options?.replyTo ? Number(options.replyTo) : undefined, + voiceNote: media.type === "voice", + }); + break; + default: + result = await client.sendFile(entity, { + file, + caption, + replyTo: options?.replyTo ? Number(options.replyTo) : undefined, + attributes: media.fileName + ? [ + new Api.DocumentAttributeFilename({ + fileName: media.fileName, + }), + ] + : undefined, + }); + break; + } + + return { + messageId: result.id.toString(), + status: "sent", + providerMeta: { + jid: extractUserId(entity), + }, + }; + } finally { + // CRITICAL: Clean up temp file on success or failure + if (downloadCleanup) { + await downloadCleanup(); + } + } +} + +/** + * Send typing indicator to a chat. + */ +export async function sendTypingIndicator( + client: TelegramClient, + to: string, +): Promise { + const entity = await resolveEntity(client, to); + await client.invoke( + new Api.messages.SetTyping({ + peer: entity, + action: new Api.SendMessageTypingAction(), + }), + ); +} diff --git a/src/telegram/prompts.ts b/src/telegram/prompts.ts new file mode 100644 index 000000000..6d43123c4 --- /dev/null +++ b/src/telegram/prompts.ts @@ -0,0 +1,55 @@ +import { stdin, stdout } from "node:process"; +import readline from "node:readline/promises"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; + +/** + * Prompt for phone number input. + */ +export async function promptPhone( + _runtime: RuntimeEnv = defaultRuntime, +): Promise { + const rl = readline.createInterface({ input: stdin, output: stdout }); + try { + const phone = await rl.question( + "📱 Enter your phone number (with country code, e.g., +1234567890): ", + ); + return phone.trim(); + } finally { + rl.close(); + } +} + +/** + * Prompt for SMS verification code. + */ +export async function promptSMSCode( + _runtime: RuntimeEnv = defaultRuntime, +): Promise { + const rl = readline.createInterface({ input: stdin, output: stdout }); + try { + const code = await rl.question( + "🔐 Enter the verification code from Telegram: ", + ); + return code.trim(); + } finally { + rl.close(); + } +} + +/** + * Prompt for 2FA password if enabled. + */ +export async function prompt2FA( + _runtime: RuntimeEnv = defaultRuntime, +): Promise { + const rl = readline.createInterface({ input: stdin, output: stdout }); + try { + const password = await rl.question( + "🔑 Enter your 2FA password (or leave empty if not enabled): ", + ); + return password.trim(); + } finally { + rl.close(); + } +} diff --git a/src/telegram/provider.test.ts b/src/telegram/provider.test.ts new file mode 100644 index 000000000..1ae6f7029 --- /dev/null +++ b/src/telegram/provider.test.ts @@ -0,0 +1,506 @@ +/** + * TelegramProvider Tests + */ + +import type { TelegramClient } from "telegram"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + ProviderMedia, + TelegramProviderConfig, +} from "../providers/base/types.js"; +import { capabilities } from "./capabilities.js"; +import * as clientModule from "./client.js"; +import * as inboundModule from "./inbound.js"; +import * as loginModule from "./login.js"; +import * as outboundModule from "./outbound.js"; +import { TelegramProvider } from "./provider.js"; +import * as sessionModule from "./session.js"; + +// Mock all dependencies +vi.mock("./session.js"); +vi.mock("./client.js"); +vi.mock("./login.js"); +vi.mock("./outbound.js"); +vi.mock("./inbound.js"); + +describe("TelegramProvider", () => { + let provider: TelegramProvider; + let mockClient: Partial; + + beforeEach(() => { + provider = new TelegramProvider(); + mockClient = { + connected: true, + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + getMe: vi.fn().mockResolvedValue({ + username: "testuser", + firstName: "Test", + phone: "+1234567890", + }), + }; + + vi.clearAllMocks(); + }); + + describe("Provider Properties", () => { + it("has correct kind", () => { + expect(provider.kind).toBe("telegram"); + }); + + it("has correct capabilities", () => { + expect(provider.capabilities).toEqual(capabilities); + expect(provider.capabilities.supportsDeliveryReceipts).toBe(false); + expect(provider.capabilities.supportsReadReceipts).toBe(false); + expect(provider.capabilities.supportsTypingIndicator).toBe(true); + expect(provider.capabilities.maxMediaSize).toBe(2 * 1024 * 1024 * 1024); + }); + }); + + describe("initialize", () => { + it("initializes successfully with valid config and session", async () => { + const config: TelegramProviderConfig = { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + verbose: false, + }; + + vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any); + vi.mocked(clientModule.createTelegramClient).mockResolvedValue( + mockClient as TelegramClient, + ); + + await provider.initialize(config); + + expect(sessionModule.loadSession).toHaveBeenCalled(); + expect(clientModule.createTelegramClient).toHaveBeenCalledWith({}, false); + expect(mockClient.connect).toHaveBeenCalled(); + }); + + it("throws error with invalid config kind", async () => { + const config = { + kind: "web" as any, + verbose: false, + }; + + await expect(provider.initialize(config)).rejects.toThrow( + "Invalid config kind for TelegramProvider: web", + ); + }); + + it("throws error when no session exists", async () => { + const config: TelegramProviderConfig = { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + }; + + vi.mocked(sessionModule.loadSession).mockResolvedValue(null); + + await expect(provider.initialize(config)).rejects.toThrow( + "No Telegram session found. Run: warelay login --provider telegram", + ); + }); + + it("throws error when connection fails", async () => { + const config: TelegramProviderConfig = { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + }; + + vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any); + vi.mocked(clientModule.createTelegramClient).mockResolvedValue({ + ...mockClient, + connected: false, + } as TelegramClient); + + await expect(provider.initialize(config)).rejects.toThrow( + "Failed to connect to Telegram", + ); + }); + + it("passes verbose flag to client", async () => { + const config: TelegramProviderConfig = { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + verbose: true, + }; + + vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any); + vi.mocked(clientModule.createTelegramClient).mockResolvedValue( + mockClient as TelegramClient, + ); + + await provider.initialize(config); + + expect(clientModule.createTelegramClient).toHaveBeenCalledWith({}, true); + }); + }); + + describe("Connection Management", () => { + beforeEach(async () => { + const config: TelegramProviderConfig = { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + }; + + vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any); + vi.mocked(clientModule.createTelegramClient).mockResolvedValue( + mockClient as TelegramClient, + ); + + await provider.initialize(config); + }); + + it("isConnected returns true when client is connected", () => { + vi.mocked(clientModule.isClientConnected).mockReturnValue(true); + expect(provider.isConnected()).toBe(true); + }); + + it("isConnected returns false when client is not connected", () => { + vi.mocked(clientModule.isClientConnected).mockReturnValue(false); + expect(provider.isConnected()).toBe(false); + }); + + it("isConnected returns false when no client exists", () => { + const uninitializedProvider = new TelegramProvider(); + expect(uninitializedProvider.isConnected()).toBe(false); + }); + + it("disconnects client and clears reference", async () => { + await provider.disconnect(); + + expect(mockClient.disconnect).toHaveBeenCalled(); + expect(provider.isConnected()).toBe(false); + }); + + it("disconnect handles null client gracefully", async () => { + const uninitializedProvider = new TelegramProvider(); + await expect(uninitializedProvider.disconnect()).resolves.not.toThrow(); + }); + }); + + describe("Message Sending", () => { + beforeEach(async () => { + const config: TelegramProviderConfig = { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + }; + + vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any); + vi.mocked(clientModule.createTelegramClient).mockResolvedValue( + mockClient as TelegramClient, + ); + + await provider.initialize(config); + }); + + it("send sends text message when no media provided", async () => { + const mockResult = { + messageId: "999", + status: "sent" as const, + }; + + vi.mocked(outboundModule.sendTextMessage).mockResolvedValue(mockResult); + + const result = await provider.send("@testuser", "Hello!"); + + expect(outboundModule.sendTextMessage).toHaveBeenCalledWith( + mockClient, + "@testuser", + "Hello!", + undefined, + ); + expect(result).toEqual(mockResult); + }); + + it("send sends media message when media provided", async () => { + const mockResult = { + messageId: "999", + status: "sent" as const, + }; + + vi.mocked(outboundModule.sendMediaMessage).mockResolvedValue(mockResult); + + const media: ProviderMedia = { + type: "image", + buffer: Buffer.from("test"), + }; + + const result = await provider.send("@testuser", "Check this out!", { + media: [media], + }); + + expect(outboundModule.sendMediaMessage).toHaveBeenCalledWith( + mockClient, + "@testuser", + "Check this out!", + media, + { media: [media] }, + ); + expect(result).toEqual(mockResult); + }); + + it("send throws error when provider not initialized", async () => { + const uninitializedProvider = new TelegramProvider(); + + await expect( + uninitializedProvider.send("@testuser", "Hello!"), + ).rejects.toThrow("Provider not initialized"); + }); + + it("sendTyping sends typing indicator", async () => { + vi.mocked(outboundModule.sendTypingIndicator).mockResolvedValue( + undefined, + ); + + await provider.sendTyping("@testuser"); + + expect(outboundModule.sendTypingIndicator).toHaveBeenCalledWith( + mockClient, + "@testuser", + ); + }); + + it("sendTyping throws error when provider not initialized", async () => { + const uninitializedProvider = new TelegramProvider(); + + await expect( + uninitializedProvider.sendTyping("@testuser"), + ).rejects.toThrow("Provider not initialized"); + }); + + it("getDeliveryStatus returns unknown status", async () => { + const status = await provider.getDeliveryStatus("test-id"); + expect(status.messageId).toBe("test-id"); + expect(status.status).toBe("unknown"); + expect(status.timestamp).toBeGreaterThan(0); + }); + }); + + describe("Message Listening", () => { + beforeEach(async () => { + const config: TelegramProviderConfig = { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + }; + + vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any); + vi.mocked(clientModule.createTelegramClient).mockResolvedValue( + mockClient as TelegramClient, + ); + + await provider.initialize(config); + }); + + it("onMessage stores handler", () => { + const handler = vi.fn(); + provider.onMessage(handler); + // No way to directly test private field, but it shouldn't throw + }); + + it("startListening works when handler is registered", async () => { + const handler = vi.fn(); + const mockCleanup = vi.fn(); + + vi.mocked(inboundModule.startMessageListener).mockResolvedValue( + mockCleanup, + ); + + provider.onMessage(handler); + await provider.startListening(); + + expect(inboundModule.startMessageListener).toHaveBeenCalledWith( + mockClient, + handler, + undefined, + ); + }); + + it("startListening throws error when provider not initialized", async () => { + const uninitializedProvider = new TelegramProvider(); + const handler = vi.fn(); + + uninitializedProvider.onMessage(handler); + + await expect(uninitializedProvider.startListening()).rejects.toThrow( + "Provider not initialized", + ); + }); + + it("startListening throws error when no message handler registered", async () => { + await expect(provider.startListening()).rejects.toThrow( + "No message handler registered. Call onMessage() first.", + ); + }); + + it("startListening passes allowFrom config to listener", async () => { + const config: TelegramProviderConfig = { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + allowFrom: ["@testuser", "+1234567890"], + }; + + vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any); + vi.mocked(clientModule.createTelegramClient).mockResolvedValue( + mockClient as TelegramClient, + ); + + const providerWithAllowFrom = new TelegramProvider(); + await providerWithAllowFrom.initialize(config); + + const handler = vi.fn(); + const mockCleanup = vi.fn(); + + vi.mocked(inboundModule.startMessageListener).mockResolvedValue( + mockCleanup, + ); + + providerWithAllowFrom.onMessage(handler); + await providerWithAllowFrom.startListening(); + + expect(inboundModule.startMessageListener).toHaveBeenCalledWith( + mockClient, + handler, + ["@testuser", "+1234567890"], + ); + }); + + it("stopListening calls cleanup function", async () => { + const handler = vi.fn(); + const mockCleanup = vi.fn(); + + vi.mocked(inboundModule.startMessageListener).mockResolvedValue( + mockCleanup, + ); + + provider.onMessage(handler); + await provider.startListening(); + await provider.stopListening(); + + expect(mockCleanup).toHaveBeenCalled(); + }); + + it("stopListening handles no active listener gracefully", async () => { + await expect(provider.stopListening()).resolves.not.toThrow(); + }); + + it("stopListening can be called multiple times", async () => { + const handler = vi.fn(); + const mockCleanup = vi.fn(); + + vi.mocked(inboundModule.startMessageListener).mockResolvedValue( + mockCleanup, + ); + + provider.onMessage(handler); + await provider.startListening(); + await provider.stopListening(); + await provider.stopListening(); + + expect(mockCleanup).toHaveBeenCalledTimes(1); + }); + }); + + describe("Authentication", () => { + it("isAuthenticated delegates to telegramAuthExists", async () => { + vi.mocked(sessionModule.telegramAuthExists).mockResolvedValue(true); + const result = await provider.isAuthenticated(); + expect(result).toBe(true); + expect(sessionModule.telegramAuthExists).toHaveBeenCalled(); + }); + + it("login delegates to loginTelegram", async () => { + vi.mocked(loginModule.loginTelegram).mockResolvedValue(undefined); + await provider.login(); + expect(loginModule.loginTelegram).toHaveBeenCalledWith(false); + }); + + it("logout delegates to logoutTelegram", async () => { + vi.mocked(loginModule.logoutTelegram).mockResolvedValue(undefined); + await provider.logout(); + expect(loginModule.logoutTelegram).toHaveBeenCalledWith(false); + }); + + it("login passes verbose flag", async () => { + const config: TelegramProviderConfig = { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + verbose: true, + }; + + vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any); + vi.mocked(clientModule.createTelegramClient).mockResolvedValue( + mockClient as TelegramClient, + ); + + await provider.initialize(config); + + vi.mocked(loginModule.loginTelegram).mockResolvedValue(undefined); + await provider.login(); + expect(loginModule.loginTelegram).toHaveBeenCalledWith(true); + }); + }); + + describe("getSessionId", () => { + beforeEach(async () => { + const config: TelegramProviderConfig = { + kind: "telegram", + apiId: 12345, + apiHash: "test-hash", + }; + + vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any); + vi.mocked(clientModule.createTelegramClient).mockResolvedValue( + mockClient as TelegramClient, + ); + + await provider.initialize(config); + }); + + it("returns username with @ prefix when available", async () => { + const sessionId = await provider.getSessionId(); + expect(sessionId).toBe("@testuser"); + }); + + it("returns phone when username not available", async () => { + mockClient.getMe = vi.fn().mockResolvedValue({ + firstName: "Test", + phone: "+1234567890", + }); + + const sessionId = await provider.getSessionId(); + expect(sessionId).toBe("+1234567890"); + }); + + it("returns null when neither username nor phone available", async () => { + mockClient.getMe = vi.fn().mockResolvedValue({ + firstName: "Test", + }); + + const sessionId = await provider.getSessionId(); + expect(sessionId).toBe(null); + }); + + it("returns null when client is not initialized", async () => { + const uninitializedProvider = new TelegramProvider(); + const sessionId = await uninitializedProvider.getSessionId(); + expect(sessionId).toBe(null); + }); + + it("returns null when getMe throws error", async () => { + mockClient.getMe = vi.fn().mockRejectedValue(new Error("API error")); + + const sessionId = await provider.getSessionId(); + expect(sessionId).toBe(null); + }); + }); +}); diff --git a/src/telegram/provider.ts b/src/telegram/provider.ts new file mode 100644 index 000000000..f575b219c --- /dev/null +++ b/src/telegram/provider.ts @@ -0,0 +1,176 @@ +import type { TelegramClient } from "telegram"; +import type { + DeliveryStatus, + MessageHandler, + Provider, + ProviderConfig, + SendOptions, + SendResult, + TelegramProviderConfig, +} from "../providers/base/index.js"; +import { capabilities } from "./capabilities.js"; +import { createTelegramClient, isClientConnected } from "./client.js"; +import { cleanOrphanedTempFiles } from "./download.js"; +import { startMessageListener } from "./inbound.js"; +import { loginTelegram, logoutTelegram } from "./login.js"; +import { + sendMediaMessage, + sendTextMessage, + sendTypingIndicator, +} from "./outbound.js"; +import { loadSession, telegramAuthExists } from "./session.js"; + +/** + * Telegram MTProto Provider + * + * Implements the unified Provider interface for Telegram using MTProto (GramJS). + * Handles personal account automation for 1-on-1 conversations. + */ +export class TelegramProvider implements Provider { + readonly kind = "telegram"; + readonly capabilities = capabilities; + + private client: TelegramClient | null = null; + private config: TelegramProviderConfig | null = null; + private messageHandler: MessageHandler | null = null; + private cleanupListener: (() => void) | null = null; + private verbose = false; + + async initialize(config: ProviderConfig): Promise { + if (config.kind !== "telegram") { + throw new Error( + `Invalid config kind for TelegramProvider: ${config.kind}`, + ); + } + + this.config = config; + this.verbose = config.verbose ?? false; + + // Clean up orphaned temp files from previous crashes + await cleanOrphanedTempFiles(); + + // Load session + const session = await loadSession(); + if (!session) { + throw new Error( + "No Telegram session found. Run: warelay login --provider telegram", + ); + } + + // Create and connect client + this.client = await createTelegramClient(session, this.verbose); + await this.client.connect(); + + if (!this.client.connected) { + throw new Error("Failed to connect to Telegram"); + } + } + + isConnected(): boolean { + return this.client !== null && isClientConnected(this.client); + } + + async disconnect(): Promise { + if (this.client) { + await this.client.disconnect(); + this.client = null; + } + } + + async send( + to: string, + body: string, + options?: SendOptions, + ): Promise { + if (!this.client) { + throw new Error("Provider not initialized"); + } + + // If media provided, send with media + if (options?.media && options.media.length > 0) { + const media = options.media[0]; // Take first media attachment + return await sendMediaMessage(this.client, to, body, media, options); + } + + // Otherwise send text only + return await sendTextMessage(this.client, to, body, options); + } + + async sendTyping(to: string): Promise { + if (!this.client) { + throw new Error("Provider not initialized"); + } + + await sendTypingIndicator(this.client, to); + } + + async getDeliveryStatus(messageId: string): Promise { + // Telegram MTProto doesn't provide reliable delivery tracking + // Messages are sent optimistically, so we return "unknown" + return { + messageId, + status: "unknown", + timestamp: Date.now(), + }; + } + + onMessage(handler: MessageHandler): void { + this.messageHandler = handler; + } + + async startListening(): Promise { + if (!this.client) { + throw new Error("Provider not initialized"); + } + + if (!this.messageHandler) { + throw new Error("No message handler registered. Call onMessage() first."); + } + + const allowFrom = this.config?.allowFrom; + + this.cleanupListener = await startMessageListener( + this.client, + this.messageHandler, + allowFrom, + ); + } + + async stopListening(): Promise { + if (this.cleanupListener) { + this.cleanupListener(); + this.cleanupListener = null; + } + } + + async isAuthenticated(): Promise { + return await telegramAuthExists(); + } + + async login(): Promise { + await loginTelegram(this.verbose); + } + + async logout(): Promise { + await logoutTelegram(this.verbose); + } + + async getSessionId(): Promise { + if (!this.client) { + return null; + } + + try { + const me = await this.client.getMe(); + if ("username" in me && typeof me.username === "string") { + return `@${me.username}`; + } + if ("phone" in me && typeof me.phone === "string") { + return me.phone; + } + return null; + } catch { + return null; + } + } +} diff --git a/src/telegram/session.test.ts b/src/telegram/session.test.ts new file mode 100644 index 000000000..2cc5b338a --- /dev/null +++ b/src/telegram/session.test.ts @@ -0,0 +1,134 @@ +import fs from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs/promises"); +vi.mock("telegram/sessions/index.js", () => ({ + StringSession: class { + _sessionString: string; + constructor(sessionString = "") { + this._sessionString = sessionString; + } + save() { + return this._sessionString || "mock-session-string"; + } + }, +})); +vi.mock("../utils.js", () => ({ + ensureDir: vi.fn().mockResolvedValue(undefined), +})); + +import { + clearSession, + loadSession, + saveSession, + telegramAuthExists, +} from "./session.js"; + +describe("telegram session", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("loadSession", () => { + it("returns null when session file does not exist", async () => { + vi.mocked(fs.readFile).mockRejectedValue( + Object.assign(new Error("ENOENT"), { + code: "ENOENT", + }), + ); + + const result = await loadSession(); + + expect(result).toBeNull(); + expect(fs.readFile).toHaveBeenCalled(); + }); + + it("loads session from disk when file exists", async () => { + const sessionString = "test-session-string"; + vi.mocked(fs.readFile).mockResolvedValue(` ${sessionString} ` as never); + + const result = await loadSession(); + + expect(result).toBeDefined(); + expect(result?._sessionString).toBe(sessionString); + expect(fs.readFile).toHaveBeenCalled(); + }); + + it("throws on unexpected errors", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied")); + + await expect(loadSession()).rejects.toThrow("Permission denied"); + }); + }); + + describe("saveSession", () => { + it("saves session to disk", async () => { + const mockSession = { + save: vi.fn(() => "saved-session-string"), + }; + vi.mocked(fs.writeFile).mockResolvedValue(undefined as never); + + await saveSession(mockSession as never); + + expect(mockSession.save).toHaveBeenCalled(); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining("session.string"), + "saved-session-string", + "utf-8", + ); + }); + }); + + describe("clearSession", () => { + it("removes session file when it exists", async () => { + vi.mocked(fs.unlink).mockResolvedValue(undefined as never); + + await clearSession(); + + expect(fs.unlink).toHaveBeenCalledWith( + expect.stringContaining("session.string"), + ); + }); + + it("does not throw when file does not exist", async () => { + vi.mocked(fs.unlink).mockRejectedValue( + Object.assign(new Error("ENOENT"), { + code: "ENOENT", + }), + ); + + await expect(clearSession()).resolves.toBeUndefined(); + }); + + it("throws on unexpected errors", async () => { + vi.mocked(fs.unlink).mockRejectedValue(new Error("Permission denied")); + + await expect(clearSession()).rejects.toThrow("Permission denied"); + }); + }); + + describe("telegramAuthExists", () => { + it("returns true when session file exists", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined as never); + + const result = await telegramAuthExists(); + + expect(result).toBe(true); + expect(fs.access).toHaveBeenCalledWith( + expect.stringContaining("session.string"), + ); + }); + + it("returns false when session file does not exist", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")); + + const result = await telegramAuthExists(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/telegram/session.ts b/src/telegram/session.ts new file mode 100644 index 000000000..bace995e9 --- /dev/null +++ b/src/telegram/session.ts @@ -0,0 +1,119 @@ +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { StringSession } from "telegram/sessions/index.js"; +import { ensureDir } from "../utils.js"; + +// New branding path (preferred) +const TELEGRAM_SESSION_DIR_CLAWDIS = path.join( + os.homedir(), + ".clawdis", + "telegram", + "session", +); + +// Legacy path (fallback for backward compatibility) +const TELEGRAM_SESSION_DIR_LEGACY = path.join( + os.homedir(), + ".warelay", + "telegram", + "session", +); + +// Exported for backward compatibility +export const TELEGRAM_SESSION_DIR = TELEGRAM_SESSION_DIR_LEGACY; + +/** + * Resolve the Telegram session directory path. + * Prefers ~/.clawdis/telegram/session, falls back to ~/.warelay/telegram/session + */ +function resolveSessionDir(): string { + try { + // Synchronous check for CLAWDIS path + const clawdisSession = path.join( + TELEGRAM_SESSION_DIR_CLAWDIS, + "session.string", + ); + if (fsSync.existsSync(clawdisSession)) { + return TELEGRAM_SESSION_DIR_CLAWDIS; + } + } catch { + // Fall through to legacy path + } + return TELEGRAM_SESSION_DIR_LEGACY; +} + +/** + * Load Telegram session from disk. + * Returns null if no session exists. + */ +export async function loadSession(): Promise { + try { + const sessionDir = resolveSessionDir(); + await ensureDir(sessionDir); + const sessionFile = path.join(sessionDir, "session.string"); + const sessionString = await fs.readFile(sessionFile, "utf-8"); + return new StringSession(sessionString.trim()); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return null; // No session file exists yet + } + throw err; // Unexpected error + } +} + +/** + * Save Telegram session to disk. + * Prefers saving to ~/.clawdis/telegram/session (new location). + */ +export async function saveSession(session: StringSession): Promise { + // Always save to new CLAWDIS path for new sessions + await ensureDir(TELEGRAM_SESSION_DIR_CLAWDIS); + const sessionString = session.save(); + const sessionFile = path.join(TELEGRAM_SESSION_DIR_CLAWDIS, "session.string"); + await fs.writeFile(sessionFile, sessionString, "utf-8"); +} + +/** + * Clear Telegram session from disk. + * Removes session from both CLAWDIS and legacy paths. + */ +export async function clearSession(): Promise { + const paths = [ + path.join(TELEGRAM_SESSION_DIR_CLAWDIS, "session.string"), + path.join(TELEGRAM_SESSION_DIR_LEGACY, "session.string"), + ]; + + for (const sessionPath of paths) { + try { + await fs.unlink(sessionPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + // Ignore ENOENT (file doesn't exist), but throw other errors + throw err; + } + } + } +} + +/** + * Check if a Telegram session exists. + * Checks both CLAWDIS and legacy paths. + */ +export async function telegramAuthExists(): Promise { + const paths = [ + path.join(TELEGRAM_SESSION_DIR_CLAWDIS, "session.string"), + path.join(TELEGRAM_SESSION_DIR_LEGACY, "session.string"), + ]; + + for (const sessionPath of paths) { + try { + await fs.access(sessionPath); + return true; // Found a session file + } catch { + // Continue checking other paths + } + } + return false; // No session found in any path +} diff --git a/src/telegram/utils.test.ts b/src/telegram/utils.test.ts new file mode 100644 index 000000000..af8b3c0d7 --- /dev/null +++ b/src/telegram/utils.test.ts @@ -0,0 +1,169 @@ +/** + * Utils Tests + */ + +import type { TelegramClient } from "telegram"; +import { Api } from "telegram"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { extractUserId, resolveEntity } from "./utils.js"; + +describe("utils", () => { + let mockClient: Partial; + + beforeEach(() => { + mockClient = { + getEntity: vi.fn(), + }; + vi.clearAllMocks(); + }); + + describe("resolveEntity", () => { + it("resolves entity with @username", async () => { + const mockUser = new Api.User({ + id: BigInt(12345), + firstName: "Test", + }); + + vi.mocked(mockClient.getEntity).mockResolvedValue(mockUser); + + const result = await resolveEntity( + mockClient as TelegramClient, + "@testuser", + ); + + expect(result).toBe(mockUser); + expect(mockClient.getEntity).toHaveBeenCalledWith("@testuser"); + }); + + it("resolves entity with phone number", async () => { + const mockUser = new Api.User({ + id: BigInt(12345), + firstName: "Test", + phone: "1234567890", + }); + + vi.mocked(mockClient.getEntity).mockResolvedValue(mockUser); + + const result = await resolveEntity( + mockClient as TelegramClient, + "+1234567890", + ); + + expect(result).toBe(mockUser); + expect(mockClient.getEntity).toHaveBeenCalledWith("+1234567890"); + }); + + it("resolves entity with user ID", async () => { + const mockUser = new Api.User({ + id: BigInt(12345), + firstName: "Test", + }); + + vi.mocked(mockClient.getEntity).mockResolvedValue(mockUser); + + const result = await resolveEntity(mockClient as TelegramClient, "12345"); + + expect(result).toBe(mockUser); + expect(mockClient.getEntity).toHaveBeenCalledWith("12345"); + }); + + it("adds @ prefix automatically if first attempt fails", async () => { + const mockUser = new Api.User({ + id: BigInt(12345), + firstName: "Test", + }); + + vi.mocked(mockClient.getEntity) + .mockRejectedValueOnce(new Error("Not found")) + .mockResolvedValueOnce(mockUser); + + const result = await resolveEntity( + mockClient as TelegramClient, + "testuser", + ); + + expect(result).toBe(mockUser); + expect(mockClient.getEntity).toHaveBeenCalledTimes(2); + expect(mockClient.getEntity).toHaveBeenNthCalledWith(1, "testuser"); + expect(mockClient.getEntity).toHaveBeenNthCalledWith(2, "@testuser"); + }); + + it("does not add @ prefix if identifier already starts with @", async () => { + vi.mocked(mockClient.getEntity).mockRejectedValue(new Error("Not found")); + + await expect( + resolveEntity(mockClient as TelegramClient, "@testuser"), + ).rejects.toThrow("Could not resolve Telegram entity: @testuser"); + + expect(mockClient.getEntity).toHaveBeenCalledTimes(1); + expect(mockClient.getEntity).toHaveBeenCalledWith("@testuser"); + }); + + it("throws descriptive error when entity cannot be resolved", async () => { + vi.mocked(mockClient.getEntity).mockRejectedValue(new Error("Not found")); + + await expect( + resolveEntity(mockClient as TelegramClient, "unknown"), + ).rejects.toThrow( + "Could not resolve Telegram entity: unknown. Use @username, phone number (+1234567890), or user ID.", + ); + }); + + it("trims whitespace from identifier", async () => { + const mockUser = new Api.User({ + id: BigInt(12345), + firstName: "Test", + }); + + vi.mocked(mockClient.getEntity).mockResolvedValue(mockUser); + + await resolveEntity(mockClient as TelegramClient, " @testuser "); + + expect(mockClient.getEntity).toHaveBeenCalledWith("@testuser"); + }); + }); + + describe("extractUserId", () => { + it("extracts user ID from User entity as string", () => { + const mockUser = new Api.User({ + id: BigInt(12345), + firstName: "Test", + }); + + const userId = extractUserId(mockUser); + expect(userId).toBe("12345"); + }); + + it("extracts user ID from Chat entity as string", () => { + const mockChat = new Api.Chat({ + id: BigInt(67890), + title: "Test Chat", + }); + + const userId = extractUserId(mockChat); + expect(userId).toBe("67890"); + }); + + it("returns '0' for entity without id field", () => { + const mockEntity = {} as Api.User; + const userId = extractUserId(mockEntity); + expect(userId).toBe("0"); + }); + + it("returns '0' for entity with non-bigint id", () => { + const mockEntity = { id: "12345" } as unknown as Api.User; + const userId = extractUserId(mockEntity); + expect(userId).toBe("0"); + }); + + it("handles large user IDs without precision loss", () => { + const mockUser = new Api.User({ + id: BigInt("9007199254740992"), // MAX_SAFE_INTEGER + 1 + firstName: "Test", + }); + + const userId = extractUserId(mockUser); + expect(userId).toBe("9007199254740992"); + }); + }); +}); diff --git a/src/telegram/utils.ts b/src/telegram/utils.ts new file mode 100644 index 000000000..8428a4ea8 --- /dev/null +++ b/src/telegram/utils.ts @@ -0,0 +1,48 @@ +import type { Api, TelegramClient } from "telegram"; + +// Entity type - can be User, Chat, Channel, or their empty variants +type Entity = Api.User | Api.Chat | Api.Channel; + +/** + * Resolve Telegram entity (user/chat) from identifier. + * Supports @username, phone number, or user ID. + */ +export async function resolveEntity( + client: TelegramClient, + identifier: string, +): Promise { + // Clean identifier + const clean = identifier.trim(); + + // Try as-is first (handles @username, phone, user ID) + try { + return (await client.getEntity(clean)) as Entity; + } catch (_firstErr) { + // If not @ prefix, try adding it + if (!clean.startsWith("@")) { + try { + return (await client.getEntity(`@${clean}`)) as Entity; + } catch { + // Fall through to error + } + } + + throw new Error( + `Could not resolve Telegram entity: ${identifier}. ` + + "Use @username, phone number (+1234567890), or user ID.", + ); + } +} + +/** + * Extract user ID from entity as string to avoid precision loss. + * Telegram IDs are bigint and can exceed Number.MAX_SAFE_INTEGER. + */ +export function extractUserId(entity: Entity): string { + // Extract ID as unknown to bypass TypeScript's overly narrow type inference + const id = ("id" in entity ? entity.id : null) as unknown; + if (typeof id === "bigint") { + return id.toString(); + } + return "0"; +} diff --git a/src/twilio/client.ts b/src/twilio/client.ts index fbb5565b6..8d148ebdd 100644 --- a/src/twilio/client.ts +++ b/src/twilio/client.ts @@ -3,6 +3,12 @@ import type { EnvConfig } from "../env.js"; export function createClient(env: EnvConfig) { // Twilio client using either auth token or API key/secret. + if (!env.auth || !env.accountSid) { + throw new Error( + "Twilio credentials not configured. Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN (or TWILIO_API_KEY/TWILIO_API_SECRET) in .env", + ); + } + if ("authToken" in env.auth) { return Twilio(env.accountSid, env.auth.authToken, { accountSid: env.accountSid, diff --git a/src/twilio/messages.ts b/src/twilio/messages.ts index 72c27ca85..9a0de41d0 100644 --- a/src/twilio/messages.ts +++ b/src/twilio/messages.ts @@ -43,7 +43,7 @@ export async function listRecentMessages( ): Promise { const env = readEnv(); const client = clientOverride ?? createClient(env); - const from = withWhatsAppPrefix(env.whatsappFrom); + const from = withWhatsAppPrefix(env.whatsappFrom!); const since = new Date(Date.now() - lookbackMinutes * 60_000); // Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit. diff --git a/src/twilio/monitor.ts b/src/twilio/monitor.ts index 58bae951f..c7820e9fb 100644 --- a/src/twilio/monitor.ts +++ b/src/twilio/monitor.ts @@ -60,7 +60,7 @@ export async function monitorTwilio( let backoffMs = 1_000; const env = deps.readEnv(runtime); - const from = withWhatsAppPrefix(env.whatsappFrom); + const from = withWhatsAppPrefix(env.whatsappFrom!); const client = opts?.client ?? deps.createClient(env); logInfo( `📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`, diff --git a/src/twilio/send.ts b/src/twilio/send.ts index eb08e6567..0f02fa470 100644 --- a/src/twilio/send.ts +++ b/src/twilio/send.ts @@ -17,7 +17,7 @@ export async function sendMessage( ) { const env = readEnv(runtime); const client = createClient(env); - const from = withWhatsAppPrefix(env.whatsappFrom); + const from = withWhatsAppPrefix(env.whatsappFrom!); const toNumber = withWhatsAppPrefix(to); try { diff --git a/src/twilio/update-webhook.ts b/src/twilio/update-webhook.ts index 0d6dc2fc7..0a492fac9 100644 --- a/src/twilio/update-webhook.ts +++ b/src/twilio/update-webhook.ts @@ -11,7 +11,7 @@ export async function findIncomingNumberSid( // Look up incoming phone number SID matching the configured WhatsApp number. try { const env = readEnv(); - const phone = env.whatsappFrom.replace("whatsapp:", ""); + const phone = env.whatsappFrom!.replace("whatsapp:", ""); const list = await client.incomingPhoneNumbers.list({ phoneNumber: phone, limit: 1, @@ -29,7 +29,7 @@ export async function findMessagingServiceSid( type IncomingNumberWithService = { messagingServiceSid?: string }; try { const env = readEnv(); - const phone = env.whatsappFrom.replace("whatsapp:", ""); + const phone = env.whatsappFrom!.replace("whatsapp:", ""); const list = await client.incomingPhoneNumbers.list({ phoneNumber: phone, limit: 1, diff --git a/src/twilio/webhook.ts b/src/twilio/webhook.ts index e82970d10..a45f9e41d 100644 --- a/src/twilio/webhook.ts +++ b/src/twilio/webhook.ts @@ -151,6 +151,9 @@ export async function startWebhook( } function buildTwilioBasicAuth(env: EnvConfig) { + if (!env.auth || !env.accountSid) { + throw new Error("Twilio credentials not configured for webhook authentication"); + } if ("authToken" in env.auth) { return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString( "base64", diff --git a/src/utils.ts b/src/utils.ts index df6125d2c..05b6fcbab 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,14 +7,32 @@ export async function ensureDir(dir: string) { await fs.promises.mkdir(dir, { recursive: true }); } -export type Provider = "twilio" | "web"; +export type Provider = "twilio" | "web" | "telegram"; export function assertProvider(input: string): asserts input is Provider { - if (input !== "twilio" && input !== "web") { - throw new Error("Provider must be 'twilio' or 'web'"); + if (input !== "twilio" && input !== "web" && input !== "telegram") { + throw new Error("Provider must be 'web', 'twilio', or 'telegram'"); } } +export type AllowFromProvider = "telegram" | "web" | "twilio"; + +export function normalizeAllowFromEntry( + entry: string, + provider: AllowFromProvider, +): string { + const trimmed = entry.trim().toLowerCase(); + if (!trimmed) return ""; + + if (provider === "telegram") { + // Telegram uses @username format + return trimmed.startsWith("@") ? trimmed : `@${trimmed}`; + } + + // WhatsApp (both web and twilio) use E.164 phone numbers + return normalizeE164(entry); +} + export function normalizePath(p: string): string { if (!p.startsWith("/")) return `/${p}`; return p; diff --git a/src/web/session.ts b/src/web/session.ts index 38c185792..45ecb482f 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -15,10 +15,13 @@ import { SESSION_STORE_DEFAULT } from "../config/sessions.js"; import { danger, info, success } from "../globals.js"; import { getChildLogger } from "../logging.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { telegramAuthExists } from "../telegram/session.js"; import type { Provider } from "../utils.js"; import { CONFIG_DIR, ensureDir, jidToE164 } from "../utils.js"; import { VERSION } from "../version.js"; +export { telegramAuthExists }; + export const WA_WEB_AUTH_DIR = path.join(CONFIG_DIR, "credentials"); /** @@ -212,9 +215,15 @@ export function logWebSelfId( } export async function pickProvider(pref: Provider | "auto"): Promise { - // Auto-select web when logged in; otherwise fall back to twilio. + // Auto-select web when logged in; otherwise fall back to telegram or twilio. if (pref !== "auto") return pref; + + // Priority: web > telegram > twilio const hasWeb = await webAuthExists(); if (hasWeb) return "web"; + + const hasTelegram = await telegramAuthExists(); + if (hasTelegram) return "telegram"; + return "twilio"; }