diff --git a/packages/connector-twilio-sms/README.md b/packages/connector-twilio-sms/README.md new file mode 100644 index 000000000..d65491f1b --- /dev/null +++ b/packages/connector-twilio-sms/README.md @@ -0,0 +1,2 @@ +### Twilio SMS README +placeholder diff --git a/packages/connector-twilio-sms/docs/config-template.md b/packages/connector-twilio-sms/docs/config-template.md new file mode 100644 index 000000000..50d4646bf --- /dev/null +++ b/packages/connector-twilio-sms/docs/config-template.md @@ -0,0 +1,21 @@ +```json +{ + "accountSID": "", + "authToken": "", + "fromMessagingServiceSID": "", + "templates": [ + { + "usageType": "SignIn", + "content": "This is for sign-in purposes only. Your passcode is {{code}}.", + }, + { + "usageType": "Register", + "content": "This is for registering purposes only. Your passcode is {{code}}.", + }, + { + "usageType": "Test", + "content": "This is for testing purposes only. Your passcode is {{code}}.", + } + ] +} +``` diff --git a/packages/connector-twilio-sms/jest.config.ts b/packages/connector-twilio-sms/jest.config.ts new file mode 100644 index 000000000..431024545 --- /dev/null +++ b/packages/connector-twilio-sms/jest.config.ts @@ -0,0 +1,8 @@ +import { Config, merge } from '@silverhand/jest-config'; + +const config: Config.InitialOptions = merge({ + testEnvironment: 'node', + setupFilesAfterEnv: ['jest-matcher-specific-error'], +}); + +export default config; diff --git a/packages/connector-twilio-sms/package.json b/packages/connector-twilio-sms/package.json new file mode 100644 index 000000000..c3244b15f --- /dev/null +++ b/packages/connector-twilio-sms/package.json @@ -0,0 +1,55 @@ +{ + "name": "@logto/connector-twilio-sms", + "version": "0.1.0", + "description": "Twilio SMS connector implementation.", + "main": "./lib/index.js", + "exports": "./lib/index.js", + "author": "Silverhand Inc. ", + "license": "MPL-2.0", + "files": [ + "lib", + "docs" + ], + "private": false, + "scripts": { + "preinstall": "npx only-allow pnpm", + "precommit": "lint-staged", + "build": "rm -rf lib/ && tsc -p tsconfig.build.json", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint --format json --output-file report.json", + "dev": "rm -rf lib/ && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"", + "test": "jest", + "test:coverage": "jest --coverage --silent", + "prepack": "pnpm build" + }, + "dependencies": { + "@logto/connector-types": "^0.1.0", + "@logto/shared": "^0.1.0", + "@silverhand/essentials": "^1.1.6", + "@silverhand/jest-config": "^0.14.0", + "got": "^11.8.2", + "zod": "^3.14.3" + }, + "devDependencies": { + "@jest/types": "^27.5.1", + "@silverhand/eslint-config": "^0.14.0", + "@silverhand/ts-config": "^0.14.0", + "@types/jest": "^27.4.1", + "@types/node": "^16.3.1", + "eslint": "^8.10.0", + "jest": "^27.5.1", + "jest-matcher-specific-error": "^1.0.0", + "lint-staged": "^12.0.0", + "prettier": "^2.3.2", + "ts-jest": "^27.1.1", + "tsc-watch": "^5.0.0", + "typescript": "^4.6.2" + }, + "engines": { + "node": "^16.0.0" + }, + "eslintConfig": { + "extends": "@silverhand" + }, + "prettier": "@silverhand/eslint-config/.prettierrc" +} diff --git a/packages/connector-twilio-sms/src/constant.ts b/packages/connector-twilio-sms/src/constant.ts new file mode 100644 index 000000000..db1c2f6d4 --- /dev/null +++ b/packages/connector-twilio-sms/src/constant.ts @@ -0,0 +1,30 @@ +import path from 'path'; + +import { ConnectorType, ConnectorMetadata } from '@logto/connector-types'; +import { getFileContents } from '@logto/shared'; + +export const endpoint = 'https://api.twilio.com/2010-04-01/Accounts/{{accountSID}}/Messages.json'; + +// eslint-disable-next-line unicorn/prefer-module +const currentPath = __dirname; +const pathToReadmeFile = path.join(currentPath, '..', 'README.md'); +const pathToConfigTemplate = path.join(currentPath, '..', 'docs', 'config-template.md'); +const readmeContentFallback = 'Please check README.md file directory.'; +const configTemplateFallback = 'Please check config-template.md file directory.'; + +export const defaultMetadata: ConnectorMetadata = { + target: 'twilio-sms', + type: ConnectorType.SMS, + platform: null, + name: { + en: 'Twilio SMS Service', + 'zh-CN': 'Twilio 短信服务', + }, + logo: './logo.png', + description: { + en: 'Messaging APIs for reliable SMS delivery.', + 'zh-CN': '可信赖的短信消息 API。', + }, + readme: getFileContents(pathToReadmeFile, readmeContentFallback), + configTemplate: getFileContents(pathToConfigTemplate, configTemplateFallback), +}; diff --git a/packages/connector-twilio-sms/src/index.test.ts b/packages/connector-twilio-sms/src/index.test.ts new file mode 100644 index 000000000..807b4133c --- /dev/null +++ b/packages/connector-twilio-sms/src/index.test.ts @@ -0,0 +1,23 @@ +import { GetConnectorConfig } from '@logto/connector-types'; + +import { TwilioSmsConnector } from '.'; +import { mockedConfig } from './mock'; +import { TwilioSmsConfig } from './types'; + +const getConnectorConfig = jest.fn() as GetConnectorConfig; + +const twilioSmsMethods = new TwilioSmsConnector(getConnectorConfig); + +describe('validateConfig()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass on valid config', async () => { + await expect(twilioSmsMethods.validateConfig(mockedConfig)).resolves.not.toThrow(); + }); + + it('throws if config is invalid', async () => { + await expect(twilioSmsMethods.validateConfig({})).rejects.toThrow(); + }); +}); diff --git a/packages/connector-twilio-sms/src/index.ts b/packages/connector-twilio-sms/src/index.ts new file mode 100644 index 000000000..0e6991718 --- /dev/null +++ b/packages/connector-twilio-sms/src/index.ts @@ -0,0 +1,67 @@ +import { + ConnectorError, + ConnectorErrorCodes, + ConnectorMetadata, + EmailSendMessageFunction, + ValidateConfig, + SmsConnector, + GetConnectorConfig, +} from '@logto/connector-types'; +import { assert } from '@silverhand/essentials'; +import got from 'got'; + +import { defaultMetadata, endpoint } from './constant'; +import { twilioSmsConfigGuard, SendSmsResponse, TwilioSmsConfig, PublicParameters } from './types'; + +export class TwilioSmsConnector implements SmsConnector { + public metadata: ConnectorMetadata = defaultMetadata; + + public readonly getConfig: GetConnectorConfig; + + constructor(getConnectorConfig: GetConnectorConfig) { + this.getConfig = getConnectorConfig; + } + + public validateConfig: ValidateConfig = async (config: unknown) => { + const result = twilioSmsConfigGuard.safeParse(config); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); + } + }; + + public sendMessage: EmailSendMessageFunction = async (address, type, data) => { + const config = await this.getConfig(this.metadata.target, this.metadata.platform); + await this.validateConfig(config); + const { accountSID, authToken, fromMessagingServiceSID, templates } = config; + const template = templates.find((template) => template.usageType === type); + + assert( + template, + new ConnectorError( + ConnectorErrorCodes.TemplateNotFound, + `Cannot find template for type: ${type}` + ) + ); + + const parameters: PublicParameters = { + To: address, + MessagingServiceSid: fromMessagingServiceSID, + Body: + typeof data.code === 'string' + ? template.content.replace(/{{code}}/g, data.code) + : template.content, + }; + + return got + .post(endpoint.replace(/{{accountSID}}/g, accountSID), { + headers: { + Authorization: + 'Basic ' + Buffer.from([accountSID, authToken].join(':')).toString('base64'), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(parameters).toString(), + }) + .json(); + }; +} diff --git a/packages/connector-twilio-sms/src/mock.ts b/packages/connector-twilio-sms/src/mock.ts new file mode 100644 index 000000000..f008ff8ee --- /dev/null +++ b/packages/connector-twilio-sms/src/mock.ts @@ -0,0 +1,17 @@ +import { TwilioSmsConfig } from './types'; + +const mockedAccountSID = 'account-sid'; +const mockedAuthToken = 'auth-token'; +const mockedFromMessagingServiceSID = 'from-messaging-service-sid'; + +export const mockedConfig: TwilioSmsConfig = { + accountSID: mockedAccountSID, + authToken: mockedAuthToken, + fromMessagingServiceSID: mockedFromMessagingServiceSID, + templates: [ + { + usageType: 'Test', + content: 'This is for testing purposes only. Your passcode is {{code}}.', + }, + ], +}; diff --git a/packages/connector-twilio-sms/src/types.ts b/packages/connector-twilio-sms/src/types.ts new file mode 100644 index 000000000..bae2f6f09 --- /dev/null +++ b/packages/connector-twilio-sms/src/types.ts @@ -0,0 +1,63 @@ +import { Nullable } from '@silverhand/essentials'; +import { z } from 'zod'; + +/** + * @doc https://www.twilio.com/docs/sms/send-messages + * + * @doc https://www.twilio.com/docs/phone-numbers + * @doc https://www.twilio.com/phone-numbers/global-catalog + * @doc https://en.wikipedia.org/wiki/E.164 + */ + +export type PublicParameters = { + To: string; + MessagingServiceSid: string; + Body: string; +}; + +/** + * 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(), + content: z.string(), +}); + +export const twilioSmsConfigGuard = z.object({ + accountSID: z.string(), + authToken: z.string(), + fromMessagingServiceSID: z.string(), + templates: z.array(templateGuard), +}); + +export type TwilioSmsConfig = z.infer; + +export type SendSmsResponse = { + account_sid: string; + api_version: string; + body: string; + code: number; + date_cereated: Nullable; + date_sent: Nullable; + date_updated: Nullable; + direction: string; + error_code: Nullable; + error_message: Nullable; + from: Nullable; + message: Nullable; + messaging_service_sid: string; + more_info: Nullable; + num_media: string; + num_segments: string; + price: Nullable; + price_unit: Nullable; + sid: string; + status: number; + subresource_uris: { + media?: string; + feedback?: string; + }; + to: string; + uri: string; +}; diff --git a/packages/connector-twilio-sms/tsconfig.base.json b/packages/connector-twilio-sms/tsconfig.base.json new file mode 100644 index 000000000..848a915f7 --- /dev/null +++ b/packages/connector-twilio-sms/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/packages/connector-twilio-sms/tsconfig.build.json b/packages/connector-twilio-sms/tsconfig.build.json new file mode 100644 index 000000000..d42923dd3 --- /dev/null +++ b/packages/connector-twilio-sms/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.base", + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/connector-twilio-sms/tsconfig.json b/packages/connector-twilio-sms/tsconfig.json new file mode 100644 index 000000000..20354364a --- /dev/null +++ b/packages/connector-twilio-sms/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base", + "compilerOptions": { + "types": ["node", "jest", "jest-matcher-specific-error"] + }, + "include": ["src", "jest.config.ts"] +} diff --git a/packages/connector-twilio-sms/tsconfig.test.json b/packages/connector-twilio-sms/tsconfig.test.json new file mode 100644 index 000000000..98c16f367 --- /dev/null +++ b/packages/connector-twilio-sms/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "isolatedModules": false + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 25a5316a2..d1a27b6d0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,6 +27,7 @@ "@logto/connector-github": "^0.1.0", "@logto/connector-google": "^0.1.0", "@logto/connector-sendgrid-email": "^0.1.0", + "@logto/connector-twilio-sms": "^0.1.0", "@logto/connector-types": "^0.1.0", "@logto/connector-wechat": "^0.1.0", "@logto/connector-wechat-native": "^0.1.0", diff --git a/packages/core/src/connectors/index.test.ts b/packages/core/src/connectors/index.test.ts index fa9c0f80c..bd57b4095 100644 --- a/packages/core/src/connectors/index.test.ts +++ b/packages/core/src/connectors/index.test.ts @@ -67,6 +67,14 @@ const sendGridMailConnector = { config: {}, createdAt: 1_646_382_233_111, }; +const twilioSmsConnector = { + id: 'twilio-sms', + target: 'twilio-sms', + platform: null, + enabled: false, + config: {}, + createdAt: 1_646_382_233_000, +}; const wechatConnector = { id: 'wechat', target: 'wechat', @@ -92,6 +100,7 @@ const connectors = [ githubConnector, googleConnector, sendGridMailConnector, + twilioSmsConnector, wechatConnector, wechatNativeConnector, ]; @@ -116,8 +125,9 @@ describe('getConnectorInstances', () => { expect(connectorInstances[4]).toHaveProperty('connector', githubConnector); expect(connectorInstances[5]).toHaveProperty('connector', googleConnector); expect(connectorInstances[6]).toHaveProperty('connector', sendGridMailConnector); - expect(connectorInstances[7]).toHaveProperty('connector', wechatConnector); - expect(connectorInstances[8]).toHaveProperty('connector', wechatNativeConnector); + expect(connectorInstances[7]).toHaveProperty('connector', twilioSmsConnector); + expect(connectorInstances[8]).toHaveProperty('connector', wechatConnector); + expect(connectorInstances[9]).toHaveProperty('connector', wechatNativeConnector); }); test('should throw if any required connector does not exist in DB', async () => { diff --git a/packages/core/src/connectors/index.ts b/packages/core/src/connectors/index.ts index 1df3aa383..4f9f5c247 100644 --- a/packages/core/src/connectors/index.ts +++ b/packages/core/src/connectors/index.ts @@ -5,6 +5,7 @@ import { FacebookConnector } from '@logto/connector-facebook'; import { GithubConnector } from '@logto/connector-github'; import { GoogleConnector } from '@logto/connector-google'; import { SendGridMailConnector } from '@logto/connector-sendgrid-email'; +import { TwilioSmsConnector } from '@logto/connector-twilio-sms'; import { WeChatConnector } from '@logto/connector-wechat'; import { WeChatNativeConnector } from '@logto/connector-wechat-native'; import { nanoid } from 'nanoid'; @@ -23,6 +24,7 @@ const allConnectors: IConnector[] = [ new GithubConnector(getConnectorConfig), new GoogleConnector(getConnectorConfig), new SendGridMailConnector(getConnectorConfig), + new TwilioSmsConnector(getConnectorConfig), new WeChatConnector(getConnectorConfig), new WeChatNativeConnector(getConnectorConfig), ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b966180f3..83238e49d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -349,6 +349,49 @@ importers: tsc-watch: 5.0.3_typescript@4.6.4 typescript: 4.6.4 + packages/connector-twilio-sms: + specifiers: + '@jest/types': ^27.5.1 + '@logto/connector-types': ^0.1.0 + '@logto/shared': ^0.1.0 + '@silverhand/eslint-config': ^0.14.0 + '@silverhand/essentials': ^1.1.6 + '@silverhand/jest-config': ^0.14.0 + '@silverhand/ts-config': ^0.14.0 + '@types/jest': ^27.4.1 + '@types/node': ^16.3.1 + eslint: ^8.10.0 + got: ^11.8.2 + jest: ^27.5.1 + jest-matcher-specific-error: ^1.0.0 + lint-staged: ^12.0.0 + prettier: ^2.3.2 + ts-jest: ^27.1.1 + tsc-watch: ^5.0.0 + typescript: ^4.6.2 + zod: ^3.14.3 + dependencies: + '@logto/connector-types': link:../connector-types + '@logto/shared': link:../shared + '@silverhand/essentials': 1.1.7 + '@silverhand/jest-config': 0.14.0_53ggqi2i4rbcfjtktmjua6zili + got: 11.8.3 + zod: 3.14.3 + devDependencies: + '@jest/types': 27.5.1 + '@silverhand/eslint-config': 0.14.0_rqoong6vegs374egqglqjbgiwm + '@silverhand/ts-config': 0.14.0_typescript@4.6.4 + '@types/jest': 27.4.1 + '@types/node': 16.11.12 + eslint: 8.10.0 + jest: 27.5.1 + jest-matcher-specific-error: 1.0.0 + lint-staged: 12.4.0 + prettier: 2.5.1 + ts-jest: 27.1.1_53ggqi2i4rbcfjtktmjua6zili + tsc-watch: 5.0.3_typescript@4.6.4 + typescript: 4.6.4 + packages/connector-types: specifiers: '@jest/types': ^27.5.1 @@ -599,6 +642,7 @@ importers: '@logto/connector-github': ^0.1.0 '@logto/connector-google': ^0.1.0 '@logto/connector-sendgrid-email': ^0.1.0 + '@logto/connector-twilio-sms': ^0.1.0 '@logto/connector-types': ^0.1.0 '@logto/connector-wechat': ^0.1.0 '@logto/connector-wechat-native': ^0.1.0 @@ -671,6 +715,7 @@ importers: '@logto/connector-github': link:../connector-github '@logto/connector-google': link:../connector-google '@logto/connector-sendgrid-email': link:../connector-sendgrid-mail + '@logto/connector-twilio-sms': link:../connector-twilio-sms '@logto/connector-types': link:../connector-types '@logto/connector-wechat': link:../connector-wechat '@logto/connector-wechat-native': link:../connector-wechat-native