0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

feat(connector): add YunPian SMS connector (#6906)

* feat(connector): add YunPian SMS connector

* chore: update README and pnpm lock

* chore: update SVG and error messages

---------

Co-authored-by: Charles Zhao <charleszhao@silverhand.io>
This commit is contained in:
u0x01 2024-12-31 15:21:37 +08:00 committed by GitHub
parent 3fa2b796e6
commit 3004ae9a63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 603 additions and 1 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/connector-yunpian-sms": minor
---
add YunPian SMS connector

View file

@ -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",

View file

@ -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/)

View file

@ -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)

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<path fill="#3363fe"
d="M759.96,197.79c7.49,5.01,45.89,34.47,43.09,42.21-.75,2.09-2.7,2.88-4.8,2.73-10.38-8.49-21.16-16.2-32.35-23.13-32.52-17.36-67.36-30.96-104.34-35.45-112.89-13.71-245.06,47.72-260.53,170.28-21.9-14.71-44.76-26.08-70.95-31.14-57.56-11.13-124.06,6.22-168.97,44.01-142.52,119.92-40.88,305.7,137.29,264.19,129.29-30.12,200.64-189.22,293.95-269.18,109.37-93.72,323.64-75.27,359.35,82.93,32.34,143.27-106.65,264.28-240.35,272.03-9.53.55-44.43-3.54-48.6-1.01-1.46.89-11.57,23.12-14.23,27.41-74.07,119.23-282.01,141.14-394.2,68.8-24.36-14.32-46.88-32.44-63.45-55.52-.6-.7-1.39-1.27-1.98-1.98-5.08-7.68-13.98-24.59,0-11.9,3.11,3.7,4.28,4.82,7.93,7.93,5.73,5.45,11.68,10.74,17.85,15.86,14.73,9.91,32.06,20.84,48.24,28.1,127.57,57.3,310.97,3.75,336.46-147.07,2.75-1.89,28.2,15.59,34.3,18.22,124.32,53.63,302.59-55.7,268.47-197.08-24.89-103.12-152.35-126.67-239.06-89.54-60.41,25.86-122.86,120.6-165.81,171.28-59,69.61-129.67,131.66-224.11,142.72-159.74,18.71-282.41-116.51-206.62-270.08,39.5-80.03,132.23-134.89,220.54-140.34,17.07-1.05,33.67,2.41,50.63,1.05,67.56-151.6,296.6-175.17,422.27-86.33Z" />
<path fill="#4c6eda"
d="M823.42,265.2c4.23,7.92,1.09,8.58-3.97,1.98-14.67-18.96-35.29-32.46-53.54-47.59.67.36,3.43-.12,5.8,1.06,6.82,3.41,19.15,16.57,24.97,18.75,3.66,1.37,3.59-2.28,2.64-4.63-4.48-11.06-32.18-25.96-39.36-36.99,6.74,1.28,13.53,7.45,18.78,11.95,19.75,16.94,32.27,32.85,44.67,55.47Z" />
<path fill="#748ede" d="M254.33,812.47c-23.97-12.33-50.04-31.75-63.45-55.52,17.47,20.4,40.84,40.94,63.45,55.52Z" />
<path fill="#748ede"
d="M188.9,743.07c-1.63-1.16-4.97-3.67-5.97-.97.12,4.28,7.76,6.96,5.97,12.87-8.89-10.66-19.39-23.26-19.81-37.67.82-1.11,16.29,18.75,17.92,20.8,1.44,1.82,1.47,4.48,1.89,4.97Z" />
<path fill="#7c91d4"
d="M823.42,265.2c3.49,6.37,10.8,12.93,9.89,21.81-6.86-3.75-10.1-14.96-13.86-19.83,4.05,2.53,4.3,3.31,3.97-1.98Z" />
<path fill="#748ede" d="M214.68,766.87c-5.82-3.91-16.06-9.03-17.85-15.86,5.38,4.58,14.24,8.03,17.85,15.86Z" />
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,68 @@
{
"name": "@logto/connector-yunpian-sms",
"version": "1.0.0",
"description": "云片网 SMS connector implementation.",
"author": "Silverhand Inc. <contact@silverhand.io>",
"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"
}
}

View file

@ -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: '<api-key>',
},
{
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.',
},
],
};

View file

@ -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();
});
});
});

View file

@ -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<SmsConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
type: ConnectorType.Sms,
configGuard: yunpianSmsConfigGuard,
sendMessage: sendMessage(getConfig),
};
};
export default createYunpianSmsConnector;

View file

@ -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}}。如非本人操作,请忽略本短信',
},
],
};

View file

@ -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<typeof yunpianSmsConfigGuard>;
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(),
});

55
pnpm-lock.yaml generated
View file

@ -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':