diff --git a/.changeset/brown-donkeys-share.md b/.changeset/brown-donkeys-share.md new file mode 100644 index 000000000..8bcbc2198 --- /dev/null +++ b/.changeset/brown-donkeys-share.md @@ -0,0 +1,5 @@ +--- +"@logto/connector-yunpian-sms": minor +--- + +add YunPian SMS connector diff --git a/packages/connectors/connector-xiaomi/package.json b/packages/connectors/connector-xiaomi/package.json index dfec997d9..d121fed31 100644 --- a/packages/connectors/connector-xiaomi/package.json +++ b/packages/connectors/connector-xiaomi/package.json @@ -26,7 +26,6 @@ "build": "tsup", "dev": "tsup --watch", "lint": "eslint --ext .ts src", - "lint:fix": "eslint --ext .ts src --fix", "lint:report": "pnpm lint --format json --output-file report.json", "test": "vitest src", "test:ci": "pnpm run test --silent --coverage", diff --git a/packages/connectors/connector-yunpian-sms/README.md b/packages/connectors/connector-yunpian-sms/README.md new file mode 100644 index 000000000..415bcb5e1 --- /dev/null +++ b/packages/connectors/connector-yunpian-sms/README.md @@ -0,0 +1,60 @@ +# Yunpian SMS connector + +The official Logto connector for Yunpian SMS service. [中文文档](https://github.com/logto-io/logto/tree/master/packages/connectors/connector-yunpian-sms/README.zh-CN.md) + +**Table of contents** + +- [Yunpian SMS connector](#yunpian-sms-connector) + - [Get started](#get-started) + - [Set up SMS service in Yunpian Console](#set-up-sms-service-in-yunpian-console) + - [Create a Yunpian account](#create-a-yunpian-account) + - [Get API KEY](#get-api-key) + - [Configure SMS templates](#configure-sms-templates) + - [Configure in Logto](#configure-in-logto) + - [Notes](#notes) + - [References](#references) + +## Get started + +Yunpian is a communication service provider offering various services including SMS. The Yunpian SMS Connector is a plugin provided by the Logto team to integrate with Yunpian's SMS service, enabling Logto end-users to register and sign in via SMS verification codes. + +## Set up SMS service in Yunpian Console + +### Create a Yunpian account + +Visit [Yunpian's website](https://www.yunpian.com/) to register an account and complete real-name verification. + +### Get API KEY + +1. Log in to Yunpian Console +2. Go to "Account Settings" -> "Sub-account Management" +3. Find and copy the API KEY + +### Configure SMS templates + +1. In Yunpian Console, go to "Domestic SMS" -> "Signature Filing" +2. Create and submit a signature, wait for carrier approval +3. Go to "Domestic SMS" -> "Template Filing" and select "Verification Code" +4. Create a verification code template, ensure it includes the `#code#` variable (you can also use "Common Templates" to speed up the approval process) +5. Wait for template approval +6. If you need to send international SMS, repeat the above steps but select "International SMS" -> "Template Filing" + +## Configure in Logto + +1. In Logto Console, go to "Connectors" +2. Find and click "Yunpian SMS Service" +3. Fill in the configuration form: + - API KEY: The API KEY obtained from Yunpian + - SMS templates: Configure templates according to usage, ensure they match exactly with approved templates in Yunpian + +## Notes + +1. SMS template content must match exactly with the approved template in Yunpian +2. The verification code variable placeholder in Yunpian templates is `#code#`, while in the connector configuration it's `{{code}}` +3. Yunpian automatically adds the default signature based on API KEY, no need to include signature in the template content +4. It's recommended to test the configuration before formal use + +## References + +- [Yunpian Development Documentation](https://www.yunpian.com/official/document/sms/zh_CN/introduction_brief) +- [Logto SMS Connector Guide](https://docs.logto.io/docs/recipes/configure-connectors/sms-connector/) diff --git a/packages/connectors/connector-yunpian-sms/README.zh-CN.md b/packages/connectors/connector-yunpian-sms/README.zh-CN.md new file mode 100644 index 000000000..25d9984e5 --- /dev/null +++ b/packages/connectors/connector-yunpian-sms/README.zh-CN.md @@ -0,0 +1,60 @@ +# 云片网短信连接器 + +云片网短信服务 Logto 官方连接器 + +**目录** + +- [云片网短信连接器](#云片网短信连接器) + - [开始使用](#开始使用) + - [在云片网中配置](#在云片网中配置) + - [创建云片网账号](#创建云片网账号) + - [获取 API KEY](#获取-api-key) + - [配置短信模板](#配置短信模板) + - [在 Logto 中配置](#在-logto-中配置) + - [注意事项](#注意事项) + - [参考](#参考) + +## 开始使用 + +云片网是一家通信服务提供商,提供包括短信在内的多种通信服务。云片网 SMS 连接器是由 Logto 团队提供的插件,用于调用云片网的短信服务,帮助 Logto 终端用户通过短信验证码进行注册和登录。 + +## 在云片网中配置 + +### 创建云片网账号 + +访问[云片网官网](https://www.yunpian.com/),注册账号并完成实名认证。 + +### 获取 API KEY + +1. 登录云片网控制台 +2. 进入"账户设置" -> "子账户管理" +3. 找到并复制 API KEY + +### 配置短信模板 + +1. 在云片网控制台中进入"国内短信" -> "签名报备" +2. 创建签名并提交,等待运营商审核通过 +3. 在云片网控制台中进入"国内短信" -> "模板报备",选择"验证码类" +4. 创建验证码类短信模板,确保模板中包含 `#code#` 变量(也可以直接使用`常用模板`申请,加快审核速度) +5. 等待模板审核通过 +6. 如果您需要发送国际短信,请重复上述步骤,选择"国际短信" -> "模板报备"并提交 + +## 在 Logto 中配置 + +1. 在 Logto 管理控制台中转到"连接器" +2. 找到并点击"云片短信服务" +3. 在配置表单中填入: + - API KEY: 从云片网获取的 API KEY + - 短信模板: 按照用途配置相应的模板内容,确保与云片网审核通过的模板内容一致 + +## 注意事项 + +1. 短信模板内容必须与云片网审核通过的模板完全一致 +2. 短信审核模板中的验证码变量占位符为 `#code#`,模板配置中验证码变量占位符为 `{{code}}` +3. 云片网会自动根据 API KEY 添加默认签名,无需在发送模板内容中包含签名 +4. 建议在正式使用前进行测试,确保配置正确 + +## 参考 + +- [云片网开发文档](https://www.yunpian.com/official/document/sms/zh_CN/introduction_brief) +- [Logto SMS 连接器指南](https://docs.logto.io/zh-CN/connectors/sms-connectors) diff --git a/packages/connectors/connector-yunpian-sms/logo.svg b/packages/connectors/connector-yunpian-sms/logo.svg new file mode 100644 index 000000000..998c7376a --- /dev/null +++ b/packages/connectors/connector-yunpian-sms/logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/packages/connectors/connector-yunpian-sms/package.json b/packages/connectors/connector-yunpian-sms/package.json new file mode 100644 index 000000000..2c91d372b --- /dev/null +++ b/packages/connectors/connector-yunpian-sms/package.json @@ -0,0 +1,68 @@ +{ + "name": "@logto/connector-yunpian-sms", + "version": "1.0.0", + "description": "云片网 SMS connector implementation.", + "author": "Silverhand Inc. ", + "dependencies": { + "@logto/connector-kit": "workspace:^4.0.0", + "@silverhand/essentials": "^2.9.1", + "got": "^14.0.0", + "zod": "^3.23.8" + }, + "main": "./lib/index.js", + "module": "./lib/index.js", + "exports": "./lib/index.js", + "license": "MPL-2.0", + "type": "module", + "files": [ + "lib", + "docs", + "logo.svg", + "logo-dark.svg" + ], + "scripts": { + "precommit": "lint-staged", + "check": "tsc --noEmit", + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint --format json --output-file report.json", + "test": "vitest src", + "test:ci": "pnpm run test --silent --coverage", + "prepublishOnly": "pnpm build" + }, + "engines": { + "node": "^20.9.0" + }, + "eslintConfig": { + "extends": "@silverhand", + "settings": { + "import/core-modules": [ + "@silverhand/essentials", + "got", + "nock", + "snakecase-keys", + "zod" + ] + } + }, + "prettier": "@silverhand/eslint-config/.prettierrc", + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^2.1.8", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "supertest": "^7.0.0", + "tsup": "^8.3.0", + "typescript": "^5.5.3", + "vitest": "^2.1.8" + } +} diff --git a/packages/connectors/connector-yunpian-sms/src/constant.ts b/packages/connectors/connector-yunpian-sms/src/constant.ts new file mode 100644 index 000000000..613a7f5b0 --- /dev/null +++ b/packages/connectors/connector-yunpian-sms/src/constant.ts @@ -0,0 +1,72 @@ +import type { ConnectorMetadata } from '@logto/connector-kit'; +import { ConnectorConfigFormItemType } from '@logto/connector-kit'; + +export const endpoint = 'https://sms.yunpian.com/v2/sms/single_send.json'; + +export const defaultMetadata: ConnectorMetadata = { + id: 'yunpian-sms', + target: 'yunpian-sms', + platform: null, + name: { + en: 'YunPian SMS Service', + zh: '云片短信服务', + }, + logo: './logo.svg', + logoDark: null, + description: { + en: 'YunPian is a SMS service provider.', + zh: '云片网是一家短信服务提供商。', + }, + readme: './README.md', + formItems: [ + { + key: 'apikey', + label: 'API Key', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', + }, + { + key: 'templates', + label: 'SMS Template', + type: ConnectorConfigFormItemType.Json, + required: true, + defaultValue: [ + { + usageType: 'SignIn', + content: '您的验证码是 {{code}}。如非本人操作,请忽略本短信', + }, + { + usageType: 'Register', + content: '您的验证码是 {{code}}。如非本人操作,请忽略本短信', + }, + { + usageType: 'ForgotPassword', + content: '您的验证码是 {{code}}。如非本人操作,请忽略本短信', + }, + { + usageType: 'Generic', + content: '您的验证码是 {{code}}。如非本人操作,请忽略本短信', + }, + ], + }, + { + key: 'enableInternational', + label: 'Enable International SMS', + description: + '* To enable it, you need to apply for international templates at the same time.', + type: ConnectorConfigFormItemType.Switch, + required: false, + defaultValue: false, + }, + { + key: 'unsupportedCountriesMsg', + label: 'Unsupported Countries Error Message', + description: + 'The message to be displayed when the phone number is not supported. If left empty, no error will be returned.', + type: ConnectorConfigFormItemType.Text, + required: false, + defaultValue: 'The administrator has not enabled international SMS services.', + }, + ], +}; diff --git a/packages/connectors/connector-yunpian-sms/src/index.test.ts b/packages/connectors/connector-yunpian-sms/src/index.test.ts new file mode 100644 index 000000000..1d4942330 --- /dev/null +++ b/packages/connectors/connector-yunpian-sms/src/index.test.ts @@ -0,0 +1,54 @@ +import nock from 'nock'; + +import { TemplateType } from '@logto/connector-kit'; + +import { endpoint } from './constant.js'; +import createConnector from './index.js'; +import { mockedConfig } from './mock.js'; + +const getConfig = vi.fn().mockResolvedValue(mockedConfig); + +describe('yunpian SMS connector', () => { + it('init without throwing errors', async () => { + await expect(createConnector({ getConfig })).resolves.not.toThrow(); + }); + + describe('sendMessage()', async () => { + const connector = await createConnector({ getConfig }); + const { sendMessage } = connector; + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.enableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('should send message successfully', async () => { + const mockResponse = { + code: 0, + msg: '发送成功', + count: 1, + fee: 0.05, + unit: 'RMB', + mobile: '13800138000', + sid: 3_310_228_982, + }; + + nock(endpoint).post('').reply(200, mockResponse); + + await expect( + sendMessage({ + to: '13800138000', + type: TemplateType.Generic, + payload: { code: '1234' }, + }) + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/connectors/connector-yunpian-sms/src/index.ts b/packages/connectors/connector-yunpian-sms/src/index.ts new file mode 100644 index 000000000..ff53bd705 --- /dev/null +++ b/packages/connectors/connector-yunpian-sms/src/index.ts @@ -0,0 +1,127 @@ +import { assert } from '@silverhand/essentials'; +import { got, RequestError } from 'got'; + +import type { + GetConnectorConfig, + SendMessageFunction, + CreateConnector, + SmsConnector, +} from '@logto/connector-kit'; +import { + ConnectorError, + ConnectorErrorCodes, + validateConfig, + ConnectorType, + replaceSendMessageHandlebars, +} from '@logto/connector-kit'; + +import { defaultMetadata, endpoint } from './constant.js'; +import { + yunpianSmsConfigGuard, + type YunpianSmsPayload, + yunpianErrorResponseGuard, +} from './types.js'; + +const isChinaPhoneNumber = (phone: string) => { + // Match formats: + // 1. +86 followed by 11 digits, first digit must be 1 + // 2. 86 followed by 11 digits, first digit must be 1 + const pattern = /^(\+?86)1[3-9]\d{9}$/; + + return pattern.test(phone); +}; + +const formatPhoneNumber = (phoneNumber: string) => { + const phone = phoneNumber.replaceAll(/\s/g, ''); + + if (!isChinaPhoneNumber(phone)) { + if (!phone.startsWith('+')) { + return `+${phone}`; + } + return phone; + } + + // If it starts with +86 or 86, truncate the last 11 digits + if (phone.startsWith('+86')) { + return phone.slice(3); + } + if (phone.startsWith('86')) { + return phone.slice(2); + } + + return phone; +}; + +const sendMessage = + (getConfig: GetConnectorConfig): SendMessageFunction => + async (data, inputConfig) => { + const { to, type, payload } = data; + const config = inputConfig ?? (await getConfig(defaultMetadata.id)); + validateConfig(config, yunpianSmsConfigGuard); + const { apikey, templates, enableInternational, unsupportedCountriesMsg } = config; + + const template = templates.find((template) => template.usageType === type); + assert( + template, + new ConnectorError( + ConnectorErrorCodes.TemplateNotFound, + `No SMS template found for type ${type}` + ) + ); + + const messageContent = replaceSendMessageHandlebars(template.content, payload); + + const formattedPhone = formatPhoneNumber(to); + + if (!enableInternational && formattedPhone.startsWith('+')) { + if (unsupportedCountriesMsg) { + throw new ConnectorError(ConnectorErrorCodes.General, unsupportedCountriesMsg); + } else { + console.warn(`connector-yunpian-sms: unsupported phone number: ${formattedPhone}`); + return; + } + } + + const body: YunpianSmsPayload = { + apikey, + mobile: formattedPhone, + text: messageContent, + }; + + try { + return await got.post(endpoint, { + form: body, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Accept: 'application/json;charset=utf-8', + }, + }); + } catch (error: unknown) { + if ( + error instanceof RequestError && + error.response?.statusCode === 400 && + typeof error.response.body === 'string' + ) { + const errorBody = yunpianErrorResponseGuard.parse(JSON.parse(error.response.body)); + console.warn('connector-yunpian-sms: send error', errorBody); + + if (errorBody.msg) { + throw new ConnectorError(ConnectorErrorCodes.General, errorBody.msg); + } + } + + console.warn('connector-yunpian-sms: send unknown error', error); + throw new ConnectorError(ConnectorErrorCodes.General, `Unknown error: ${String(error)}`); + } + }; + +const createYunpianSmsConnector: CreateConnector = async ({ getConfig }) => { + return { + metadata: defaultMetadata, + type: ConnectorType.Sms, + configGuard: yunpianSmsConfigGuard, + sendMessage: sendMessage(getConfig), + }; +}; + +export default createYunpianSmsConnector; diff --git a/packages/connectors/connector-yunpian-sms/src/mock.ts b/packages/connectors/connector-yunpian-sms/src/mock.ts new file mode 100644 index 000000000..2a3f0bd14 --- /dev/null +++ b/packages/connectors/connector-yunpian-sms/src/mock.ts @@ -0,0 +1,23 @@ +import type { YunpianSmsConfig } from './types.js'; + +export const mockedConfig: YunpianSmsConfig = { + apikey: 'a123b456c789d0', + templates: [ + { + usageType: 'Generic', + content: '您的验证码是{{code}}。如非本人操作,请忽略本短信', + }, + { + usageType: 'SignIn', + content: '您的验证码是{{code}}。如非本人操作,请忽略本短信', + }, + { + usageType: 'Register', + content: '您的验证码是{{code}}。如非本人操作,请忽略本短信', + }, + { + usageType: 'ForgotPassword', + content: '您的验证码是{{code}}。如非本人操作,请忽略本短信', + }, + ], +}; diff --git a/packages/connectors/connector-yunpian-sms/src/types.ts b/packages/connectors/connector-yunpian-sms/src/types.ts new file mode 100644 index 000000000..934d528b0 --- /dev/null +++ b/packages/connectors/connector-yunpian-sms/src/types.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; + +const templateGuard = z.object({ + usageType: z.string(), + content: z.string(), +}); + +export const yunpianSmsConfigGuard = z.object({ + apikey: z.string(), + templates: z + .array(templateGuard) + .refine( + (templates) => + ['Register', 'SignIn', 'ForgotPassword', 'Generic'].every((type) => + templates.map((template) => template.usageType).includes(type) + ), + { + message: + 'Must provide all required template types (Register/SignIn/ForgotPassword/Generic)', + } + ), + enableInternational: z.boolean().optional(), + unsupportedCountriesMsg: z.string().optional(), +}); + +export type YunpianSmsConfig = z.infer; + +export type YunpianSmsPayload = { + apikey: string; + mobile: string; + text: string; +}; + +export type YunpianSmsResponse = { + code: number; + msg: string; + count: number; + fee: number; + unit: string; + mobile: string; + sid: number; +}; + +export const yunpianSmsResponseGuard = z.object({ + code: z.number(), + msg: z.string(), + count: z.number(), + fee: z.number(), + unit: z.string(), + mobile: z.string(), + sid: z.number(), +}); + +export type YunpianErrorResponse = { + http_status_code: number; + code: number; + msg: string; + detail?: string; +}; + +export const yunpianErrorResponseGuard = z.object({ + http_status_code: z.number(), + code: z.number(), + msg: z.string(), + detail: z.string().optional(), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca630024b..c334a7703 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2794,6 +2794,61 @@ importers: specifier: ^2.1.8 version: 2.1.8(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8) + packages/connectors/connector-yunpian-sms: + dependencies: + '@logto/connector-kit': + specifier: workspace:^4.0.0 + version: link:../../toolkit/connector-kit + '@silverhand/essentials': + specifier: ^2.9.1 + version: 2.9.2 + got: + specifier: ^14.0.0 + version: 14.0.0 + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@silverhand/eslint-config': + specifier: 6.0.1 + version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.5.3) + '@silverhand/ts-config': + specifier: 6.0.0 + version: 6.0.0(typescript@5.5.3) + '@types/node': + specifier: ^20.11.20 + version: 20.12.7 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.2 + '@vitest/coverage-v8': + specifier: ^2.1.8 + version: 2.1.8(vitest@2.1.8(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)) + eslint: + specifier: ^8.56.0 + version: 8.57.0 + lint-staged: + specifier: ^15.0.2 + version: 15.0.2 + nock: + specifier: ^13.3.1 + version: 13.3.1 + prettier: + specifier: ^3.0.0 + version: 3.0.0 + supertest: + specifier: ^7.0.0 + version: 7.0.0 + tsup: + specifier: ^8.3.0 + version: 8.3.0(@swc/core@1.3.52(@swc/helpers@0.5.1))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.5.3)(yaml@2.4.5) + typescript: + specifier: ^5.5.3 + version: 5.5.3 + vitest: + specifier: ^2.1.8 + version: 2.1.8(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8) + packages/console: devDependencies: '@fontsource/roboto-mono':