813 lines
20 KiB
TypeScript
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;
|