mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
feat(aliyun sms connector): add aliyun sms connector (#203)
* chore(SMS connector): add SMS connector and UT * feat(Aliyun SMS): remove redundancy * chore(Aliyun SMS connector): merge duplicates and fix accordingly
This commit is contained in:
parent
ffeabbedcf
commit
30ce91810f
10 changed files with 229 additions and 49 deletions
|
@ -4,9 +4,9 @@ import {
|
|||
ConnectorConfigError,
|
||||
ConnectorError,
|
||||
ConnectorMetadata,
|
||||
ConnectorType,
|
||||
EmailSendMessageFunction,
|
||||
ValidateConfig,
|
||||
ConnectorType,
|
||||
} from '../types';
|
||||
import { getConnectorConfig } from '../utilities';
|
||||
import { singleSendMail } from './single-send-mail';
|
||||
|
@ -26,6 +26,24 @@ export const metadata: ConnectorMetadata = {
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* UsageType here is used to specify the use case of the template, can be either
|
||||
* 'Register', 'SignIn', 'ForgotPassword' or 'Test'.
|
||||
*/
|
||||
const templateGuard = z.object({
|
||||
usageType: z.string(),
|
||||
subject: z.string(),
|
||||
content: z.string(), // With variable {{code}}, support HTML
|
||||
});
|
||||
|
||||
const configGuard = z.object({
|
||||
accessKeyId: z.string(),
|
||||
accessKeySecret: z.string(),
|
||||
accountName: z.string(),
|
||||
fromAlias: z.string().optional(),
|
||||
templates: z.array(templateGuard),
|
||||
});
|
||||
|
||||
export const validateConfig: ValidateConfig = async (config: unknown) => {
|
||||
if (!config) {
|
||||
throw new ConnectorConfigError('Missing config');
|
||||
|
@ -38,45 +56,32 @@ export const validateConfig: ValidateConfig = async (config: unknown) => {
|
|||
}
|
||||
};
|
||||
|
||||
const configGuard = z.object({
|
||||
accessKeyId: z.string(),
|
||||
accessKeySecret: z.string(),
|
||||
accountName: z.string(),
|
||||
fromAlias: z.string().optional(),
|
||||
templates: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
subject: z.string(),
|
||||
content: z.string(), // With variable {{code}}, support HTML
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type AliyunDmConfig = z.infer<typeof configGuard>;
|
||||
|
||||
export const sendMessage: EmailSendMessageFunction = async (address, type, data) => {
|
||||
const config = await getConnectorConfig<AliyunDmConfig>(metadata.id);
|
||||
await validateConfig(config);
|
||||
const template = config.templates.find((template) => template.type === type);
|
||||
const { accessKeyId, accessKeySecret, accountName, fromAlias, templates } = config;
|
||||
const template = templates.find((template) => template.usageType === type);
|
||||
|
||||
if (!template) {
|
||||
throw new ConnectorError(`Can not find template for type: ${type}`);
|
||||
throw new ConnectorError(`Cannot find template for type: ${type}`);
|
||||
}
|
||||
|
||||
return singleSendMail(
|
||||
{
|
||||
AccessKeyId: config.accessKeyId,
|
||||
AccountName: config.accountName,
|
||||
AccessKeyId: accessKeyId,
|
||||
AccountName: accountName,
|
||||
ReplyToAddress: 'false',
|
||||
AddressType: '1',
|
||||
ToAddress: address,
|
||||
FromAlias: config.fromAlias,
|
||||
FromAlias: fromAlias,
|
||||
Subject: template.subject,
|
||||
HtmlBody:
|
||||
typeof data.code === 'string'
|
||||
? template.content.replaceAll('{{code}}', data.code)
|
||||
: template.content,
|
||||
},
|
||||
config.accessKeySecret
|
||||
accessKeySecret
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,12 +9,12 @@ describe('singleSendMail', () => {
|
|||
{
|
||||
AccessKeyId: '<access-key-id>',
|
||||
AccountName: 'noreply@example.com',
|
||||
ReplyToAddress: 'false',
|
||||
AddressType: '1',
|
||||
ToAddress: 'user@example.com',
|
||||
FromAlias: 'CompanyName',
|
||||
Subject: 'test',
|
||||
HtmlBody: 'test from logto',
|
||||
ReplyToAddress: 'false',
|
||||
Subject: 'test',
|
||||
ToAddress: 'user@example.com',
|
||||
},
|
||||
'<access-key-secret>'
|
||||
);
|
||||
|
|
|
@ -7,18 +7,25 @@ import { PublicParameters, request } from '../utilities/aliyun';
|
|||
interface SingleSendMail {
|
||||
AccountName: string;
|
||||
AddressType: '0' | '1';
|
||||
ReplyToAddress: 'true' | 'false';
|
||||
Subject: string;
|
||||
ToAddress: string;
|
||||
ClickTrace?: '0' | '1';
|
||||
FromAlias?: string;
|
||||
HtmlBody?: string;
|
||||
ReplyToAddress: 'true' | 'false';
|
||||
Subject: string;
|
||||
TagName?: string;
|
||||
TextBody?: string;
|
||||
ToAddress: string;
|
||||
}
|
||||
|
||||
const Endpoint = 'https://dm.aliyuncs.com/';
|
||||
|
||||
const staticConfigs = {
|
||||
Format: 'json',
|
||||
SignatureMethod: 'HMAC-SHA1',
|
||||
SignatureVersion: '1.0',
|
||||
Version: '2015-11-23',
|
||||
};
|
||||
|
||||
/**
|
||||
* @doc https://help.aliyun.com/document_detail/29444.html
|
||||
*
|
||||
|
@ -27,9 +34,9 @@ export const singleSendMail = async (
|
|||
parameters: PublicParameters & SingleSendMail,
|
||||
accessKeySecret: string
|
||||
) => {
|
||||
return request<{ RequestId: string; EnvId: string }>(
|
||||
return request<{ EnvId: string; RequestId: string }>(
|
||||
Endpoint,
|
||||
{ Action: 'SingleSendMail', ...parameters },
|
||||
{ Action: 'SingleSendMail', ...staticConfigs, ...parameters },
|
||||
accessKeySecret
|
||||
);
|
||||
};
|
||||
|
|
107
packages/core/src/connectors/aliyun-sms/index.ts
Normal file
107
packages/core/src/connectors/aliyun-sms/index.ts
Normal file
|
@ -0,0 +1,107 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
ConnectorConfigError,
|
||||
ConnectorError,
|
||||
ConnectorMetadata,
|
||||
ConnectorType,
|
||||
SmsSendMessageFunction,
|
||||
ValidateConfig,
|
||||
} from '../types';
|
||||
import { getConnectorConfig } from '../utilities';
|
||||
import { sendSms } from './single-send-text';
|
||||
|
||||
export const metadata: ConnectorMetadata = {
|
||||
id: 'aliyun-sms',
|
||||
type: ConnectorType.SMS,
|
||||
name: {
|
||||
en: 'Aliyun Short Message Service',
|
||||
zh_CN: '阿里云短信服务',
|
||||
},
|
||||
logo: './logo.png',
|
||||
description: {
|
||||
en: 'Short Message Service (SMS) has a batch sending feature and various API operations to send one-time password (OTP) messages, notification messages, and promotional messages to customers.',
|
||||
zh_CN:
|
||||
'短信服务(Short Message Service)是指通过调用短信发送API,将指定短信内容发送给指定手机用户。',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Details of SmsTemplateType can be found at:
|
||||
* https://next.api.aliyun.com/document/Dysmsapi/2017-05-25/QuerySmsTemplateList.
|
||||
*
|
||||
* For our use case is to send passcode sms for passwordless sign-in/up as well as
|
||||
* reset password, the default value of type code is set to be 2.
|
||||
*
|
||||
*/
|
||||
enum SmsTemplateType {
|
||||
Notification = 0,
|
||||
Promotion = 1,
|
||||
Passcode = 2,
|
||||
InternationalMessage = 6,
|
||||
PureNumber = 7,
|
||||
}
|
||||
|
||||
/**
|
||||
* UsageType here is used to specify the use case of the template, can be either
|
||||
* 'Register', 'SignIn', 'ForgotPassword' or 'Test'.
|
||||
*
|
||||
* Type here in the template is used to specify the purpose of sending the sms,
|
||||
* can be either item in SmsTemplateType.
|
||||
* As the SMS is applied for sending passcode, the value should always be 2 in our case.
|
||||
*
|
||||
*/
|
||||
const templateGuard = z.object({
|
||||
type: z.nativeEnum(SmsTemplateType).default(2),
|
||||
usageType: z.string(),
|
||||
code: z.string().optional(),
|
||||
name: z.string().min(1).max(30),
|
||||
content: z.string().min(1).max(500),
|
||||
remark: z.string(),
|
||||
});
|
||||
|
||||
const configGuard = z.object({
|
||||
accessKeyId: z.string(),
|
||||
accessKeySecret: z.string(),
|
||||
signName: z.string(),
|
||||
templateCode: z.string(),
|
||||
templates: z.array(templateGuard),
|
||||
});
|
||||
|
||||
export const validateConfig: ValidateConfig = async (config: unknown) => {
|
||||
if (!config) {
|
||||
throw new ConnectorConfigError('Missing config');
|
||||
}
|
||||
|
||||
const result = configGuard.safeParse(config);
|
||||
|
||||
if (!result.success) {
|
||||
throw new ConnectorConfigError(result.error.message);
|
||||
}
|
||||
};
|
||||
|
||||
export type AliyunSmsConfig = z.infer<typeof configGuard>;
|
||||
|
||||
export const sendMessage: SmsSendMessageFunction = async (phone, type, { code }) => {
|
||||
const config = await getConnectorConfig<AliyunSmsConfig>(metadata.id);
|
||||
await validateConfig(config);
|
||||
const { accessKeyId, accessKeySecret, signName, templateCode, templates } = config;
|
||||
const template = templates.find(
|
||||
({ code, usageType }) => code === templateCode && usageType === type
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
throw new ConnectorError(`Cannot find template code: ${templateCode}`);
|
||||
}
|
||||
|
||||
return sendSms(
|
||||
{
|
||||
AccessKeyId: accessKeyId,
|
||||
PhoneNumbers: phone,
|
||||
SignName: signName,
|
||||
TemplateCode: templateCode,
|
||||
TemplateParam: JSON.stringify({ code }),
|
||||
},
|
||||
accessKeySecret
|
||||
);
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { request } from '../utilities/aliyun';
|
||||
import { sendSms } from './single-send-text';
|
||||
|
||||
export const passcodeLength = 4;
|
||||
const randomCode = customAlphabet('1234567890', passcodeLength);
|
||||
|
||||
jest.mock('../utilities/aliyun');
|
||||
|
||||
describe('sendSms', () => {
|
||||
it('should call request with action sendSms', async () => {
|
||||
const code = randomCode();
|
||||
|
||||
await sendSms(
|
||||
{
|
||||
AccessKeyId: '<access-key-id>',
|
||||
PhoneNumbers: '13912345678',
|
||||
SignName: '阿里云短信测试',
|
||||
TemplateCode: ' SMS_154950909',
|
||||
TemplateParam: JSON.stringify({ code }),
|
||||
},
|
||||
'<access-key-secret>'
|
||||
);
|
||||
const calledData = (request as jest.MockedFunction<typeof request>).mock.calls[0];
|
||||
expect(calledData).not.toBeUndefined();
|
||||
const payload = calledData?.[1];
|
||||
expect(payload).toHaveProperty('Action', 'SendSms');
|
||||
});
|
||||
});
|
36
packages/core/src/connectors/aliyun-sms/single-send-text.ts
Normal file
36
packages/core/src/connectors/aliyun-sms/single-send-text.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { PublicParameters, request } from '../utilities/aliyun';
|
||||
|
||||
/**
|
||||
* @doc https://help.aliyun.com/document_detail/101414.html
|
||||
*
|
||||
*/
|
||||
interface SendSms {
|
||||
OutId?: string;
|
||||
PhoneNumbers: string; // 11 digits w/o prefix (can be multiple phone numbers with separator `,`)
|
||||
SignName: string; // Name of SMS signature
|
||||
SmsUpExtendCode?: string;
|
||||
TemplateCode: string; // Text message template ID
|
||||
TemplateParam?: string; // Stringified JSON (used to fill in text template)
|
||||
}
|
||||
|
||||
const Endpoint = 'https://dysmsapi.aliyuncs.com/';
|
||||
|
||||
const staticConfigs = {
|
||||
Format: 'json',
|
||||
RegionId: 'cn-hangzhou',
|
||||
SignatureMethod: 'HMAC-SHA1',
|
||||
SignatureVersion: '1.0',
|
||||
Version: '2017-05-25',
|
||||
};
|
||||
|
||||
/**
|
||||
* @doc https://help.aliyun.com/document_detail/101414.html
|
||||
*
|
||||
*/
|
||||
export const sendSms = async (parameters: PublicParameters & SendSms, accessKeySecret: string) => {
|
||||
return request<{ BizId: string; Code: string; Message: string; RequestId: string }>(
|
||||
Endpoint,
|
||||
{ Action: 'SendSms', ...staticConfigs, ...parameters },
|
||||
accessKeySecret
|
||||
);
|
||||
};
|
|
@ -2,10 +2,11 @@ import RequestError from '@/errors/RequestError';
|
|||
import { findConnectorById, hasConnector, insertConnector } from '@/queries/connector';
|
||||
|
||||
import * as AliyunDM from './aliyun-dm';
|
||||
import * as AliyunSMS from './aliyun-sms';
|
||||
import * as GitHub from './github';
|
||||
import { ConnectorInstance, ConnectorType } from './types';
|
||||
|
||||
const allConnectors: ConnectorInstance[] = [AliyunDM, GitHub];
|
||||
const allConnectors: ConnectorInstance[] = [AliyunDM, AliyunSMS, GitHub];
|
||||
|
||||
export const getConnectorInstances = async (): Promise<ConnectorInstance[]> => {
|
||||
return Promise.all(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Languages } from '@logto/phrases';
|
||||
import { ConnectorConfig, Connector } from '@logto/schemas';
|
||||
import { ConnectorConfig, Connector, PasscodeType } from '@logto/schemas';
|
||||
|
||||
export enum ConnectorType {
|
||||
SMS = 'SMS',
|
||||
|
@ -37,7 +37,7 @@ export interface SocialConector extends BaseConnector {
|
|||
getUserInfo: GetUserInfo;
|
||||
}
|
||||
|
||||
export interface EmailMessageTypes {
|
||||
type EmailMessageTypes = {
|
||||
SignIn: {
|
||||
code: string;
|
||||
};
|
||||
|
@ -48,7 +48,7 @@ export interface EmailMessageTypes {
|
|||
code: string;
|
||||
};
|
||||
Test: Record<string, unknown>;
|
||||
}
|
||||
};
|
||||
|
||||
type SmsMessageTypes = EmailMessageTypes;
|
||||
|
||||
|
@ -59,11 +59,13 @@ export type EmailSendMessageFunction<T = unknown> = (
|
|||
) => Promise<T>;
|
||||
|
||||
export type SmsSendMessageFunction<T = unknown> = (
|
||||
address: string,
|
||||
phone: string,
|
||||
type: keyof SmsMessageTypes,
|
||||
payload: SmsMessageTypes[typeof type]
|
||||
) => Promise<T>;
|
||||
|
||||
export type TemplateType = PasscodeType | 'Test';
|
||||
|
||||
export class ConnectorError extends Error {}
|
||||
|
||||
export class ConnectorConfigError extends ConnectorError {}
|
||||
|
|
|
@ -43,30 +43,22 @@ export const getSignature = (
|
|||
|
||||
export interface PublicParameters {
|
||||
AccessKeyId: string;
|
||||
RegionId?: string;
|
||||
Format?: string;
|
||||
Version?: string;
|
||||
SignatureMethod?: string;
|
||||
Timestamp?: string;
|
||||
Format?: string; // 'json' or 'xml', default: 'json'
|
||||
RegionId?: string; // 'cn-hangzhou' | 'ap-southeast-1' | 'ap-southeast-2'
|
||||
Signature?: string;
|
||||
SignatureVersion?: string;
|
||||
SignatureMethod?: string;
|
||||
SignatureNonce?: string;
|
||||
SignatureVersion?: string;
|
||||
Timestamp?: string;
|
||||
Version?: string;
|
||||
}
|
||||
|
||||
const commonParameters = {
|
||||
Version: '2015-11-23',
|
||||
Format: 'json',
|
||||
SignatureVersion: '1.0',
|
||||
SignatureMethod: 'HMAC-SHA1',
|
||||
};
|
||||
|
||||
export const request = async <T>(
|
||||
url: string,
|
||||
parameters: PublicParameters & Record<string, string>,
|
||||
accessKeySecret: string
|
||||
) => {
|
||||
const finalParameters: Record<string, string> = {
|
||||
...commonParameters,
|
||||
...parameters,
|
||||
SignatureNonce: String(Math.random()),
|
||||
Timestamp: new Date().toISOString(),
|
||||
|
|
|
@ -44,7 +44,7 @@ const errors = {
|
|||
insufficient_info: 'Insufficent sign-in info.',
|
||||
},
|
||||
connector: {
|
||||
not_found: 'Can not find any available connector for type: {{type}}.',
|
||||
not_found: 'Cannot find any available connector for type: {{type}}.',
|
||||
},
|
||||
passcode: {
|
||||
phone_email_empty: 'Both phone and email are empty.',
|
||||
|
|
Loading…
Add table
Reference in a new issue