统一钉钉通知标题与正文排版
This commit is contained in:
parent
5c9e53e159
commit
41b1c0c3dd
12
docs/接入指南.md
12
docs/接入指南.md
@ -47,7 +47,7 @@ YYD_NOTIFICATION_PROJECT_SECRET=由通知中心单独分配的项目密钥
|
||||
"idempotencyKey": "disposal_applied:53:approver-user-id:v1",
|
||||
"recipientUserId": "钉钉userId",
|
||||
"title": "资产报废待审批",
|
||||
"content": "### 待你审批\n\n**资产:** 摄影灯 A-102\n**申请人:** 张三",
|
||||
"content": "**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102\n\n**申请人:** 张三",
|
||||
"actionTitle": "立即审批",
|
||||
"actionUrl": "https://asset.jiansun.vip/workflows/disposals/53"
|
||||
}
|
||||
@ -59,23 +59,25 @@ YYD_NOTIFICATION_PROJECT_SECRET=由通知中心单独分配的项目密钥
|
||||
| --- | --- |
|
||||
| `idempotencyKey` | 必填,最长 160 字符;同一业务事件、同一收件人必须稳定不变 |
|
||||
| `recipientUserId` | 必填,只能使用企业通讯录中的钉钉 `userId` |
|
||||
| `title` | 必填,最长 100 字符;不要添加 `【由壹点】` 或项目名称 |
|
||||
| `content` | 必填,Markdown,最长 4800 字符 |
|
||||
| `title` | 必填,最长 100 字符;只写业务主题,不要添加 `【项目名】`,拼接项目名后的最终标题不能超过 120 字符 |
|
||||
| `content` | 必填,Markdown,业务正文最长 4800 字符;不要自行添加首行标题,各字段之间必须空一行,通知中心加上统一首行后的最终正文也不能超过 4800 字符 |
|
||||
| `actionTitle` | 可选,最长 20 字符,默认“查看详情” |
|
||||
| `actionUrl` | 必填,标准 443 端口的完整 HTTPS 地址,域名必须在项目白名单内 |
|
||||
|
||||
通知中心会自动生成最终标题:
|
||||
|
||||
```text
|
||||
【由壹点】{项目名称}·{业务标题}
|
||||
【{项目名称}】{业务标题}
|
||||
```
|
||||
|
||||
例如业务项目提交“资产报废待审批”,用户最终看到的是:
|
||||
|
||||
```text
|
||||
【由壹点】固定资产·资产报废待审批
|
||||
【固定资产】资产报废待审批
|
||||
```
|
||||
|
||||
卡片正文首行也由通知中心统一生成为 `### 【项目名称】业务标题`。为兼容已接入项目,业务正文如果仍以三级 Markdown 标题(`###`)开头,通知中心会先移除该旧标题,再生成统一首行,避免出现双标题;其它级别标题按正文原样保留。
|
||||
|
||||
## 5. 请求签名
|
||||
|
||||
每次请求必须携带:
|
||||
|
||||
@ -39,8 +39,9 @@
|
||||
- [ ] 无效钉钉 `userId` 能进入明确失败状态。
|
||||
- [ ] 重复发送同一请求不会产生第二条消息。
|
||||
- [ ] 同一幂等键提交不同内容会被拒绝。
|
||||
- [ ] 最终标题中的“由壹点”和项目来源正确。
|
||||
- [ ] 正文第一眼能看出当前状态和需要采取的动作。
|
||||
- [ ] 最终标题使用 `【项目名称】业务主题`,项目来源正确且醒目。
|
||||
- [ ] 正文首行与最终标题一致,时间紧随其后,各字段之间有空行。
|
||||
- [ ] 正文第一眼能看出业务主题、发生时间、核心内容和需要采取的动作。
|
||||
- [ ] 按钮名称明确,跳转到正确的白名单域名。
|
||||
- [ ] 能查询到 `sent / unread / read / failed` 状态。
|
||||
- [ ] 如启用免登,票据只能消费一次,且业务项目重新核验本地账号和权限。
|
||||
|
||||
77
docs/消息规范.md
77
docs/消息规范.md
@ -1,51 +1,80 @@
|
||||
# 由壹点通知消息规范
|
||||
|
||||
统一机器人解决的是“谁发出的”,项目名称解决的是“消息来自哪里”,业务标题和首屏内容解决的是“用户现在要做什么”。所有接入项目必须遵守本规范。
|
||||
统一机器人已经说明“由壹点发出的”,每条消息不再重复品牌名。项目名称解决“消息来自哪里”,业务标题、时间和首屏内容解决“发生了什么、用户现在要做什么”。所有接入项目必须遵守本规范。
|
||||
|
||||
## 1. 标题规则
|
||||
|
||||
最终标题由通知中心统一拼接:
|
||||
|
||||
```text
|
||||
【由壹点】项目名称·业务标题
|
||||
【项目名称】业务标题
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
- `【固定资产】资产报废待审批`
|
||||
- `【素材库】新的考核调研待填写`
|
||||
- `【产品开发】任务即将到期`
|
||||
|
||||
业务项目只提交业务标题,例如:
|
||||
|
||||
- `新的考核调研待填写`
|
||||
- `资产报废待审批`
|
||||
- `产品任务即将到期`
|
||||
|
||||
禁止:
|
||||
通知中心根据 `projectCode` 生成 `【项目名称】`,业务项目不能自行拼接。禁止:
|
||||
|
||||
- 自己添加 `【由壹点】`、机器人名称或项目名称。
|
||||
- 自己添加 `【项目名称】`、机器人名称或其它来源前缀。
|
||||
- 使用“通知”“消息提醒”“统一消息测试”等无法表达任务的空泛标题。
|
||||
- 在标题中堆积人员、日期、编号等详细字段。
|
||||
- 使用多个 emoji 或连续感叹号制造紧迫感。
|
||||
|
||||
## 2. 内容结构
|
||||
|
||||
消息第一屏按以下顺序组织:
|
||||
|
||||
1. 当前状态或用户动作。
|
||||
2. 最重要的业务对象。
|
||||
3. 截止时间或异常原因。
|
||||
4. 一句必要补充。
|
||||
|
||||
推荐模板:
|
||||
通知中心会把卡片正文首行统一生成为:
|
||||
|
||||
```markdown
|
||||
### 待你填写
|
||||
### 【项目名称】业务标题
|
||||
```
|
||||
|
||||
业务项目不要再提交自己的 Markdown 首行标题。为兼容旧项目,通知中心只会移除正文开头已有的三级 Markdown 标题(`###`),再生成统一首行;其它级别标题按正文原样保留。
|
||||
|
||||
消息第一屏按以下顺序组织:
|
||||
|
||||
1. 项目名称和业务主题,由通知中心统一生成。
|
||||
2. 申请时间、处理时间或截止时间。
|
||||
3. 最重要的业务对象和相关人员。
|
||||
4. 原因、结果或异常说明。
|
||||
5. 一句必要的行动提示。
|
||||
|
||||
业务项目推荐提交:
|
||||
|
||||
```markdown
|
||||
**截止时间:** 2026年7月31日 18:00
|
||||
|
||||
**调研:** 2026年7月 · 跨部门协同
|
||||
|
||||
**评价对象:** 无邪
|
||||
|
||||
**截止时间:** 7月31日 18:00
|
||||
> 请在截止时间前完成评分。
|
||||
```
|
||||
|
||||
通知中心最终渲染为:
|
||||
|
||||
```markdown
|
||||
### 【素材库】新的考核调研待填写
|
||||
|
||||
**截止时间:** 2026年7月31日 18:00
|
||||
|
||||
**调研:** 2026年7月 · 跨部门协同
|
||||
|
||||
**评价对象:** 无邪
|
||||
|
||||
> 请在截止时间前完成评分。
|
||||
```
|
||||
|
||||
每个字段之间必须保留一个空行。不要只使用单换行;钉钉会把连续字段合并成一段,明显降低阅读性。
|
||||
|
||||
不要写成一整段:
|
||||
|
||||
```text
|
||||
@ -105,31 +134,39 @@
|
||||
```text
|
||||
标题:新的考核调研待填写
|
||||
内容:
|
||||
### 待你填写
|
||||
**截止时间:** 2026年7月31日 18:00
|
||||
|
||||
**调研:** 2026年7月 · 工作配合度
|
||||
|
||||
**评价对象:** 无邪
|
||||
**截止时间:** 7月31日 18:00
|
||||
|
||||
> 请在截止时间前完成评分。
|
||||
|
||||
按钮:立即填写
|
||||
```
|
||||
|
||||
最终显示标题:`【由壹点】素材库·新的考核调研待填写`
|
||||
最终显示标题:`【素材库】新的考核调研待填写`
|
||||
|
||||
最终卡片首行:`### 【素材库】新的考核调研待填写`
|
||||
|
||||
### 固定资产
|
||||
|
||||
```text
|
||||
标题:资产报废待审批
|
||||
内容:
|
||||
### 待你审批
|
||||
**申请时间:** 2026年7月16日 14:30
|
||||
|
||||
**资产:** 摄影灯 A-102
|
||||
|
||||
**申请人:** 无邪
|
||||
**申请时间:** 7月16日 14:30
|
||||
|
||||
**报废原因:** 设备损坏,无法继续维修
|
||||
|
||||
> 请核对资产及配件明细后处理。
|
||||
|
||||
按钮:立即审批
|
||||
```
|
||||
|
||||
最终显示标题:`【由壹点】固定资产·资产报废待审批`
|
||||
最终显示标题:`【固定资产】资产报废待审批`
|
||||
|
||||
最终卡片首行:`### 【固定资产】资产报废待审批`
|
||||
|
||||
@ -19,11 +19,11 @@ const delivery = await client.send({
|
||||
recipientUserId,
|
||||
title: '资产报废待审批',
|
||||
content: [
|
||||
'### 待你审批',
|
||||
'',
|
||||
'**资产:** 摄影灯 A-102 ',
|
||||
'**申请人:** 无邪 ',
|
||||
'**申请时间:** 2026年7月16日 14:30',
|
||||
'',
|
||||
'**资产:** 摄影灯 A-102',
|
||||
'',
|
||||
'**申请人:** 无邪',
|
||||
].join('\n'),
|
||||
actionTitle: '立即审批',
|
||||
actionUrl: 'https://asset.jiansun.vip/workflows/disposals/53',
|
||||
|
||||
@ -34,7 +34,7 @@ test('发送通知使用业务正文原文签名并返回投递记录', async ()
|
||||
idempotencyKey: 'asset_disposal:53:user-1:v1',
|
||||
recipientUserId: 'user-1',
|
||||
title: '资产报废待审批',
|
||||
content: '### 待你审批',
|
||||
content: '**申请时间:** 2026年7月16日 14:30',
|
||||
actionTitle: '立即审批',
|
||||
actionUrl: 'https://asset.example.com/workflows/disposals/53',
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@ import test from 'node:test'
|
||||
import { AddressInfo } from 'node:net'
|
||||
import { loadProjectConfigs } from '../config'
|
||||
import prisma from '../prisma'
|
||||
import { createServer } from '../server'
|
||||
import { createCanonicalSignature } from '../signature'
|
||||
import { createServer, renderNotificationContent, renderNotificationTitle } from '../server'
|
||||
import { createCanonicalSignature, sha256 } from '../signature'
|
||||
|
||||
const projectSecret = '123456789012345678901234'
|
||||
const projects = loadProjectConfigs(JSON.stringify([
|
||||
@ -34,7 +34,7 @@ test('通知中心验证签名、项目域名和幂等键', async () => {
|
||||
idempotencyKey: 'disposal_applied:53:1',
|
||||
recipientUserId: 'ding-user-1',
|
||||
title: '待审批报废',
|
||||
content: '有一项报废申请等待审批',
|
||||
content: '### 待你审批\n\n**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102',
|
||||
actionTitle: '查看详情',
|
||||
actionUrl: 'https://asset.example.com/workflows/disposals/53',
|
||||
})
|
||||
@ -43,9 +43,14 @@ test('通知中心验证签名、项目域名和幂等键', async () => {
|
||||
const first = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body })
|
||||
assert.equal(first.status, 202)
|
||||
const firstPayload = await first.json() as any
|
||||
assert.equal(firstPayload.title, '【由壹点】固定资产·待审批报废')
|
||||
assert.equal(firstPayload.title, '【固定资产】待审批报废')
|
||||
const stored = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id: firstPayload.id } })
|
||||
assert.equal(stored.maxAttempts, 5)
|
||||
assert.equal(
|
||||
stored.content,
|
||||
'### 【固定资产】待审批报废\n\n**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102',
|
||||
)
|
||||
assert.doesNotMatch(stored.content, /待你审批/)
|
||||
|
||||
const second = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body })
|
||||
assert.equal(second.status, 200)
|
||||
@ -55,6 +60,133 @@ test('通知中心验证签名、项目域名和幂等键', async () => {
|
||||
const badBody = JSON.stringify({ ...JSON.parse(body), actionUrl: 'https://evil.example.com/path' })
|
||||
const rejected = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, badBody), body: badBody })
|
||||
assert.equal(rejected.status, 422)
|
||||
|
||||
const reservedTitleBody = JSON.stringify({ ...JSON.parse(body), idempotencyKey: 'reserved-title', title: '【固定资产】待审批报废' })
|
||||
const reservedTitle = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST', headers: headers('POST', path, reservedTitleBody), body: reservedTitleBody,
|
||||
})
|
||||
assert.equal(reservedTitle.status, 422)
|
||||
assert.deepEqual(await reservedTitle.json(), { error: 'TITLE_BRAND_RESERVED' })
|
||||
|
||||
const urgentTitleBody = JSON.stringify({ ...JSON.parse(body), idempotencyKey: 'urgent-title', title: '【紧急】资产报废待审批' })
|
||||
const urgentTitle = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST', headers: headers('POST', path, urgentTitleBody), body: urgentTitleBody,
|
||||
})
|
||||
assert.equal(urgentTitle.status, 202)
|
||||
assert.equal((await urgentTitle.json() as any).title, '【固定资产】【紧急】资产报废待审批')
|
||||
|
||||
const tooLongBody = JSON.stringify({
|
||||
...JSON.parse(body),
|
||||
idempotencyKey: 'rendered-content-too-long',
|
||||
content: '内'.repeat(4800),
|
||||
})
|
||||
const tooLong = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST', headers: headers('POST', path, tooLongBody), body: tooLongBody,
|
||||
})
|
||||
assert.equal(tooLong.status, 422)
|
||||
assert.deepEqual(await tooLong.json(), { error: 'MESSAGE_TOO_LONG' })
|
||||
|
||||
const legacyRequest = {
|
||||
idempotencyKey: 'legacy-existing',
|
||||
recipientUserId: 'ding-user-1',
|
||||
title: '【固定资产】旧版消息',
|
||||
content: '旧'.repeat(4800),
|
||||
actionTitle: '查看详情',
|
||||
actionUrl: 'https://asset.example.com/workflows/disposals/53',
|
||||
}
|
||||
const legacyRequestHash = sha256(JSON.stringify({
|
||||
recipientUserId: legacyRequest.recipientUserId,
|
||||
title: legacyRequest.title,
|
||||
content: legacyRequest.content,
|
||||
actionTitle: legacyRequest.actionTitle,
|
||||
actionUrl: legacyRequest.actionUrl,
|
||||
}))
|
||||
await prisma.notificationDelivery.create({
|
||||
data: {
|
||||
id: 'legacy-existing-delivery',
|
||||
projectCode: 'fixed_asset',
|
||||
idempotencyKey: legacyRequest.idempotencyKey,
|
||||
requestHash: legacyRequestHash,
|
||||
recipientUserId: legacyRequest.recipientUserId,
|
||||
businessTitle: legacyRequest.title,
|
||||
renderedTitle: `【由壹点】固定资产·${legacyRequest.title}`,
|
||||
content: legacyRequest.content,
|
||||
actionTitle: legacyRequest.actionTitle,
|
||||
actionUrl: legacyRequest.actionUrl,
|
||||
},
|
||||
})
|
||||
const legacyBody = JSON.stringify(legacyRequest)
|
||||
const legacyResponse = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST', headers: headers('POST', path, legacyBody), body: legacyBody,
|
||||
})
|
||||
assert.equal(legacyResponse.status, 200)
|
||||
const legacyPayload = await legacyResponse.json() as any
|
||||
assert.equal(legacyPayload.id, 'legacy-existing-delivery')
|
||||
assert.equal(legacyPayload.title, `【由壹点】固定资产·${legacyRequest.title}`)
|
||||
const unchangedLegacy = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id: 'legacy-existing-delivery' } })
|
||||
assert.equal(unchangedLegacy.content, legacyRequest.content)
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
test('通知标题突出项目,正文首行由通知中心统一生成', () => {
|
||||
assert.equal(renderNotificationTitle('固定资产', '资产报废待审批'), '【固定资产】资产报废待审批')
|
||||
assert.equal(
|
||||
renderNotificationContent('固定资产', '资产报废待审批', '**申请时间:** 2026年7月16日 14:30'),
|
||||
'### 【固定资产】资产报废待审批\n\n**申请时间:** 2026年7月16日 14:30',
|
||||
)
|
||||
assert.equal(
|
||||
renderNotificationContent('固定资产', '资产报废待审批', '### 待你审批\n\n正文'),
|
||||
'### 【固定资产】资产报废待审批\n\n正文',
|
||||
)
|
||||
assert.equal(
|
||||
renderNotificationContent('固定资产', '资产报废待审批', '### 【固定资产】资产报废待审批\n\n正文'),
|
||||
'### 【固定资产】资产报废待审批\n\n正文',
|
||||
)
|
||||
for (const heading of ['# 一级标题', '## 二级标题', '#### 四级标题']) {
|
||||
assert.equal(
|
||||
renderNotificationContent('固定资产', '资产报废待审批', `${heading}\n\n正文`),
|
||||
`### 【固定资产】资产报废待审批\n\n${heading}\n\n正文`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('渲染后标题长度边界由通知入口校验', async () => {
|
||||
const longName = '项'.repeat(20)
|
||||
const longSecret = 'abcdefghijklmnopqrstuvwxyz123456'
|
||||
const longProjects = loadProjectConfigs(JSON.stringify([
|
||||
{ code: 'long_project', name: longName, secret: longSecret, allowedHosts: ['long.example.com'] },
|
||||
]))
|
||||
const server = createServer(longProjects).listen(0)
|
||||
await new Promise<void>((resolve) => server.once('listening', resolve))
|
||||
const port = (server.address() as AddressInfo).port
|
||||
const path = '/v1/notifications'
|
||||
const body = JSON.stringify({
|
||||
idempotencyKey: 'rendered-title-too-long',
|
||||
recipientUserId: 'ding-user-1',
|
||||
title: '题'.repeat(100),
|
||||
content: '正文',
|
||||
actionTitle: '查看详情',
|
||||
actionUrl: 'https://long.example.com/detail',
|
||||
})
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = crypto.randomBytes(18).toString('base64url')
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-yyd-project-code': 'long_project',
|
||||
'x-yyd-timestamp': timestamp,
|
||||
'x-yyd-nonce': nonce,
|
||||
'x-yyd-signature': createCanonicalSignature({ secret: longSecret, timestamp, nonce, method: 'POST', path, body }),
|
||||
},
|
||||
body,
|
||||
})
|
||||
assert.equal(response.status, 422)
|
||||
assert.deepEqual(await response.json(), { error: 'MESSAGE_TOO_LONG' })
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
||||
await prisma.$disconnect()
|
||||
|
||||
@ -41,6 +41,53 @@ test('发送请求超时属于结果不确定并停止自动重试', async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test('机器人 ActionCard 原样发送统一标题、分段正文和按钮跳转', async () => {
|
||||
const originalFetch = global.fetch
|
||||
const oldAppKey = process.env.DINGTALK_APP_KEY
|
||||
const oldAppSecret = process.env.DINGTALK_APP_SECRET
|
||||
const oldRobotCode = process.env.DINGTALK_ROBOT_CODE
|
||||
process.env.DINGTALK_APP_KEY = 'test-app-key'
|
||||
process.env.DINGTALK_APP_SECRET = 'test-app-secret'
|
||||
process.env.DINGTALK_ROBOT_CODE = 'test-robot-code'
|
||||
let batchRequest: Record<string, any> | undefined
|
||||
global.fetch = (async (input, init) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/gettoken?')) {
|
||||
return new Response(JSON.stringify({ errcode: 0, access_token: 'test-token', expires_in: 7200 }), { status: 200 })
|
||||
}
|
||||
assert.equal(url, 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend')
|
||||
batchRequest = JSON.parse(String(init?.body || '{}'))
|
||||
return new Response(JSON.stringify({ processQueryKey: 'process-query-key-1' }), { status: 200 })
|
||||
}) as typeof fetch
|
||||
|
||||
const input = {
|
||||
recipientUserId: 'ding-user-1',
|
||||
title: '【固定资产】资产报废待审批',
|
||||
content: '### 【固定资产】资产报废待审批\n\n**申请时间:** 2026年7月16日 14:30\n\n**资产:** 主机(JS-PC-0010)',
|
||||
actionTitle: '立即审批',
|
||||
actionUrl: 'https://asset.example.com/workflows/disposals/53',
|
||||
}
|
||||
|
||||
try {
|
||||
assert.equal(await sendRobotMessage(input), 'process-query-key-1')
|
||||
assert.ok(batchRequest)
|
||||
assert.equal(batchRequest.robotCode, 'test-robot-code')
|
||||
assert.deepEqual(batchRequest.userIds, ['ding-user-1'])
|
||||
assert.equal(batchRequest.msgKey, 'sampleActionCard')
|
||||
assert.deepEqual(JSON.parse(batchRequest.msgParam), {
|
||||
title: input.title,
|
||||
text: input.content,
|
||||
singleTitle: input.actionTitle,
|
||||
singleURL: input.actionUrl,
|
||||
})
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
process.env.DINGTALK_APP_KEY = oldAppKey
|
||||
process.env.DINGTALK_APP_SECRET = oldAppSecret
|
||||
process.env.DINGTALK_ROBOT_CODE = oldRobotCode
|
||||
}
|
||||
})
|
||||
|
||||
test('明确限流可安全重试,确定性错误和耗尽次数则停止', () => {
|
||||
assert.equal(classifyDeliveryFailure({
|
||||
error: new DingTalkError('限流', 'FLOW_CONTROLLED'), attemptCount: 1, maxAttempts: 4,
|
||||
|
||||
@ -9,6 +9,21 @@ import { createCanonicalSignature, safeEqual, sha256 } from './signature'
|
||||
type SignedRequest = Request & { rawBody?: Buffer; project?: ProjectConfig }
|
||||
|
||||
const clean = (value: unknown) => String(value || '').trim()
|
||||
|
||||
export const renderNotificationTitle = (projectName: string, businessTitle: string) =>
|
||||
`【${clean(projectName)}】${clean(businessTitle)}`
|
||||
|
||||
export const renderNotificationContent = (
|
||||
projectName: string,
|
||||
businessTitle: string,
|
||||
businessContent: string,
|
||||
) => {
|
||||
const contentWithoutLegacyHeading = clean(businessContent)
|
||||
.replace(/^###(?!#)(?:[ \t]+[^\r\n]*)?(?:\r?\n|$)/, '')
|
||||
.replace(/^(?:\r?\n)+/, '')
|
||||
const heading = `### ${renderNotificationTitle(projectName, businessTitle)}`
|
||||
return contentWithoutLegacyHeading ? `${heading}\n\n${contentWithoutLegacyHeading}` : heading
|
||||
}
|
||||
const ticketTtlSeconds = () => {
|
||||
const configured = Number(process.env.DINGTALK_LOGIN_TICKET_TTL_SECONDS || 60)
|
||||
return Number.isInteger(configured) && configured >= 30 && configured <= 60 ? configured : 60
|
||||
@ -216,7 +231,6 @@ export const createServer = (projects: Map<string, ProjectConfig>, dependencies:
|
||||
if (title.length > 100 || content.length > 4800 || actionTitle.length > 20) return res.status(422).json({ error: 'MESSAGE_TOO_LONG' })
|
||||
if (title.startsWith('【由壹点')) return res.status(422).json({ error: 'TITLE_BRAND_RESERVED' })
|
||||
|
||||
const renderedTitle = `【由壹点】${project.name}·${title}`
|
||||
const normalized = { recipientUserId, title, content, actionTitle, actionUrl: destinationActionUrl }
|
||||
const requestHash = sha256(JSON.stringify(normalized))
|
||||
const existing = await prisma.notificationDelivery.findUnique({
|
||||
@ -227,6 +241,13 @@ export const createServer = (projects: Map<string, ProjectConfig>, dependencies:
|
||||
return res.status(200).json(deliveryView(existing))
|
||||
}
|
||||
|
||||
if (title.startsWith(`【${project.name}】`)) return res.status(422).json({ error: 'TITLE_BRAND_RESERVED' })
|
||||
const renderedTitle = renderNotificationTitle(project.name, title)
|
||||
const renderedContent = renderNotificationContent(project.name, title, content)
|
||||
if (renderedTitle.length > 120 || renderedContent.length > 4800) {
|
||||
return res.status(422).json({ error: 'MESSAGE_TOO_LONG' })
|
||||
}
|
||||
|
||||
const created = await prisma.notificationDelivery.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
@ -236,7 +257,7 @@ export const createServer = (projects: Map<string, ProjectConfig>, dependencies:
|
||||
recipientUserId,
|
||||
businessTitle: title,
|
||||
renderedTitle,
|
||||
content,
|
||||
content: renderedContent,
|
||||
actionTitle,
|
||||
actionUrl,
|
||||
maxAttempts: 5,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user