2026-01-28 23:00:58 -05:00

536 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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<void> {
if (!existsSync(CREDENTIALS_DIR)) {
await mkdir(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
}
}
async function saveCredentials(token: string, expiresAt?: number): Promise<void> {
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<StoredCredentials | null> {
// 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<boolean> {
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<TextMeAPI> {
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<string> {
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 <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 <n>', '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 <conversation-id>')
.description('Read messages from a conversation')
.option('-n, --limit <n>', '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 <conversation-id> <message>')
.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 <ids>', '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();