#!/usr/bin/env node /** * TextMe CLI * Command-line interface for TextMe messaging service */ import { Command } from 'commander'; import { homedir } from 'os'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; import qrcode from 'qrcode-terminal'; import { TextMeAuth, TextMeAPI, TextMeRealtime, createTextMeClient } from '../src/index.js'; import type { Conversation, Message, UserInfo, IncomingMessage } from '../src/index.js'; // ============================================================================ // Credentials Management // ============================================================================ interface StoredCredentials { token: string; expiresAt?: number; savedAt: number; } const CREDENTIALS_DIR = join(homedir(), '.textme'); const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'credentials.json'); const CAPTURED_TOKEN_FILE = join(CREDENTIALS_DIR, 'captured-token.json'); async function ensureCredentialsDir(): Promise { if (!existsSync(CREDENTIALS_DIR)) { await mkdir(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); } } async function saveCredentials(token: string, expiresAt?: number): Promise { await ensureCredentialsDir(); const credentials: StoredCredentials = { token, expiresAt, savedAt: Date.now(), }; await writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 }); } async function loadCredentials(): Promise { // First try the main credentials file try { const data = await readFile(CREDENTIALS_FILE, 'utf-8'); return JSON.parse(data) as StoredCredentials; } catch { // Fall through to check captured token } // Check for captured token from mitmproxy try { const data = await readFile(CAPTURED_TOKEN_FILE, 'utf-8'); const captured = JSON.parse(data) as { token: string; captured_at?: string }; if (captured.token) { console.log('šŸ“± Using token captured from mobile app'); return { token: captured.token, savedAt: Date.now(), }; } } catch { // No captured token either } return null; } async function importCapturedToken(): Promise { try { const data = await readFile(CAPTURED_TOKEN_FILE, 'utf-8'); const captured = JSON.parse(data) as { token: string; captured_at?: string }; if (captured.token) { await saveCredentials(captured.token); return true; } } catch { return false; } return false; } async function getAuthenticatedClient(): Promise { const credentials = await loadCredentials(); if (!credentials) { console.error('āŒ Not authenticated. Run `textme auth` first.'); process.exit(1); } // Check if token might be expired if (credentials.expiresAt && Date.now() > credentials.expiresAt) { console.warn('āš ļø Token may be expired. Run `textme auth` to re-authenticate.'); } return createTextMeClient(credentials.token); } async function getToken(): Promise { const credentials = await loadCredentials(); if (!credentials) { console.error('āŒ Not authenticated. Run `textme auth` first.'); process.exit(1); } return credentials.token; } // ============================================================================ // CLI Helpers // ============================================================================ function formatTimestamp(dateStr: string): string { const date = new Date(dateStr); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); if (isToday) { return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } function truncate(text: string, maxLength: number): string { if (text.length <= maxLength) return text; return text.slice(0, maxLength - 1) + '…'; } function getConversationName(conv: Conversation): string { if (conv.name) return conv.name; if (conv.participants.length === 0) return 'Unknown'; if (conv.participants.length === 1) { return conv.participants[0].name || conv.participants[0].phone_number; } return conv.participants.map(p => p.name || p.phone_number).join(', '); } // ============================================================================ // Commands // ============================================================================ const program = new Command(); program .name('textme') .description('TextMe CLI - Send and receive messages from the terminal') .version('0.1.0'); // Auth command program .command('auth') .description('Authenticate via QR code scan') .action(async () => { console.log('šŸ” TextMe Authentication\n'); // Check if there's already a captured token const existing = await loadCredentials(); if (existing) { console.log('ā„¹ļø Found existing token. Use `textme whoami` to verify or continue to re-authenticate.\n'); } const auth = new TextMeAuth(); const sessionId = auth.generateQRSession(); // Generate QR code URL for mobile app to scan const qrData = `textme://auth/${sessionId}`; console.log('Scan this QR code with the TextMe mobile app:\n'); qrcode.generate(qrData, { small: true }); console.log('\nWaiting for scan... (timeout: 2 minutes)'); console.log('āš ļø If this fails, use `textme import` with mitmproxy capture instead.\n'); try { const token = await auth.waitForQRAuth(sessionId); // Get token expiry (default 24h from now if not available) const expiresAt = Date.now() + (24 * 60 * 60 * 1000); await saveCredentials(token, expiresAt); console.log('āœ… Authentication successful!'); console.log(` Token saved to ${CREDENTIALS_FILE}`); // Try to get user info to confirm try { const client = createTextMeClient(token); const user = await client.getUserInfo(); console.log(`\nšŸ‘¤ Logged in as: ${user.first_name || user.username || user.email}`); if (user.phone_number) { console.log(` Phone: ${user.phone_number}`); } } catch { // Ignore - user info is optional } } catch (err) { if (err instanceof Error) { if (err.message.includes('timeout')) { console.error('\nā° QR scan timed out. Please try again.'); } else if (err.message.includes('expired')) { console.error('\nā° Session expired. Please try again.'); } else { console.error(`\nāŒ Authentication failed: ${err.message}`); } } else { console.error('\nāŒ Authentication failed'); } process.exit(1); } }); // Import command - import captured token from mitmproxy program .command('import') .description('Import token captured from mobile app via mitmproxy') .option('-t, --token ', 'Manually provide a JWT token') .action(async (options) => { console.log('šŸ“± Token Import\n'); if (options.token) { // Manual token provided await saveCredentials(options.token); console.log('āœ… Token saved!'); console.log(' Run `textme whoami` to verify.\n'); return; } // Try to import from captured-token.json const imported = await importCapturedToken(); if (imported) { console.log('āœ… Imported token from captured-token.json'); console.log(` Saved to ${CREDENTIALS_FILE}`); console.log('\n Run `textme whoami` to verify.\n'); } else { console.log('āŒ No captured token found.'); console.log('\nTo capture a token from the mobile app:'); console.log(' 1. Run: mitmproxy -s scripts/capture-token.py'); console.log(' 2. Configure your phone to use the proxy'); console.log(' 3. Open TextMe app on your phone'); console.log(' 4. Token will be saved to ~/.textme/captured-token.json'); console.log('\nOr manually provide a token:'); console.log(' textme import --token YOUR_JWT_TOKEN\n'); process.exit(1); } }); // Whoami command program .command('whoami') .description('Display current user information') .action(async () => { try { const client = await getAuthenticatedClient(); const user: UserInfo = await client.getUserInfo(); console.log('\nšŸ‘¤ User Information\n'); console.log(` ID: ${user.id}`); console.log(` Email: ${user.email}`); if (user.username) console.log(` Username: ${user.username}`); if (user.first_name || user.last_name) { console.log(` Name: ${[user.first_name, user.last_name].filter(Boolean).join(' ')}`); } if (user.phone_number) console.log(` Phone: ${user.phone_number}`); console.log(` Verified: ${user.is_verified ? 'āœ“' : 'āœ—'}`); console.log(` Created: ${formatTimestamp(user.created_at)}`); console.log(); } catch (err) { if (err instanceof Error) { console.error(`āŒ Failed to get user info: ${err.message}`); } else { console.error('āŒ Failed to get user info'); } process.exit(1); } }); // List command program .command('list') .description('List conversations') .option('-a, --archived', 'Show archived conversations') .option('-l, --limit ', 'Number of conversations to show', '20') .action(async (options) => { try { const client = await getAuthenticatedClient(); const response = await client.listConversations({ limit: parseInt(options.limit, 10), archived: options.archived, }); if (response.conversations.length === 0) { console.log('\nšŸ“­ No conversations found.\n'); return; } console.log('\nšŸ“¬ Conversations\n'); console.log(' ID Unread Name / Participants'); console.log(' ────── ────── ────────────────────────────────────────'); for (const conv of response.conversations) { const name = getConversationName(conv); const unreadBadge = conv.unread_count > 0 ? `(${conv.unread_count})`.padEnd(6) : ' '; const archiveBadge = conv.is_archived ? ' [archived]' : ''; const muteBadge = conv.is_muted ? ' [muted]' : ''; console.log(` ${String(conv.id).padEnd(7)} ${unreadBadge} ${truncate(name, 40)}${archiveBadge}${muteBadge}`); if (conv.last_message) { const preview = truncate(conv.last_message.body, 50).replace(/\n/g, ' '); const time = formatTimestamp(conv.last_message.created_at); console.log(` └─ ${preview} (${time})`); } } console.log(`\n Showing ${response.conversations.length} of ${response.count} conversations\n`); } catch (err) { if (err instanceof Error) { console.error(`āŒ Failed to list conversations: ${err.message}`); } else { console.error('āŒ Failed to list conversations'); } process.exit(1); } }); // Read command program .command('read ') .description('Read messages from a conversation') .option('-n, --limit ', 'Number of messages to show', '20') .action(async (conversationId: string, options) => { try { const client = await getAuthenticatedClient(); const convId = parseInt(conversationId, 10); if (isNaN(convId)) { console.error('āŒ Invalid conversation ID. Must be a number.'); process.exit(1); } // Get conversation details const conv = await client.getConversation(convId); const messages = await client.getMessages(convId, { limit: parseInt(options.limit, 10), }); console.log(`\nšŸ’¬ ${getConversationName(conv)}\n`); if (messages.messages.length === 0) { console.log(' No messages yet.\n'); return; } // Display messages in chronological order (oldest first) const sortedMessages = [...messages.messages].reverse(); for (const msg of sortedMessages) { const sender = conv.participants.find(p => p.id === msg.sender_id); const senderName = sender?.name || sender?.phone_number || 'You'; const time = formatTimestamp(msg.created_at); const statusIcon = getStatusIcon(msg.status); console.log(` [${time}] ${senderName} ${statusIcon}`); // Handle multi-line messages const lines = msg.body.split('\n'); for (const line of lines) { console.log(` ${line}`); } // Show attachments if (msg.attachments && msg.attachments.length > 0) { for (const att of msg.attachments) { console.log(` šŸ“Ž ${att.filename} (${att.content_type})`); } } console.log(); } console.log(` Showing ${messages.messages.length} messages\n`); } catch (err) { if (err instanceof Error) { console.error(`āŒ Failed to read messages: ${err.message}`); } else { console.error('āŒ Failed to read messages'); } process.exit(1); } }); function getStatusIcon(status: Message['status']): string { switch (status) { case 'pending': return 'ā³'; case 'sent': return 'āœ“'; case 'delivered': return 'āœ“āœ“'; case 'read': return 'āœ“āœ“'; case 'failed': return 'āŒ'; default: return ''; } } // Send command program .command('send ') .description('Send a message to a conversation') .action(async (conversationId: string, message: string) => { try { const client = await getAuthenticatedClient(); const convId = parseInt(conversationId, 10); if (isNaN(convId)) { console.error('āŒ Invalid conversation ID. Must be a number.'); process.exit(1); } const response = await client.sendMessage(convId, { body: message, type: 'text', }); console.log(`\nāœ… Message sent!`); console.log(` ID: ${response.message.id}`); console.log(` Status: ${response.message.status}`); console.log(); } catch (err) { if (err instanceof Error) { console.error(`āŒ Failed to send message: ${err.message}`); } else { console.error('āŒ Failed to send message'); } process.exit(1); } }); // Watch command program .command('watch') .description('Watch for incoming messages in real-time') .option('-c, --conversation ', 'Comma-separated conversation IDs to watch') .action(async (options) => { try { const token = await getToken(); const realtime = new TextMeRealtime(); console.log('\nšŸ‘ļø Watching for messages... (Ctrl+C to stop)\n'); // Set up event handlers realtime.on('connected', () => { console.log('🟢 Connected to TextMe realtime\n'); }); realtime.on('disconnected', ({ reason }) => { console.log(`šŸ”“ Disconnected: ${reason}`); }); realtime.on('message', (msg: IncomingMessage) => { const time = msg.timestamp.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit' }); console.log(`[${time}] šŸ’¬ ${msg.senderName || msg.senderId}`); console.log(` Conversation: ${msg.conversationId}`); console.log(` ${msg.text}`); if (msg.mediaUrl) { console.log(` šŸ“Ž ${msg.mediaType}: ${msg.mediaUrl}`); } console.log(); }); realtime.on('typing', (event) => { const indicator = event.isTyping ? 'āœļø typing...' : ' stopped typing'; console.log(` [${event.conversationId}] ${event.userId} ${indicator}`); }); realtime.on('call', (event) => { const callType = event.type === 'incoming' ? 'šŸ“ž Incoming call' : event.type === 'ended' ? 'šŸ““ Call ended' : event.type === 'missed' ? 'šŸ“µ Missed call' : 'šŸ“± Call'; console.log(` ${callType} from ${event.callerName || event.callerId}`); if (event.duration) { console.log(` Duration: ${event.duration}s`); } console.log(); }); realtime.on('error', (err) => { console.error(`āŒ Error: ${err.message}`); }); // Connect await realtime.connect(token); // Subscribe to specific conversations if provided if (options.conversation) { const ids = options.conversation.split(',').map((s: string) => s.trim()); for (const id of ids) { realtime.subscribe(id); console.log(` Subscribed to conversation ${id}`); } console.log(); } // Handle graceful shutdown const shutdown = () => { console.log('\n\nšŸ‘‹ Disconnecting...'); realtime.disconnect(); process.exit(0); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); // Keep process alive await new Promise(() => {}); } catch (err) { if (err instanceof Error) { console.error(`āŒ Failed to connect: ${err.message}`); } else { console.error('āŒ Failed to connect'); } process.exit(1); } }); // Parse and run program.parse();