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

813 lines
20 KiB
TypeScript

/**
* 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<UserSettings> {}
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<string, unknown>;
}
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<string, string>; // 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<string, string>;
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<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
endpoint: string,
body?: unknown,
customHeaders?: Record<string, string>
): Promise<T> {
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<string, string>)['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<T>;
}
// ==========================================================================
// 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<AuthTokenResponse> {
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<AuthTokenResponse>;
}
/**
* Refresh access token
* Note: This can be called without auth headers
*/
static async refreshToken(
request: AuthTokenRefreshRequest,
baseUrl: string = BASE_URL
): Promise<AuthTokenRefreshResponse> {
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<AuthTokenRefreshResponse>;
}
/**
* Register a device for push notifications
*/
async registerDevice(
request: RegisterDeviceRequest
): Promise<RegisterDeviceResponse> {
return this.request<RegisterDeviceResponse>(
'POST',
'/api/register-device/',
request
);
}
// ==========================================================================
// User Endpoints
// ==========================================================================
/**
* Get current user information
*/
async getUserInfo(): Promise<UserInfo> {
return this.request<UserInfo>('GET', '/api/user-info/');
}
/**
* Get user settings
*/
async getSettings(): Promise<UserSettings> {
return this.request<UserSettings>('GET', '/api/settings/');
}
/**
* Update user settings
*/
async updateSettings(
settings: UpdateUserSettingsRequest
): Promise<UserSettings> {
return this.request<UserSettings>('PUT', '/api/settings/', settings);
}
/**
* Upload profile picture
*/
async uploadProfilePicture(file: Blob | File): Promise<ProfilePictureResponse> {
const formData = new FormData();
formData.append('file', file);
return this.request<ProfilePictureResponse>(
'POST',
'/api/profile-picture/',
formData
);
}
// ==========================================================================
// Phone Number Endpoints
// ==========================================================================
/**
* Get available countries for phone numbers
*/
async getAvailableCountries(): Promise<AvailableCountriesResponse> {
return this.request<AvailableCountriesResponse>(
'GET',
'/api/phone-number/available-countries/'
);
}
/**
* Choose/claim a phone number
*/
async choosePhoneNumber(
request: ChoosePhoneNumberRequest
): Promise<ChoosePhoneNumberResponse> {
return this.request<ChoosePhoneNumberResponse>(
'POST',
'/api/phone-number/choose/',
request
);
}
// ==========================================================================
// Messaging Endpoints
// ==========================================================================
/**
* List conversations
*/
async listConversations(
params?: ListConversationsParams
): Promise<ListConversationsResponse> {
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<ListConversationsResponse>('GET', endpoint);
}
/**
* Get a specific conversation
*/
async getConversation(conversationId: number): Promise<Conversation> {
return this.request<Conversation>(
'GET',
`/api/conversation/${conversationId}/`
);
}
/**
* Send a message to a conversation
*/
async sendMessage(
conversationId: number,
request: SendMessageRequest
): Promise<SendMessageResponse> {
return this.request<SendMessageResponse>(
'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<GetUploadUrlResponse> {
return this.request<GetUploadUrlResponse>(
'POST',
'/api/attachment/get-upload-url/',
request
);
}
/**
* Create/upload an attachment directly
*/
async createAttachment(
file: Blob | File,
conversationId?: number
): Promise<CreateAttachmentResponse> {
const formData = new FormData();
formData.append('file', file);
if (conversationId) {
formData.append('conversation_id', conversationId.toString());
}
return this.request<CreateAttachmentResponse>(
'POST',
'/api/attachment/',
formData
);
}
/**
* Upload file to presigned URL (helper method)
*/
async uploadToPresignedUrl(
uploadUrl: string,
file: Blob | File,
fields?: Record<string, string>
): Promise<void> {
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<ListGroupsResponse> {
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<ListGroupsResponse>('GET', endpoint);
}
/**
* Get a specific group
*/
async getGroup(groupId: number): Promise<Group> {
return this.request<Group>('GET', `/api/group/${groupId}/`);
}
/**
* Create a new group
*/
async createGroup(request: CreateGroupRequest): Promise<CreateGroupResponse> {
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<CreateGroupResponse>('POST', '/api/group/', formData);
}
return this.request<CreateGroupResponse>('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<Pick<Group, 'name' | 'description'>>
): Promise<Group> {
return this.request<Group>('PUT', `/api/group/${groupId}/`, updates);
}
/**
* Add participant to group
*/
async addGroupParticipant(
groupId: number,
phoneNumber: string
): Promise<Group> {
return this.request<Group>(
'POST',
`/api/group/${groupId}/participants/`,
{ phone_number: phoneNumber }
);
}
/**
* Remove participant from group
*/
async removeGroupParticipant(
groupId: number,
participantId: number
): Promise<void> {
return this.request<void>(
'DELETE',
`/api/group/${groupId}/participants/${participantId}/`
);
}
// ==========================================================================
// Call Endpoints
// ==========================================================================
/**
* Initiate a call
*/
async initiateCall(request: InitiateCallRequest): Promise<InitiateCallResponse> {
return this.request<InitiateCallResponse>('POST', '/api/call/', request);
}
/**
* Get call details
*/
async getCall(callId: number): Promise<Call> {
return this.request<Call>('GET', `/api/call/${callId}/`);
}
/**
* End a call
*/
async endCall(callId: number): Promise<Call> {
return this.request<Call>('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;