@longears-mobile/rcs-sdk
Version:
Provider-agnostic SDK for RCS (Rich Communication Services) messaging
807 lines (792 loc) • 29.1 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var pino = require('pino');
var axios = require('axios');
var crypto = require('crypto');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var pino__default = /*#__PURE__*/_interopDefaultLegacy(pino);
var axios__default = /*#__PURE__*/_interopDefaultLegacy(axios);
var crypto__default = /*#__PURE__*/_interopDefaultLegacy(crypto);
exports.RCSErrorCode = void 0;
(function (RCSErrorCode) {
// General errors
RCSErrorCode["UNKNOWN"] = "UNKNOWN";
RCSErrorCode["INITIALIZATION_FAILED"] = "INITIALIZATION_FAILED";
RCSErrorCode["NOT_INITIALIZED"] = "NOT_INITIALIZED";
// Provider errors
RCSErrorCode["PROVIDER_NOT_FOUND"] = "PROVIDER_NOT_FOUND";
RCSErrorCode["PROVIDER_ERROR"] = "PROVIDER_ERROR";
// Authentication errors
RCSErrorCode["AUTH_FAILED"] = "AUTH_FAILED";
RCSErrorCode["AUTH_EXPIRED"] = "AUTH_EXPIRED";
RCSErrorCode["AUTH_INVALID"] = "AUTH_INVALID";
// Message errors
RCSErrorCode["MESSAGE_SEND_FAILED"] = "MESSAGE_SEND_FAILED";
RCSErrorCode["MESSAGE_TOO_LONG"] = "MESSAGE_TOO_LONG";
RCSErrorCode["INVALID_CONTENT"] = "INVALID_CONTENT";
// Phone number errors
RCSErrorCode["INVALID_PHONE_NUMBER"] = "INVALID_PHONE_NUMBER";
RCSErrorCode["RCS_NOT_SUPPORTED"] = "RCS_NOT_SUPPORTED";
// Capability errors
RCSErrorCode["CAPABILITY_CHECK_FAILED"] = "CAPABILITY_CHECK_FAILED";
RCSErrorCode["FEATURE_NOT_SUPPORTED"] = "FEATURE_NOT_SUPPORTED";
// Network errors
RCSErrorCode["NETWORK_ERROR"] = "NETWORK_ERROR";
RCSErrorCode["TIMEOUT"] = "TIMEOUT";
RCSErrorCode["RATE_LIMIT_EXCEEDED"] = "RATE_LIMIT_EXCEEDED";
// Validation errors
RCSErrorCode["VALIDATION_FAILED"] = "VALIDATION_FAILED";
RCSErrorCode["INVALID_CONFIGURATION"] = "INVALID_CONFIGURATION";
})(exports.RCSErrorCode || (exports.RCSErrorCode = {}));
class RCSError extends Error {
code;
provider;
details;
timestamp;
constructor(message, code = exports.RCSErrorCode.UNKNOWN, provider, details) {
super(message);
this.name = 'RCSError';
this.code = code;
this.provider = provider;
this.details = details;
this.timestamp = new Date();
// Ensure proper prototype chain
Object.setPrototypeOf(this, RCSError.prototype);
}
toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
provider: this.provider,
details: this.details,
timestamp: this.timestamp,
stack: this.stack
};
}
static fromProviderError(provider, error) {
// Map provider-specific errors to RCS error codes
let code = exports.RCSErrorCode.PROVIDER_ERROR;
let message = error.message || 'Provider error occurred';
// Generic HTTP status code mapping
if (error.code) {
switch (error.code) {
case 404:
code = exports.RCSErrorCode.RCS_NOT_SUPPORTED;
message = 'Phone number does not support RCS';
break;
case 401:
case 403:
code = exports.RCSErrorCode.AUTH_FAILED;
message = 'Authentication failed';
break;
case 429:
code = exports.RCSErrorCode.RATE_LIMIT_EXCEEDED;
message = 'Rate limit exceeded';
break;
case 400:
if (error.message?.includes('phone')) {
code = exports.RCSErrorCode.INVALID_PHONE_NUMBER;
}
else if (error.message?.includes('content')) {
code = exports.RCSErrorCode.INVALID_CONTENT;
}
break;
}
}
return new RCSError(message, code, provider, error);
}
static isRCSError(error) {
return error instanceof RCSError;
}
}
const level = process.env.LOG_LEVEL || 'info';
const isDevelopment = process.env.NODE_ENV === 'development';
const logger = pino__default["default"]({
level,
transport: isDevelopment ? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss',
ignore: 'pid,hostname',
},
} : undefined,
formatters: {
level: (label) => {
return { level: label };
},
},
base: {
sdk: '@longears-mobile/rcs-sdk',
},
});
class BaseProvider {
auth;
config;
http;
constructor(auth, config) {
this.auth = auth;
this.config = config;
// Create axios instance with default config
this.http = axios__default["default"].create({
timeout: config.timeout || 30000,
headers: {
'Content-Type': 'application/json',
'User-Agent': `-mobile/rcs-sdk/${process.env.npm_package_version || '0.1.0'}`
}
});
// Add request interceptor for authentication
this.http.interceptors.request.use(async (config) => {
try {
// Get auth token
const token = await this.auth.authenticate();
// Add auth header based on token type
if (token.type && token.type === 'Bearer') {
config.headers.Authorization = `Bearer ${token.token}`;
}
else if (token.type && token.type === 'Basic') {
config.headers.Authorization = `Basic ${token.token}`;
}
else {
config.headers.Authorization = token.token;
}
return config;
}
catch (error) {
logger.error('Authentication failed:', error);
throw new RCSError('Failed to authenticate request', exports.RCSErrorCode.AUTH_FAILED, this.name, error);
}
});
// Add response interceptor for error handling
this.http.interceptors.response.use((response) => response, (error) => {
logger.error(`HTTP request failed for ${this.name} provider:`, error);
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
const status = error.response.status;
const data = error.response.data;
switch (status) {
case 401:
case 403:
throw new RCSError('Authentication failed', exports.RCSErrorCode.AUTH_FAILED, this.name, { status, data });
case 404:
throw new RCSError('Resource not found', exports.RCSErrorCode.PROVIDER_ERROR, this.name, { status, data });
case 429:
throw new RCSError('Rate limit exceeded', exports.RCSErrorCode.RATE_LIMIT_EXCEEDED, this.name, { status, data });
default:
throw RCSError.fromProviderError(this.name, {
code: status,
message: data?.error || 'Provider request failed',
details: data
});
}
}
else if (error.request) {
// The request was made but no response was received
throw new RCSError('No response received from provider', exports.RCSErrorCode.NETWORK_ERROR, this.name, error);
}
else {
// Something happened in setting up the request that triggered an Error
throw new RCSError('Request setup failed', exports.RCSErrorCode.PROVIDER_ERROR, this.name, error);
}
});
}
/**
* Make an authenticated HTTP request
*/
async makeRequest(url, options = {}) {
try {
const response = await this.http.request({
url,
...options
});
return response.data;
}
catch (error) {
// Axios interceptors will handle this error
throw error;
}
}
}
// Phone number validation regex patterns
const PHONE_PATTERNS = {
E164: /^\+[1-9]\d{1,14}$/,
US: /^(\+1)?[2-9]\d{2}[2-9](?!11)\d{6}$/,
GENERAL: /^[\d\s\-\(\)\+]+$/
};
/**
* Validates if a phone number is in E.164 format
*/
function isValidE164(phoneNumber) {
return PHONE_PATTERNS.E164.test(phoneNumber);
}
/**
* Formats a phone number to E.164 format
*/
function formatPhoneNumber(phoneNumber, countryCode = 'US') {
// Remove all non-digit characters except +
const cleaned = phoneNumber.replace(/[^\d+]/g, '');
// If already in E.164 format, return as is
if (isValidE164(cleaned)) {
return cleaned;
}
// Handle US numbers
if (countryCode === 'US') {
const digitsOnly = cleaned.replace(/^\+/, '');
// US number without country code
if (digitsOnly.length === 10) {
return `+1${digitsOnly}`;
}
// US number with country code
if (digitsOnly.length === 11 && digitsOnly.startsWith('1')) {
return `+${digitsOnly}`;
}
}
return null;
}
/**
* Message builder for creating RCS messages
*/
class MessageBuilder {
message = {};
constructor(to) {
if (to) {
this.message.to = to;
}
this.message.content = {};
}
to(phoneNumber) {
this.message.to = phoneNumber;
return this;
}
setText(text) {
if (!this.message.content) {
this.message.content = {};
}
this.message.content.text = text;
return this;
}
addMedia(url, type, thumbnailUrl) {
if (!this.message.content) {
this.message.content = {};
}
this.message.content.media = {
url,
type,
thumbnailUrl
};
return this;
}
addReply(text, postbackData) {
if (!this.message.suggestions) {
this.message.suggestions = [];
}
this.message.suggestions.push({
type: 'reply',
text,
postbackData: postbackData || text
});
return this;
}
addAction(text, actionType, data) {
if (!this.message.suggestions) {
this.message.suggestions = [];
}
const suggestion = {
type: 'action',
text,
action: {
type: actionType,
data
}
};
this.message.suggestions.push(suggestion);
return this;
}
setMetadata(metadata) {
this.message.metadata = metadata;
return this;
}
build() {
if (!this.message.to) {
throw new Error('Recipient phone number is required');
}
if (!this.message.content || (!this.message.content.text && !this.message.content.media && !this.message.content.richCard)) {
throw new Error('Message must have content');
}
return this.message;
}
}
/**
* Suggestion builder for creating suggestions
*/
class SuggestionBuilder {
static reply(text, postbackData) {
return {
type: 'reply',
text,
postbackData: postbackData || text
};
}
static action(text, actionType, data) {
return {
type: 'action',
text,
action: {
type: actionType,
data
}
};
}
static dial(text, phoneNumber) {
return this.action(text, 'dial', phoneNumber);
}
static openUrl(text, url) {
return this.action(text, 'openUrl', url);
}
static shareLocation(text) {
return this.action(text, 'shareLocation');
}
static createCalendarEvent(text, eventData) {
return this.action(text, 'createCalendarEvent', eventData);
}
}
class LongearsRCSProvider extends BaseProvider {
name = 'longears';
apiEndpoint;
initialized = false;
constructor(auth, config) {
super(auth, config);
// Set API endpoint from config or use default
this.apiEndpoint = config.apiEndpoint || 'https://api.longears.mobi/v1';
// Remove trailing slash if present
if (this.apiEndpoint.endsWith('/')) {
this.apiEndpoint = this.apiEndpoint.slice(0, -1);
}
}
/**
* Initialize the provider
*/
async initialize(config) {
if (this.initialized) {
return;
}
try {
logger.info('Initializing Longears RCS provider');
// Update config if provided
if (config.apiEndpoint) {
this.apiEndpoint = config.apiEndpoint;
if (this.apiEndpoint.endsWith('/')) {
this.apiEndpoint = this.apiEndpoint.slice(0, -1);
}
}
// Test connection to validate credentials
await this.testConnection();
this.initialized = true;
logger.info('Longears RCS provider initialized successfully');
}
catch (error) {
logger.error('Failed to initialize Longears RCS provider:', error);
throw new RCSError('Failed to initialize Longears RCS provider', exports.RCSErrorCode.INITIALIZATION_FAILED, this.name, error);
}
}
/**
* Send an RCS message
*/
async sendMessage(message) {
if (!this.initialized) {
throw new RCSError('Provider not initialized. Call initialize() first.', exports.RCSErrorCode.NOT_INITIALIZED, this.name);
}
// Validate phone number
if (!isValidE164(message.to)) {
throw new RCSError('Invalid phone number format. Must be in E.164 format.', exports.RCSErrorCode.INVALID_PHONE_NUMBER, this.name, { phoneNumber: message.to });
}
try {
logger.debug('Sending message via Longears RCS', { to: message.to });
// Transform the message to Longears format
const longearsMessage = this.transformMessage(message);
// Send the message
const response = await this.makeRequest(`${this.apiEndpoint}/messages`, {
method: 'POST',
data: longearsMessage
});
logger.info('Message sent successfully via Longears RCS', { messageId: response.messageId });
// Transform and return the response
return {
messageId: response.messageId,
status: response.status === 'success' ? 'sent' : 'pending',
timestamp: response.timestamp ? new Date(response.timestamp) : new Date(),
providerResponse: response
};
}
catch (error) {
logger.error('Failed to send message via Longears RCS:', error);
if (error instanceof RCSError) {
throw error;
}
throw new RCSError('Failed to send message via Longears RCS', exports.RCSErrorCode.MESSAGE_SEND_FAILED, this.name, error);
}
}
/**
* Check if a phone number is capable of receiving RCS messages
* @param phoneNumber The phone number to check in E.164 format
* @param options Optional parameters including agentId
*/
async validatePhoneNumber(phoneNumber, options) {
if (!this.initialized) {
throw new RCSError('Provider not initialized. Call initialize() first.', exports.RCSErrorCode.NOT_INITIALIZED, this.name);
}
try {
const agentId = options?.agentId;
logger.debug('Checking capabilities via Longears RCS', {
phoneNumber,
...(agentId ? { agentId } : {})
});
// Try to format the number first
const formattedNumber = formatPhoneNumber(phoneNumber) || phoneNumber;
// Build the request URL with query parameters
let requestUrl = `${this.apiEndpoint}/capabilities?phoneNumber=${encodeURIComponent(formattedNumber)}`;
// Add agentId if provided
if (agentId) {
requestUrl += `&agentId=${encodeURIComponent(agentId)}`;
}
// Get capabilities for this phone number
const response = await this.makeRequest(requestUrl, {
method: 'GET'
});
logger.debug('Phone number capability result from Longears RCS', {
phoneNumber,
isRcsSupported: response.isRcsSupported
});
// Transform response to match the expected format
const features = [];
if (response.features) {
// Rich card capabilities
if (response.features.richCards)
features.push('RICHCARD_STANDALONE');
if (response.features.carousels)
features.push('RICHCARD_CAROUSEL');
// Action capabilities
if (response.features.suggestions) {
features.push('ACTION_DIAL');
features.push('ACTION_OPEN_URL');
features.push('ACTION_OPEN_URL_IN_WEBVIEW');
features.push('ACTION_SHARE_LOCATION');
features.push('ACTION_VIEW_LOCATION');
features.push('ACTION_CREATE_CALENDAR_EVENT');
// Note: ACTION_COMPOSE is being deprecated by June 2025
// Only add if explicitly supported by the provider
if (response.features.supportedMediaTypes?.includes('compose')) {
features.push('ACTION_COMPOSE');
}
}
}
return {
success: true,
capability: {
phoneNumber: formattedNumber,
isCapable: response.isRcsSupported,
features: features,
timestamp: new Date().toISOString()
}
};
}
catch (error) {
logger.error('Failed to check capabilities via Longears RCS:', error);
if (error instanceof RCSError) {
throw error;
}
return {
success: false,
error: `Failed to check RCS capabilities: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Test connection to Longears API
*/
async testConnection() {
try {
await this.makeRequest(`${this.apiEndpoint}/status`, {
method: 'GET'
});
}
catch (error) {
throw new RCSError('Failed to connect to Longears API', exports.RCSErrorCode.NETWORK_ERROR, this.name, error);
}
}
/**
* Transform RCS SDK message to Longears format
*/
transformMessage(message) {
const longearsMessage = {
destination: message.to,
metadata: message.metadata || {}
};
// Add text content
if (message.content.text) {
longearsMessage.text = message.content.text;
}
// Add media content
if (message.content.media) {
longearsMessage.media = {
url: message.content.media.url,
type: message.content.media.type,
thumbnailUrl: message.content.media.thumbnailUrl,
mimeType: message.content.media.mimeType,
fileName: message.content.media.fileName
};
}
// Add rich card content
if (message.content.richCard) {
const richCard = message.content.richCard;
if (richCard.type === 'standalone') {
longearsMessage.richCard = {
type: 'standalone',
title: richCard.title,
description: richCard.description,
media: richCard.media,
orientation: richCard.orientation || 'vertical',
suggestions: richCard.suggestions
};
}
else if (richCard.type === 'carousel') {
longearsMessage.richCard = {
type: 'carousel',
cards: richCard.cards,
width: richCard.width || 'medium'
};
}
}
// Add suggestions
if (message.suggestions && message.suggestions.length > 0) {
longearsMessage.suggestions = message.suggestions.map(suggestion => ({
type: suggestion.type,
text: suggestion.text,
postbackData: suggestion.postbackData,
action: suggestion.action
}));
}
return longearsMessage;
}
}
/**
* Factory function to create provider instances
*/
function createProvider(providerName, auth, config) {
switch (providerName.toLowerCase()) {
case 'longears':
case 'longears-rcs':
return new LongearsRCSProvider(auth, config);
default:
throw new RCSError(`Unknown provider: ${providerName}`, exports.RCSErrorCode.PROVIDER_NOT_FOUND, providerName);
}
}
class LongearsAuth {
type = 'custom';
credentials;
token = null;
tokenExpiresAt = null;
constructor(credentials) {
if (!credentials.apiKey || !credentials.apiSecret) {
throw new RCSError('Longears authentication requires apiKey and apiSecret', exports.RCSErrorCode.AUTH_INVALID, 'longears');
}
this.credentials = credentials;
}
/**
* Authenticate with Longears API
* This implementation uses HMAC authentication
*/
async authenticate() {
try {
logger.debug('Authenticating with Longears');
// If we already have a valid token, return it
if (this.isValid()) {
logger.debug('Using existing Longears token');
return this.token;
}
// Generate a timestamp for the request
const timestamp = Date.now().toString();
// Create HMAC signature
const signature = this.generateSignature(timestamp);
// Create token object with expiration date
const expiresAt = new Date(Date.now() + 3600 * 1000); // Token valid for 1 hour
this.token = {
token: signature,
type: 'Custom',
expiresAt
};
this.tokenExpiresAt = expiresAt;
logger.debug('Longears authentication successful');
return this.token;
}
catch (error) {
logger.error('Longears authentication failed:', error);
throw new RCSError('Failed to authenticate with Longears', exports.RCSErrorCode.AUTH_FAILED, 'longears', error);
}
}
/**
* Refresh the token
* For Longears, we simply regenerate the signature
*/
async refresh() {
logger.debug('Refreshing Longears authentication');
this.token = null;
this.tokenExpiresAt = null;
return this.authenticate();
}
/**
* Check if the current token is valid
*/
isValid() {
if (!this.token || !this.tokenExpiresAt) {
return false;
}
// Token is valid if it expires in the future
const now = new Date();
return now < this.tokenExpiresAt;
}
/**
* Generate HMAC signature for Longears authentication
*/
generateSignature(timestamp) {
// The message to sign is the API key combined with the timestamp
const message = `${this.credentials.apiKey}:${timestamp}`;
// Create HMAC signature using the API secret
const hmac = crypto__default["default"].createHmac('sha256', this.credentials.apiSecret);
hmac.update(message);
const signature = hmac.digest('hex');
// Format as a header value: {apiKey}:{timestamp}:{signature}
return `${this.credentials.apiKey}:${timestamp}:${signature}`;
}
/**
* Get authentication headers for requests
*/
getAuthHeaders() {
if (!this.token) {
throw new RCSError('No valid token available. Call authenticate() first.', exports.RCSErrorCode.AUTH_FAILED, 'longears');
}
return {
'X-Longears-Auth': this.token.token
};
}
}
/**
* Factory function to create auth provider instances
*/
function createAuthProvider(config) {
switch (config.type) {
case 'longears':
return new LongearsAuth(config.credentials);
default:
throw new RCSError(`Unknown auth type: ${config.type}`, exports.RCSErrorCode.AUTH_INVALID, config.type);
}
}
class RCSClient {
provider;
auth;
config;
initialized = false;
constructor(config) {
this.config = config;
this.auth = createAuthProvider(config.auth);
this.provider = createProvider(config.provider, this.auth, config.options || {});
}
/**
* Initialize the RCS client and authenticate with the provider
*/
async initialize() {
if (this.initialized) {
return;
}
try {
logger.info(`Initializing RCS client with provider: ${this.config.provider}`);
// Authenticate first
await this.auth.authenticate();
// Initialize provider
await this.provider.initialize(this.config.options || {});
this.initialized = true;
logger.info('RCS client initialized successfully');
}
catch (error) {
logger.error('Failed to initialize RCS client:', error);
throw new RCSError('Failed to initialize RCS client', exports.RCSErrorCode.INITIALIZATION_FAILED, this.config.provider, error);
}
}
/**
* Send an RCS message
*/
async sendMessage(options) {
this.ensureInitialized();
try {
logger.debug('Sending RCS message:', { to: options.to, provider: this.config.provider });
const response = await this.provider.sendMessage({
to: options.to,
content: options.content,
suggestions: options.suggestions,
metadata: options.metadata
});
logger.info('Message sent successfully:', { messageId: response.messageId });
return response;
}
catch (error) {
logger.error('Failed to send message:', error);
if (error instanceof RCSError) {
throw error;
}
throw new RCSError('Failed to send message', exports.RCSErrorCode.MESSAGE_SEND_FAILED, this.config.provider, error);
}
}
/**
* Check if a phone number is capable of receiving RCS messages
* @param phoneNumber The phone number to check in E.164 format
* @param options Optional parameters including agentId for Google RBM
*/
async validatePhoneNumber(phoneNumber, options) {
this.ensureInitialized();
try {
// Use either provided agentId or the one from config
const agentId = options?.agentId || this.config.options?.agentId;
logger.debug('Checking RCS capability for phone number:', phoneNumber, agentId ? { agentId } : {});
return await this.provider.validatePhoneNumber(phoneNumber, { agentId });
}
catch (error) {
logger.error('Failed to check RCS capability:', error);
if (error instanceof RCSError) {
throw error;
}
return {
success: false,
error: `Failed to check RCS capability: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Get the current provider name
*/
getProvider() {
return this.provider.name;
}
/**
* Check if the client is initialized
*/
isInitialized() {
return this.initialized;
}
/**
* Ensure the client is initialized before making calls
*/
ensureInitialized() {
if (!this.initialized) {
throw new RCSError('RCS client not initialized. Call initialize() first.', exports.RCSErrorCode.NOT_INITIALIZED, this.config.provider);
}
}
}
exports.MessageBuilder = MessageBuilder;
exports.RCSClient = RCSClient;
exports.RCSError = RCSError;
exports.SuggestionBuilder = SuggestionBuilder;
exports.formatPhoneNumber = formatPhoneNumber;
exports.isValidE164 = isValidE164;
//# sourceMappingURL=index.cjs.js.map