Loading...
Loading...
Development guide for @rytass/sms base package (簡訊基底套件開發指南). Use when creating new SMS adapters (新增簡訊 adapter), understanding base interfaces, or extending SMS functionality. Covers SMSService interface and implementation patterns for Taiwan SMS providers (台灣簡訊服務提供商). Keywords - SMS adapter, 簡訊 adapter, service interface, 服務介面, message delivery, 訊息發送, batch processing, 批次處理, number normalization, 號碼正規化, Every8D, 互動資通, Interactive Communications
npx skill4agent add rytass/utils sms-development@rytass/sms@rytass/sms@rytass/sms (Base Package)
│
├── SMSService<Request, SendResponse, MultiTarget> # Service interface
├── SMSRequest # Single message interface
├── SMSSendResponse # Response interface
├── MultiTargetRequest # Batch message interface
├── SMSRequestResult # Status enum
└── Taiwan Phone Number Helpers # Normalization utilities
@rytass/sms-adapter-* # Provider implementations
│
├── [Provider]SMSService # Implements SMSService
├── [Provider]SMSRequest # Extends SMSRequest
├── [Provider]SMSSendResponse # Extends SMSSendResponse
└── [Provider] specific types and errorsnpm install @rytass/sms/**
* SMS service interface
* @template Request - SMS request type extending SMSRequest
* @template SendResponse - SMS response type extending SMSSendResponse
* @template MultiTarget - Multi-target request type extending MultiTargetRequest
*/
interface SMSService<
Request extends SMSRequest,
SendResponse extends SMSSendResponse,
MultiTarget extends MultiTargetRequest,
> {
/**
* Send multiple SMS messages with different content
* @param request - Array of SMS requests
* @returns Promise resolving to array of send responses
*/
send(request: Request[]): Promise<SendResponse[]>;
/**
* Send single SMS message
* @param request - Single SMS request
* @returns Promise resolving to send response
*/
send(request: Request): Promise<SendResponse>;
/**
* Send same message to multiple recipients
* @param request - Multi-target request with recipient list
* @returns Promise resolving to array of send responses
*/
send(request: MultiTarget): Promise<SendResponse[]>;
}/**
* Base SMS request interface
*/
interface SMSRequest {
/** Recipient mobile phone number */
mobile: string;
/** Message text content */
text: string;
}
/**
* Multi-target request for sending same message to multiple recipients
*/
interface MultiTargetRequest {
/** Array of recipient mobile phone numbers */
mobileList: string[];
/** Message text content (same for all recipients) */
text: string;
}/**
* SMS request result status
*/
enum SMSRequestResult {
/** Message sent successfully */
SUCCESS = 'SUCCESS',
/** Message delivery failed */
FAILED = 'FAILED',
}
/**
* Base SMS send response interface
*/
interface SMSSendResponse {
/** Unique message identifier (if provided by gateway) */
messageId?: string;
/** Delivery status */
status: SMSRequestResult;
/** Recipient mobile phone number (normalized) */
mobile: string;
}/**
* Regular expression for validating Taiwan mobile phone numbers
* Matches formats:
* - 09XXXXXXXX (standard)
* - 0912-345-678 (with dashes)
* - 0912 345 678 (with spaces)
* - +8869XXXXXXXX (international)
* - 8869XXXXXXXX (international without +)
*/
const TAIWAN_PHONE_NUMBER_RE = /^(0|\+?886-?)9\d{2}-?\d{3}-?\d{3}$/;
/**
* Normalize Taiwan mobile phone number to standard format (09XXXXXXXX)
* @param mobile - Phone number in any supported format
* @returns Normalized phone number (09XXXXXXXX)
*
* @example
* normalizedTaiwanMobilePhoneNumber('0987-654-321') // '0987654321'
* normalizedTaiwanMobilePhoneNumber('+886987654321') // '0987654321'
* normalizedTaiwanMobilePhoneNumber('886987654321') // '0987654321'
*/
function normalizedTaiwanMobilePhoneNumber(mobile: string): string;import {
SMSRequest,
SMSSendResponse,
MultiTargetRequest,
SMSRequestResult,
} from '@rytass/sms';
/**
* Provider-specific initialization options
*/
export interface MyProviderSMSRequestInit {
/** API username/account ID */
username: string;
/** API password/secret key */
password: string;
/** API base URL (optional, defaults to production) */
baseUrl?: string;
/** Restrict to Taiwan mobile numbers only */
onlyTaiwanMobileNumber?: boolean;
// Add any other provider-specific config
}
/**
* Provider-specific error codes
*/
export enum MyProviderError {
/** Invalid credentials */
INVALID_CREDENTIALS = -1,
/** Invalid phone number format */
FORMAT_ERROR = -2,
/** Insufficient account balance */
INSUFFICIENT_BALANCE = -3,
/** Rate limit exceeded */
RATE_LIMIT_EXCEEDED = -4,
/** Unknown error */
UNKNOWN = -99,
}
/**
* Provider-specific SMS request
* Extends base SMSRequest with additional fields if needed
*/
export interface MyProviderSMSRequest extends SMSRequest {
mobile: string;
text: string;
// Add provider-specific fields if needed
// priority?: 'low' | 'normal' | 'high';
// scheduledTime?: Date;
}
/**
* Provider-specific send response
* Extends base SMSSendResponse with additional fields
*/
export interface MyProviderSMSSendResponse extends SMSSendResponse {
messageId?: string;
status: SMSRequestResult;
mobile: string;
/** Error message if delivery failed */
errorMessage?: string;
/** Provider-specific error code */
errorCode?: MyProviderError;
// Add provider-specific fields if needed
// cost?: number;
// remainingBalance?: number;
}
/**
* Provider-specific multi-target request
*/
export interface MyProviderSMSMultiTargetRequest extends MultiTargetRequest {
mobileList: string[];
text: string;
}import {
SMSService,
SMSRequestResult,
normalizedTaiwanMobilePhoneNumber,
TAIWAN_PHONE_NUMBER_RE,
} from '@rytass/sms';
import axios from 'axios';
/**
* SMS service implementation for MyProvider
* Implements the SMSService interface with provider-specific logic
*/
export class SMSServiceMyProvider implements SMSService<
MyProviderSMSRequest,
MyProviderSMSSendResponse,
MyProviderSMSMultiTargetRequest
> {
private readonly username: string;
private readonly password: string;
private readonly baseUrl: string;
private readonly onlyTaiwanMobileNumber: boolean;
/**
* Initialize SMS service
* @param options - Provider configuration options
*/
constructor(options: MyProviderSMSRequestInit) {
this.username = options.username;
this.password = options.password;
this.baseUrl = options.baseUrl || 'https://api.myprovider.com';
this.onlyTaiwanMobileNumber = options.onlyTaiwanMobileNumber || false;
}
/**
* Send SMS message(s)
* Handles three sending patterns:
* 1. Single SMS to one recipient
* 2. Multiple SMS with different messages
* 3. Same message to multiple recipients
*/
async send(requests: MyProviderSMSRequest[]): Promise<MyProviderSMSSendResponse[]>;
async send(request: MyProviderSMSRequest): Promise<MyProviderSMSSendResponse>;
async send(request: MyProviderSMSMultiTargetRequest): Promise<MyProviderSMSSendResponse[]>;
async send(
requests: MyProviderSMSMultiTargetRequest | MyProviderSMSRequest | MyProviderSMSRequest[],
): Promise<MyProviderSMSSendResponse | MyProviderSMSSendResponse[]> {
// Validate input
if (
(Array.isArray(requests) && !requests.length) ||
((requests as MyProviderSMSMultiTargetRequest).mobileList &&
!(requests as MyProviderSMSMultiTargetRequest).mobileList?.length)
) {
throw new Error('No target provided.');
}
// Process and validate phone numbers
const processedRequests = this.processRequests(requests);
// Send to provider API
const results = await this.sendToProvider(processedRequests);
// Return results in appropriate format
return this.formatResults(requests, results);
}
/**
* Process and validate phone numbers
* @param requests - Raw requests
* @returns Processed requests with normalized phone numbers
*/
private processRequests(
requests: MyProviderSMSMultiTargetRequest | MyProviderSMSRequest | MyProviderSMSRequest[],
): Array<{ mobile: string; text: string }> {
const requestArray = Array.isArray(requests) ? requests : [requests];
const processed: Array<{ mobile: string; text: string }> = [];
for (const request of requestArray) {
if ((request as MyProviderSMSMultiTargetRequest).mobileList) {
// Multi-target request
const multiTarget = request as MyProviderSMSMultiTargetRequest;
for (const mobile of multiTarget.mobileList) {
const normalizedMobile = this.validateAndNormalizeMobile(mobile);
processed.push({
mobile: normalizedMobile,
text: multiTarget.text,
});
}
} else {
// Single request
const singleRequest = request as MyProviderSMSRequest;
const normalizedMobile = this.validateAndNormalizeMobile(singleRequest.mobile);
processed.push({
mobile: normalizedMobile,
text: singleRequest.text,
});
}
}
return processed;
}
/**
* Validate and normalize phone number
* @param mobile - Raw phone number
* @returns Normalized phone number
* @throws Error if number is invalid and onlyTaiwanMobileNumber is true
*/
private validateAndNormalizeMobile(mobile: string): string {
// Check if Taiwan number
if (TAIWAN_PHONE_NUMBER_RE.test(mobile)) {
return normalizedTaiwanMobilePhoneNumber(mobile);
}
// If strict Taiwan-only mode, reject non-Taiwan numbers
if (this.onlyTaiwanMobileNumber) {
throw new Error(
`${mobile} is not taiwan mobile phone (\`onlyTaiwanMobileNumber\` option is true)`
);
}
// Return as-is for international numbers
return mobile;
}
/**
* Send requests to provider API
* @param requests - Processed requests
* @returns API responses
*/
private async sendToProvider(
requests: Array<{ mobile: string; text: string }>,
): Promise<Map<string, MyProviderSMSSendResponse>> {
// Group requests by message text for batch optimization
const batches = this.groupByMessage(requests);
const results = new Map<string, MyProviderSMSSendResponse>();
// Send each batch
for (const [message, mobileList] of batches.entries()) {
try {
// Call provider API
const response = await this.callProviderAPI(mobileList, message);
// Process API response
const batchResults = this.parseAPIResponse(response, mobileList, message);
// Store results
for (const [key, result] of batchResults.entries()) {
results.set(key, result);
}
} catch (error) {
// Handle API errors
for (const mobile of mobileList) {
results.set(`${message}:${mobile}`, {
status: SMSRequestResult.FAILED,
mobile,
errorMessage: error.message,
errorCode: MyProviderError.UNKNOWN,
});
}
}
}
return results;
}
/**
* Group requests by message text for batch sending
* @param requests - Array of requests
* @returns Map of message text to mobile numbers
*/
private groupByMessage(
requests: Array<{ mobile: string; text: string }>,
): Map<string, string[]> {
const batches = new Map<string, string[]>();
for (const request of requests) {
const existing = batches.get(request.text) || [];
batches.set(request.text, [...existing, request.mobile]);
}
return batches;
}
/**
* Call provider API
* IMPORTANT: Implement this method according to your provider's API specification
* @param mobileList - Array of phone numbers
* @param message - Message text
* @returns API response
*
* NOTE: Every8D uses the following API specification:
* - Endpoint: `${baseUrl}/API21/HTTP/SendSMS.ashx`
* - Method: POST with application/x-www-form-urlencoded
* - Parameters: UID (username), PWD (password), MSG (message), DEST (comma-separated mobiles)
* - Response: CSV format: "credit,sent,cost,unsent,batchId" or "errorCode,errorMessage"
*/
private async callProviderAPI(
mobileList: string[],
message: string,
): Promise<any> {
// Example implementation using Every8D API format
// Other providers may use different endpoints and parameters
const { data } = await axios.post(
`${this.baseUrl}/API21/HTTP/SendSMS.ashx`,
new URLSearchParams({
UID: this.username, // Every8D uses UID for username
PWD: this.password, // Every8D uses PWD for password
MSG: message, // Every8D uses MSG for message content
DEST: mobileList.join(','), // Every8D uses DEST for comma-separated recipients
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
return data;
}
/**
* Parse provider API response
* IMPORTANT: Implement this method according to your provider's response format
* @param response - API response data
* @param mobileList - Array of phone numbers
* @param message - Message text
* @returns Map of results keyed by "message:mobile"
*/
private parseAPIResponse(
response: any,
mobileList: string[],
message: string,
): Map<string, MyProviderSMSSendResponse> {
const results = new Map<string, MyProviderSMSSendResponse>();
// Example parsing - REPLACE WITH ACTUAL PROVIDER RESPONSE PARSING
if (response.success) {
// All succeeded
for (const mobile of mobileList) {
results.set(`${message}:${mobile}`, {
messageId: response.messageId,
status: SMSRequestResult.SUCCESS,
mobile,
});
}
} else {
// All failed
for (const mobile of mobileList) {
results.set(`${message}:${mobile}`, {
status: SMSRequestResult.FAILED,
mobile,
errorMessage: response.errorMessage,
errorCode: response.errorCode,
});
}
}
return results;
}
/**
* Format results based on original request type
* @param originalRequest - Original request
* @param results - Processed results map
* @returns Formatted response(s)
*/
private formatResults(
originalRequest: MyProviderSMSMultiTargetRequest | MyProviderSMSRequest | MyProviderSMSRequest[],
results: Map<string, MyProviderSMSSendResponse>,
): MyProviderSMSSendResponse | MyProviderSMSSendResponse[] {
// Multi-target request
if ((originalRequest as MyProviderSMSMultiTargetRequest).mobileList) {
const multiTarget = originalRequest as MyProviderSMSMultiTargetRequest;
return multiTarget.mobileList.map(mobile => {
const normalizedMobile = TAIWAN_PHONE_NUMBER_RE.test(mobile)
? normalizedTaiwanMobilePhoneNumber(mobile)
: mobile;
return results.get(`${multiTarget.text}:${normalizedMobile}`)!;
});
}
// Array of requests
if (Array.isArray(originalRequest)) {
return originalRequest.map(request => {
const normalizedMobile = TAIWAN_PHONE_NUMBER_RE.test(request.mobile)
? normalizedTaiwanMobilePhoneNumber(request.mobile)
: request.mobile;
return results.get(`${request.text}:${normalizedMobile}`)!;
});
}
// Single request
const singleRequest = originalRequest as MyProviderSMSRequest;
const normalizedMobile = TAIWAN_PHONE_NUMBER_RE.test(singleRequest.mobile)
? normalizedTaiwanMobilePhoneNumber(singleRequest.mobile)
: singleRequest.mobile;
return results.get(`${singleRequest.text}:${normalizedMobile}`)!;
}
}// index.ts
export { SMSServiceMyProvider } from './sms-service-my-provider';
export * from './typings';// __tests__/sms-service-my-provider.spec.ts
import { SMSServiceMyProvider } from '../src/sms-service-my-provider';
import { SMSRequestResult } from '@rytass/sms';
describe('SMSServiceMyProvider', () => {
let smsService: SMSServiceMyProvider;
beforeEach(() => {
smsService = new SMSServiceMyProvider({
username: 'test-username',
password: 'test-password',
onlyTaiwanMobileNumber: true,
});
});
describe('send - single SMS', () => {
it('should send single SMS successfully', async () => {
const result = await smsService.send({
mobile: '0987654321',
text: 'Test message',
});
expect(result.status).toBe(SMSRequestResult.SUCCESS);
expect(result.mobile).toBe('0987654321');
expect(result.messageId).toBeDefined();
});
it('should normalize Taiwan phone number', async () => {
const result = await smsService.send({
mobile: '+886987654321',
text: 'Test message',
});
expect(result.mobile).toBe('0987654321');
});
it('should reject non-Taiwan number when onlyTaiwanMobileNumber is true', async () => {
await expect(
smsService.send({
mobile: '+1234567890',
text: 'Test message',
})
).rejects.toThrow('is not taiwan mobile phone');
});
});
describe('send - batch SMS', () => {
it('should send same message to multiple recipients', async () => {
const results = await smsService.send({
mobileList: ['0987654321', '0912345678', '0923456789'],
text: 'Batch message',
});
expect(results).toHaveLength(3);
expect(results.every(r => r.status === SMSRequestResult.SUCCESS)).toBe(true);
});
it('should send different messages to multiple recipients', async () => {
const results = await smsService.send([
{ mobile: '0987654321', text: 'Message 1' },
{ mobile: '0912345678', text: 'Message 2' },
{ mobile: '0923456789', text: 'Message 3' },
]);
expect(results).toHaveLength(3);
expect(results.every(r => r.status === SMSRequestResult.SUCCESS)).toBe(true);
});
});
describe('error handling', () => {
it('should handle API errors gracefully', async () => {
// Mock API error
// ... test implementation
});
it('should return FAILED status on delivery failure', async () => {
// Mock failed delivery
// ... test implementation
});
});
});SMSService<Request, SendResponse, MultiTarget>normalizedTaiwanMobilePhoneNumber()TAIWAN_PHONE_NUMBER_REonlyTaiwanMobileNumber// ✅ GOOD: Use provided utilities
import { normalizedTaiwanMobilePhoneNumber, TAIWAN_PHONE_NUMBER_RE } from '@rytass/sms';
private validateMobile(mobile: string): string {
if (TAIWAN_PHONE_NUMBER_RE.test(mobile)) {
return normalizedTaiwanMobilePhoneNumber(mobile);
}
if (this.onlyTaiwanMobileNumber) {
throw new Error(`Invalid Taiwan mobile number: ${mobile}`);
}
return mobile;
}
// ❌ BAD: Custom regex without normalization
private validateMobile(mobile: string): string {
if (!/^09\d{8}$/.test(mobile)) {
throw new Error('Invalid number');
}
return mobile;
}// ✅ GOOD: Detailed error information
catch (error) {
return {
status: SMSRequestResult.FAILED,
mobile,
errorMessage: error.response?.data?.message || error.message,
errorCode: this.mapProviderErrorCode(error.response?.data?.code),
};
}
// ❌ BAD: Generic error without details
catch (error) {
return {
status: SMSRequestResult.FAILED,
mobile,
};
}// ✅ GOOD: Group by message for efficiency
private groupByMessage(requests: Array<{ mobile: string; text: string }>) {
const batches = new Map<string, string[]>();
for (const request of requests) {
const existing = batches.get(request.text) || [];
batches.set(request.text, [...existing, request.mobile]);
}
return batches;
}
// ❌ BAD: Send each message individually
for (const request of requests) {
await this.callAPI(request.mobile, request.text);
}// ✅ GOOD: Strict typing with generics
export class SMSServiceMyProvider implements SMSService<
MyProviderSMSRequest,
MyProviderSMSSendResponse,
MyProviderSMSMultiTargetRequest
> {
// ...
}
// ❌ BAD: Using any or loose typing
export class SMSServiceMyProvider {
async send(request: any): Promise<any> {
// ...
}
}// ✅ GOOD: Environment-based configuration
export class SMSServiceMyProvider {
constructor(options: MyProviderSMSRequestInit) {
this.baseUrl = options.baseUrl || this.getDefaultBaseUrl();
}
private getDefaultBaseUrl(): string {
return process.env.NODE_ENV === 'production'
? 'https://api.myprovider.com'
: 'https://api-staging.myprovider.com';
}
}
// ❌ BAD: Hardcoded production URL
export class SMSServiceMyProvider {
private baseUrl = 'https://api.myprovider.com';
}// Types
export {
SMSService, // Service interface
SMSRequest, // Single message interface
SMSSendResponse, // Response interface
MultiTargetRequest, // Batch message interface
SMSRequestResult, // Status enum
};
// Utilities
export {
TAIWAN_PHONE_NUMBER_RE, // Taiwan number regex
normalizedTaiwanMobilePhoneNumber, // Normalization function
};09XXXXXXXX0912-345-6780912 345 678+8869XXXXXXXX8869XXXXXXXX09XXXXXXXX+8868860import {
SMSService,
SMSRequest,
SMSSendResponse,
MultiTargetRequest,
SMSRequestResult,
} from '@rytass/sms';
interface SimpleSMSRequest extends SMSRequest {
mobile: string;
text: string;
}
interface SimpleSMSResponse extends SMSSendResponse {
messageId?: string;
status: SMSRequestResult;
mobile: string;
}
interface SimpleMultiTargetRequest extends MultiTargetRequest {
mobileList: string[];
text: string;
}
export class SimpleSMSService implements SMSService<
SimpleSMSRequest,
SimpleSMSResponse,
SimpleMultiTargetRequest
> {
async send(requests: SimpleSMSRequest[]): Promise<SimpleSMSResponse[]>;
async send(request: SimpleSMSRequest): Promise<SimpleSMSResponse>;
async send(request: SimpleMultiTargetRequest): Promise<SimpleSMSResponse[]>;
async send(request: any): Promise<any> {
// Minimal implementation
if (Array.isArray(request)) {
return Promise.all(request.map(r => this.sendSingle(r)));
}
if (request.mobileList) {
return Promise.all(
request.mobileList.map(mobile =>
this.sendSingle({ mobile, text: request.text })
)
);
}
return this.sendSingle(request);
}
private async sendSingle(request: SimpleSMSRequest): Promise<SimpleSMSResponse> {
// Call provider API
// Return response
return {
messageId: 'MSG-' + Date.now(),
status: SMSRequestResult.SUCCESS,
mobile: request.mobile,
};
}
}private async callProviderAPI(mobile: string, text: string): Promise<any> {
const { data } = await axios.post(
`${this.baseUrl}/api/sms/send`,
new URLSearchParams({
username: this.username,
password: this.password,
mobile: mobile,
message: text,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
return data;
}private parseCSVResponse(data: string): {
messageId: string;
status: SMSRequestResult;
} {
const [credit, sent, cost, unsent, messageId] = data.split(',');
return {
messageId,
status: messageId ? SMSRequestResult.SUCCESS : SMSRequestResult.FAILED,
};
}import crypto from 'crypto';
private generateSignature(params: Record<string, string>): string {
const sorted = Object.keys(params)
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
return crypto
.createHash('md5')
.update(sorted + this.secretKey)
.digest('hex');
}// Error: Type 'X' is not assignable to type 'SMSService<...>'
// Solution: Ensure all three send() overloads are implemented
async send(requests: MyProviderSMSRequest[]): Promise<MyProviderSMSSendResponse[]>;
async send(request: MyProviderSMSRequest): Promise<MyProviderSMSSendResponse>;
async send(request: MyProviderSMSMultiTargetRequest): Promise<MyProviderSMSSendResponse[]>;// Issue: Numbers not being normalized correctly
// Solution: Use TAIWAN_PHONE_NUMBER_RE before normalizing
if (TAIWAN_PHONE_NUMBER_RE.test(mobile)) {
mobile = normalizedTaiwanMobilePhoneNumber(mobile);
}// Issue: Too many API calls
// Solution: Group requests by message text
const batches = requests.reduce((map, req) => {
const list = map.get(req.text) || [];
map.set(req.text, [...list, req.mobile]);
return map;
}, new Map<string, string[]>());@rytass/sms-adapter-{provider}@rytass/smsnpm run build