/** * TextMe REST API Client * Base URL: https://api.textme-app.com */ const BASE_URL = 'https://api.textme-app.com'; // ============================================================================ // Error Types // ============================================================================ export class TextMeAPIError extends Error { constructor( public statusCode: number, public statusText: string, public body: unknown, public endpoint: string ) { super(`TextMe API Error [${statusCode}] ${statusText} at ${endpoint}`); this.name = 'TextMeAPIError'; } } // ============================================================================ // Request/Response Interfaces - Authentication // ============================================================================ export interface AuthTokenRequest { email: string; password: string; } export interface AuthTokenResponse { access: string; refresh: string; user_id?: number; } export interface AuthTokenRefreshRequest { refresh: string; } export interface AuthTokenRefreshResponse { access: string; refresh?: string; } export interface RegisterDeviceRequest { device_token: string; device_type: 'ios' | 'android' | 'web'; device_id?: string; app_version?: string; } export interface RegisterDeviceResponse { id: number; device_token: string; device_type: string; created_at: string; } // ============================================================================ // Request/Response Interfaces - User // ============================================================================ export interface UserInfo { id: number; email: string; username?: string; first_name?: string; last_name?: string; phone_number?: string; profile_picture?: string; is_verified: boolean; created_at: string; updated_at: string; } export interface UserSettings { notifications_enabled: boolean; sound_enabled: boolean; vibration_enabled: boolean; read_receipts: boolean; typing_indicators: boolean; auto_download_media: boolean; theme?: 'light' | 'dark' | 'system'; language?: string; timezone?: string; } export interface UpdateUserSettingsRequest extends Partial {} export interface ProfilePictureRequest { file: Blob | File; } export interface ProfilePictureResponse { url: string; thumbnail_url?: string; } // ============================================================================ // Request/Response Interfaces - Phone Numbers // ============================================================================ export interface AvailableCountry { code: string; name: string; dial_code: string; flag_emoji?: string; available_numbers_count?: number; } export interface AvailableCountriesResponse { countries: AvailableCountry[]; } export interface ChoosePhoneNumberRequest { country_code: string; area_code?: string; number?: string; // specific number if available } export interface PhoneNumber { id: number; number: string; country_code: string; is_primary: boolean; is_verified: boolean; capabilities: { sms: boolean; mms: boolean; voice: boolean; }; created_at: string; } export interface ChoosePhoneNumberResponse { phone_number: PhoneNumber; } // ============================================================================ // Request/Response Interfaces - Messaging // ============================================================================ export interface Participant { id: number; phone_number: string; name?: string; avatar_url?: string; } export interface Message { id: number; conversation_id: number; sender_id: number; body: string; type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'location'; status: 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; attachments?: Attachment[]; created_at: string; updated_at: string; read_at?: string; } export interface Conversation { id: number; type: 'direct' | 'group'; name?: string; participants: Participant[]; last_message?: Message; unread_count: number; is_muted: boolean; is_archived: boolean; created_at: string; updated_at: string; } export interface ListConversationsParams { limit?: number; offset?: number; archived?: boolean; } export interface ListConversationsResponse { conversations: Conversation[]; count: number; next?: string; previous?: string; } export interface SendMessageRequest { body: string; type?: 'text' | 'image' | 'video' | 'audio' | 'file' | 'location'; attachment_ids?: number[]; reply_to_id?: number; metadata?: Record; } export interface SendMessageResponse { message: Message; } // ============================================================================ // Request/Response Interfaces - Attachments // ============================================================================ export interface Attachment { id: number; url: string; thumbnail_url?: string; filename: string; content_type: string; size: number; duration?: number; // for audio/video width?: number; height?: number; created_at: string; } export interface GetUploadUrlRequest { filename: string; content_type: string; size: number; } export interface GetUploadUrlResponse { upload_url: string; attachment_id: number; expires_at: string; fields?: Record; // for S3-style signed uploads } export interface CreateAttachmentRequest { file: Blob | File; conversation_id?: number; } export interface CreateAttachmentResponse { attachment: Attachment; } // ============================================================================ // Request/Response Interfaces - Groups // ============================================================================ export interface Group { id: number; name: string; description?: string; avatar_url?: string; participants: Participant[]; admin_ids: number[]; conversation_id: number; created_at: string; updated_at: string; } export interface ListGroupsParams { limit?: number; offset?: number; } export interface ListGroupsResponse { groups: Group[]; count: number; next?: string; previous?: string; } export interface CreateGroupRequest { name: string; description?: string; participant_phone_numbers: string[]; avatar?: Blob | File; } export interface CreateGroupResponse { group: Group; } // ============================================================================ // Request/Response Interfaces - Calls // ============================================================================ export interface InitiateCallRequest { to_phone_number: string; from_phone_number_id?: number; type?: 'voice' | 'video'; } export interface Call { id: number; call_sid: string; from_number: string; to_number: string; type: 'voice' | 'video'; status: 'initiated' | 'ringing' | 'in-progress' | 'completed' | 'failed' | 'busy' | 'no-answer'; direction: 'outbound' | 'inbound'; duration?: number; started_at?: string; ended_at?: string; created_at: string; } export interface InitiateCallResponse { call: Call; token?: string; // WebRTC/Twilio token for client-side connection } // ============================================================================ // Auth Headers Type // ============================================================================ export interface AuthHeaders { Authorization: string; [key: string]: string; } // ============================================================================ // TextMe API Client // ============================================================================ export class TextMeAPI { private baseUrl: string; private headers: Record; constructor(authHeaders: AuthHeaders, baseUrl: string = BASE_URL) { this.baseUrl = baseUrl; this.headers = { 'Content-Type': 'application/json', ...authHeaders, }; } /** * Update authorization headers (e.g., after token refresh) */ setAuthHeaders(authHeaders: AuthHeaders): void { this.headers = { ...this.headers, ...authHeaders, }; } /** * Generic request handler with error handling */ private async request( method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', endpoint: string, body?: unknown, customHeaders?: Record ): Promise { const url = `${this.baseUrl}${endpoint}`; const headers = { ...this.headers, ...customHeaders }; const options: RequestInit = { method, headers, }; if (body !== undefined && method !== 'GET') { if (body instanceof FormData) { // Remove Content-Type for FormData (browser sets it with boundary) delete (options.headers as Record)['Content-Type']; options.body = body; } else { options.body = JSON.stringify(body); } } const response = await fetch(url, options); if (!response.ok) { let errorBody: unknown; try { errorBody = await response.json(); } catch { errorBody = await response.text(); } throw new TextMeAPIError( response.status, response.statusText, errorBody, endpoint ); } // Handle empty responses if (response.status === 204) { return {} as T; } return response.json() as Promise; } // ========================================================================== // Authentication Endpoints // ========================================================================== /** * Login with email and password * Note: This is typically called without auth headers */ static async login( credentials: AuthTokenRequest, baseUrl: string = BASE_URL ): Promise { const response = await fetch(`${baseUrl}/api/auth-token/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); if (!response.ok) { const errorBody = await response.json().catch(() => response.text()); throw new TextMeAPIError( response.status, response.statusText, errorBody, '/api/auth-token/' ); } return response.json() as Promise; } /** * Refresh access token * Note: This can be called without auth headers */ static async refreshToken( request: AuthTokenRefreshRequest, baseUrl: string = BASE_URL ): Promise { const response = await fetch(`${baseUrl}/api/auth-token-refresh/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }); if (!response.ok) { const errorBody = await response.json().catch(() => response.text()); throw new TextMeAPIError( response.status, response.statusText, errorBody, '/api/auth-token-refresh/' ); } return response.json() as Promise; } /** * Register a device for push notifications */ async registerDevice( request: RegisterDeviceRequest ): Promise { return this.request( 'POST', '/api/register-device/', request ); } // ========================================================================== // User Endpoints // ========================================================================== /** * Get current user information */ async getUserInfo(): Promise { return this.request('GET', '/api/user-info/'); } /** * Get user settings */ async getSettings(): Promise { return this.request('GET', '/api/settings/'); } /** * Update user settings */ async updateSettings( settings: UpdateUserSettingsRequest ): Promise { return this.request('PUT', '/api/settings/', settings); } /** * Upload profile picture */ async uploadProfilePicture(file: Blob | File): Promise { const formData = new FormData(); formData.append('file', file); return this.request( 'POST', '/api/profile-picture/', formData ); } // ========================================================================== // Phone Number Endpoints // ========================================================================== /** * Get available countries for phone numbers */ async getAvailableCountries(): Promise { return this.request( 'GET', '/api/phone-number/available-countries/' ); } /** * Choose/claim a phone number */ async choosePhoneNumber( request: ChoosePhoneNumberRequest ): Promise { return this.request( 'POST', '/api/phone-number/choose/', request ); } // ========================================================================== // Messaging Endpoints // ========================================================================== /** * List conversations */ async listConversations( params?: ListConversationsParams ): Promise { const queryParams = new URLSearchParams(); if (params?.limit) queryParams.set('limit', params.limit.toString()); if (params?.offset) queryParams.set('offset', params.offset.toString()); if (params?.archived !== undefined) queryParams.set('archived', params.archived.toString()); const query = queryParams.toString(); const endpoint = `/api/conversation/${query ? `?${query}` : ''}`; return this.request('GET', endpoint); } /** * Get a specific conversation */ async getConversation(conversationId: number): Promise { return this.request( 'GET', `/api/conversation/${conversationId}/` ); } /** * Send a message to a conversation */ async sendMessage( conversationId: number, request: SendMessageRequest ): Promise { return this.request( 'POST', `/api/conversation/${conversationId}/message/`, request ); } /** * Get messages in a conversation */ async getMessages( conversationId: number, params?: { limit?: number; before?: number; after?: number } ): Promise<{ messages: Message[]; count: number }> { const queryParams = new URLSearchParams(); if (params?.limit) queryParams.set('limit', params.limit.toString()); if (params?.before) queryParams.set('before', params.before.toString()); if (params?.after) queryParams.set('after', params.after.toString()); const query = queryParams.toString(); const endpoint = `/api/conversation/${conversationId}/message/${query ? `?${query}` : ''}`; return this.request<{ messages: Message[]; count: number }>('GET', endpoint); } // ========================================================================== // Attachment Endpoints // ========================================================================== /** * Get a presigned upload URL for an attachment */ async getAttachmentUploadUrl( request: GetUploadUrlRequest ): Promise { return this.request( 'POST', '/api/attachment/get-upload-url/', request ); } /** * Create/upload an attachment directly */ async createAttachment( file: Blob | File, conversationId?: number ): Promise { const formData = new FormData(); formData.append('file', file); if (conversationId) { formData.append('conversation_id', conversationId.toString()); } return this.request( 'POST', '/api/attachment/', formData ); } /** * Upload file to presigned URL (helper method) */ async uploadToPresignedUrl( uploadUrl: string, file: Blob | File, fields?: Record ): Promise { const formData = new FormData(); // Add any required fields (for S3-style uploads) if (fields) { Object.entries(fields).forEach(([key, value]) => { formData.append(key, value); }); } formData.append('file', file); const response = await fetch(uploadUrl, { method: 'POST', body: formData, }); if (!response.ok) { throw new TextMeAPIError( response.status, response.statusText, await response.text(), uploadUrl ); } } // ========================================================================== // Group Endpoints // ========================================================================== /** * List groups */ async listGroups(params?: ListGroupsParams): Promise { const queryParams = new URLSearchParams(); if (params?.limit) queryParams.set('limit', params.limit.toString()); if (params?.offset) queryParams.set('offset', params.offset.toString()); const query = queryParams.toString(); const endpoint = `/api/group/${query ? `?${query}` : ''}`; return this.request('GET', endpoint); } /** * Get a specific group */ async getGroup(groupId: number): Promise { return this.request('GET', `/api/group/${groupId}/`); } /** * Create a new group */ async createGroup(request: CreateGroupRequest): Promise { if (request.avatar) { const formData = new FormData(); formData.append('name', request.name); if (request.description) { formData.append('description', request.description); } request.participant_phone_numbers.forEach((phone, index) => { formData.append(`participant_phone_numbers[${index}]`, phone); }); formData.append('avatar', request.avatar); return this.request('POST', '/api/group/', formData); } return this.request('POST', '/api/group/', { name: request.name, description: request.description, participant_phone_numbers: request.participant_phone_numbers, }); } /** * Update a group */ async updateGroup( groupId: number, updates: Partial> ): Promise { return this.request('PUT', `/api/group/${groupId}/`, updates); } /** * Add participant to group */ async addGroupParticipant( groupId: number, phoneNumber: string ): Promise { return this.request( 'POST', `/api/group/${groupId}/participants/`, { phone_number: phoneNumber } ); } /** * Remove participant from group */ async removeGroupParticipant( groupId: number, participantId: number ): Promise { return this.request( 'DELETE', `/api/group/${groupId}/participants/${participantId}/` ); } // ========================================================================== // Call Endpoints // ========================================================================== /** * Initiate a call */ async initiateCall(request: InitiateCallRequest): Promise { return this.request('POST', '/api/call/', request); } /** * Get call details */ async getCall(callId: number): Promise { return this.request('GET', `/api/call/${callId}/`); } /** * End a call */ async endCall(callId: number): Promise { return this.request('POST', `/api/call/${callId}/end/`); } } // ============================================================================ // Factory function for convenience // ============================================================================ /** * Create an authenticated TextMeAPI client */ export function createTextMeClient( accessToken: string, baseUrl?: string ): TextMeAPI { return new TextMeAPI( { Authorization: `Bearer ${accessToken}` }, baseUrl ); } /** * Create a TextMeAPI client from login credentials */ export async function createTextMeClientFromCredentials( email: string, password: string, baseUrl?: string ): Promise<{ client: TextMeAPI; tokens: AuthTokenResponse }> { const tokens = await TextMeAPI.login({ email, password }, baseUrl); const client = new TextMeAPI( { Authorization: `Bearer ${tokens.access}` }, baseUrl ); return { client, tokens }; } export default TextMeAPI;