瀏覽代碼

统一推送提醒为可读文本

LSamien 2 月之前
父節點
當前提交
db59ca635b

+ 25 - 1
src/server/apns.ts

@@ -13,6 +13,7 @@ import {
 } from 'node:crypto'
 import { readFileSync } from 'node:fs'
 import type { APNsProviderConfig } from './config.js'
+import { notificationPlainText } from './notificationText.js'
 
 export type APNsEnvironment = 'development' | 'production'
 
@@ -73,6 +74,29 @@ const UNREGISTER_REASONS = new Set([
   'Unregistered',
 ])
 
+function sanitizedPayload(payload: Record<string, unknown>): Record<string, unknown> {
+  const aps = payload.aps
+  if (!aps || typeof aps !== 'object' || Array.isArray(aps)) return payload
+  const alert = (aps as Record<string, unknown>).alert
+  if (!alert || typeof alert !== 'object' || Array.isArray(alert)) return payload
+  const record = alert as Record<string, unknown>
+  return {
+    ...payload,
+    aps: {
+      ...(aps as Record<string, unknown>),
+      alert: {
+        ...record,
+        ...(typeof record.title === 'string' ? {
+          title: notificationPlainText(record.title, { fallback: '新消息', maximum: 120 }),
+        } : {}),
+        ...(typeof record.body === 'string' ? {
+          body: notificationPlainText(record.body, { fallback: '请打开夭夭查看详情', maximum: 180 }),
+        } : {}),
+      },
+    },
+  }
+}
+
 function encodedJSON(value: unknown): string {
   return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')
 }
@@ -243,7 +267,7 @@ export class APNsProvider {
     if (!TOKEN_RE.test(request.deviceToken)) {
       return { disposition: 'unregister', status: 0, reason: 'BadDeviceToken' }
     }
-    const body = Buffer.from(JSON.stringify(request.payload), 'utf8')
+    const body = Buffer.from(JSON.stringify(sanitizedPayload(request.payload)), 'utf8')
     if (body.length > APNS_MAX_PAYLOAD_BYTES) {
       return { disposition: 'failed', status: 0, reason: 'PayloadTooLarge' }
     }

+ 134 - 0
src/server/notificationText.ts

@@ -0,0 +1,134 @@
+import { basename } from 'node:path'
+import MarkdownIt from 'markdown-it'
+import type Token from 'markdown-it/lib/token.mjs'
+
+export interface NotificationTextOptions {
+  fallback: string
+  maximum: number
+}
+
+const MAXIMUM_SOURCE_CHARACTERS = 16 * 1_024
+const markdown = new MarkdownIt({
+  html: false,
+  breaks: false,
+  linkify: false,
+  typographer: false,
+})
+markdown.disable('smartquotes')
+const graphemeSegmenter = new Intl.Segmenter('zh-CN', { granularity: 'grapheme' })
+
+function sourcePrefix(value: string): string {
+  if (value.length <= MAXIMUM_SOURCE_CHARACTERS) return value
+  let result = value.slice(0, MAXIMUM_SOURCE_CHARACTERS)
+  const last = result.charCodeAt(result.length - 1)
+  if (last >= 0xD800 && last <= 0xDBFF) result = result.slice(0, -1)
+  return result
+}
+
+function inlineText(tokens: readonly Token[]): string {
+  const result: string[] = []
+  for (const token of tokens) {
+    if (token.type === 'text') result.push(token.content)
+    else if (token.type === 'code_inline') result.push(token.content)
+    else if (token.type === 'softbreak' || token.type === 'hardbreak') result.push(' ')
+    else if (token.type === 'image') {
+      const alt = inlineText(token.children ?? []).trim() || token.content.trim() || '图片'
+      result.push(`🖼️ ${alt}`)
+    } else if (token.type !== 'html_inline' && token.children?.length) {
+      result.push(inlineText(token.children))
+    }
+  }
+  return result.join('')
+}
+
+function markdownText(value: string): string {
+  const result: string[] = []
+  for (const token of markdown.parse(value, {})) {
+    if (token.type === 'inline') {
+      const text = inlineText(token.children ?? []).trim()
+      if (text) result.push(text)
+    } else if (token.type === 'fence' || token.type === 'code_block') {
+      result.push('💻 代码片段')
+    } else if (token.type === 'list_item_open') {
+      result.push('•')
+    } else if (token.type === 'th_open' || token.type === 'td_open') {
+      result.push('•')
+    } else if (token.type === 'hr') {
+      result.push('•')
+    }
+  }
+  return result.join(' ')
+}
+
+function visibleBasename(value: string): string {
+  const unwrapped = value.replace(/^<|>$/g, '').replace(/[\])}>,.;,。;:]+$/u, '')
+  try {
+    const decoded = decodeURIComponent(unwrapped)
+    return basename(decoded) || '附件'
+  } catch {
+    return basename(unwrapped) || '附件'
+  }
+}
+
+function replaceHermesReferences(value: string): string {
+  return value
+    .replace(/@(terminal|tool):[^\r\n]*/giu, (_match, kind: string) => (
+      kind.toLowerCase() === 'terminal' ? '💻 终端信息' : '🛠️ 工具信息'
+    ))
+    .replace(/@(file|folder|image):\s*([^\s]+)/giu, (_match, kind: string, target: string) => {
+      const icon = kind.toLowerCase() === 'image' ? '🖼️' : kind.toLowerCase() === 'folder' ? '📁' : '📎'
+      return `${icon} ${visibleBasename(target)}`
+    })
+    .replace(/@url:\s*[^\s]+/giu, '🔗 链接')
+}
+
+function stripUnsafeTechnicalText(value: string): string {
+  return value
+    .replace(/\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/gu, '')
+    .split(/\r?\n/u)
+    .filter(line => !/^\s*at\s+(?:async\s+)?[^\s]+.*(?:\([^)]*:\d+:\d+\)|:\d+:\d+)\s*$/u.test(line)
+      && !/^\s*File\s+"[^"]+",\s+line\s+\d+/u.test(line))
+    .join('\n')
+}
+
+function redactRawLocations(value: string): string {
+  return value
+    .replace(/\bhttps?:\/\/[^\s<>()]+/giu, '🔗 链接')
+    .replace(/\bdata:[^\s]+/giu, '🖼️ 图片')
+    .replace(/(^|[\s((::])((?:\/[^\s/]+){2,})/gu, (_match, prefix: string, target: string) => (
+      `${prefix}📎 ${visibleBasename(target)}`
+    ))
+    .replace(/\[图片:([^\]]+)\]/gu, '🖼️ $1')
+    .replace(/\[文件:([^\]]+)\]/gu, '📎 $1')
+}
+
+function normalizeVisibleText(value: string): string {
+  return value
+    .normalize('NFC')
+    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu, '')
+    .replace(/[\u200B\u202A-\u202E\u2060\u2066-\u2069\uFEFF]/gu, '')
+    .replace(/<\/?[A-Za-z][^>]*>/gu, ' ')
+    .replace(/(?:\s*•\s*){2,}/gu, ' • ')
+    .replace(/\s+/gu, ' ')
+    .trim()
+}
+
+function truncateGraphemes(value: string, maximum: number): string {
+  const segments = [...graphemeSegmenter.segment(value)].map(segment => segment.segment)
+  if (segments.length <= maximum) return value
+  return `${segments.slice(0, Math.max(0, maximum - 1)).join('')}…`
+}
+
+/** Converts model-authored Markdown into one safe, visible notification line. */
+export function notificationPlainText(
+  raw: unknown,
+  options: NotificationTextOptions,
+): string {
+  const source = typeof raw === 'string' ? sourcePrefix(raw).trim() : ''
+  if (!source) return options.fallback
+  const prepared = replaceHermesReferences(stripUnsafeTechnicalText(source))
+  const rendered = markdownText(prepared)
+  const normalized = normalizeVisibleText(redactRawLocations(rendered))
+  if (!normalized) return options.fallback
+  return truncateGraphemes(normalized, options.maximum)
+}

+ 2 - 3
src/server/pushEventAdapter.ts

@@ -9,6 +9,7 @@ import {
   type PushNotificationCandidate,
 } from './pushEvents.js'
 import { PushCoordinator } from './pushCoordinator.js'
+import { notificationPlainText } from './notificationText.js'
 
 type JsonRecord = Record<string, unknown>
 
@@ -38,9 +39,7 @@ function timestampMilliseconds(value: unknown): number | undefined {
 }
 
 function summary(value: unknown, fallback: string): string {
-  const normalized = text(value).replace(/\s+/g, ' ')
-  if (!normalized) return fallback
-  return Array.from(normalized).length > 180 ? `${Array.from(normalized).slice(0, 179).join('')}…` : normalized
+  return notificationPlainText(value, { fallback, maximum: 180 })
 }
 
 function collapseID(prefix: string, value: string): string {

+ 2 - 3
src/server/pushEvents.ts

@@ -4,6 +4,7 @@ import { isSupportedGroupProtocolVersion } from '../shared/types.js'
 import type { ServerConfig } from './config.js'
 import type { UpstreamServiceSession } from './localAuth.js'
 import type { PushCoordinator as PersistentPushCoordinator } from './pushCoordinator.js'
+import { notificationPlainText } from './notificationText.js'
 
 type MaybePromise<T> = T | Promise<T>
 type JsonRecord = Record<string, unknown>
@@ -187,9 +188,7 @@ function errorMessage(value: unknown): string {
 }
 
 function summary(value: unknown, fallback: string): string {
-  const normalized = string(value).replace(/\s+/g, ' ')
-  if (!normalized) return fallback
-  return normalized.length > 180 ? `${normalized.slice(0, 179)}…` : normalized
+  return notificationPlainText(value, { fallback, maximum: 180 })
 }
 
 function eventTimestamp(value: unknown): number | undefined {

+ 7 - 2
tests/server/apns.test.ts

@@ -88,7 +88,10 @@ describe('APNsProvider', () => {
     await expect(provider.send({
       deviceToken: 'ab'.repeat(16),
       environment: 'development',
-      payload: { aps: { alert: '完成' } },
+      payload: { aps: { alert: {
+        title: '## 完成 ✅',
+        body: '**报告** [下载](https://example.com/private)',
+      } } },
       collapseId: 'chat-1',
     })).resolves.toMatchObject({ disposition: 'success', apnsId: 'returned-id' })
     await provider.send({
@@ -103,7 +106,9 @@ describe('APNsProvider', () => {
     expect(calls[0]!.headers['apns-topic']).toBe(config.topic)
     expect(calls[0]!.headers['apns-push-type']).toBe('alert')
     expect(calls[0]!.headers.authorization).toMatch(/^bearer /)
-    expect(JSON.parse(Buffer.from(calls[0]!.body).toString('utf8'))).toEqual({ aps: { alert: '完成' } })
+    expect(JSON.parse(Buffer.from(calls[0]!.body).toString('utf8'))).toEqual({
+      aps: { alert: { title: '完成 ✅', body: '报告 下载' } },
+    })
   })
 
   it('does not contact APNs for malformed or oversized requests and retries transport failures', async () => {

+ 109 - 0
tests/server/notificationText.test.ts

@@ -0,0 +1,109 @@
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { notificationPlainText } from '../../src/server/notificationText.js'
+import { PushCoordinator, type PushSender } from '../../src/server/pushCoordinator.js'
+import type { APNsRequest, APNsSendResult } from '../../src/server/apns.js'
+
+const roots: string[] = []
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function text(value: string, maximum = 180): string {
+  return notificationPlainText(value, { fallback: '新消息', maximum })
+}
+
+class RecordingSender implements PushSender {
+  requests: APNsRequest[] = []
+  async send(request: APNsRequest): Promise<APNsSendResult> {
+    this.requests.push(request)
+    return { disposition: 'success', status: 200 }
+  }
+}
+
+describe('notification plain text', () => {
+  it('keeps emoji and decorative bullets while removing Markdown syntax and link targets', () => {
+    const result = text(`
+## 部署完成 ✅
+
+- 已发布 **v0.2.15**
+- [查看报告](https://example.com/report?token=secret)
+- ![运行截图](https://example.com/screenshot.png)
+`)
+
+    expect(result).toContain('部署完成 ✅')
+    expect(result).toContain('• 已发布 v0.2.15')
+    expect(result).toContain('查看报告')
+    expect(result).toContain('🖼️ 运行截图')
+    expect(result).not.toMatch(/[#*_`~]|https?:\/\//u)
+  })
+
+  it('summarizes code, paths, attachments, raw URLs, stacks, HTML, and unsafe controls', () => {
+    const result = text(`
+<b>构建失败 ⚠️</b>
+
+\`inline --flag\`
+
+\`\`\`bash
+npm run build
+\`\`\`
+
+@file:/Users/samien/project/report.pdf
+详情 https://example.com/private
+Error: missing /Users/samien/project/config.json
+    at run (/Users/samien/project/app.js:12:3)
+\u001b[31m红色\u001b[0m\u202E
+`)
+
+    expect(result).toContain('构建失败 ⚠️')
+    expect(result).toContain('inline --flag')
+    expect(result).toContain('💻 代码片段')
+    expect(result).toContain('📎 report.pdf')
+    expect(result).toContain('📎 config.json')
+    expect(result).toContain('🔗 链接')
+    expect(result).toContain('红色')
+    expect(result).not.toContain('/Users/')
+    expect(result).not.toContain('at run')
+    expect(result).not.toContain('\u001b')
+    expect(result).not.toContain('\u202E')
+  })
+
+  it('is idempotent and truncates only at complete grapheme boundaries', () => {
+    const source = '✅ 👨‍👩‍👧‍👦 完成 • 报告'
+    const once = text(source, 8)
+    const twice = text(once, 8)
+    expect(twice).toBe(once)
+    expect(once).not.toContain('�')
+    expect([...new Intl.Segmenter('zh-CN', { granularity: 'grapheme' }).segment(once)].length).toBeLessThanOrEqual(8)
+  })
+
+  it('sanitizes notification text before persisting and delivering new outbox rows', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-notification-text-'))
+    roots.push(home)
+    const sender = new RecordingSender()
+    const config = {
+      keyFile: '/unused.p8', keyId: 'KEY1234567', teamId: 'TEAM123456',
+      topic: 'cn.samien.yaoyao.hermes', environments: ['development' as const],
+    }
+    const coordinator = new PushCoordinator({ home, apns: config, provider: sender, autoFlush: false })
+    coordinator.registerInstallation({
+      userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
+      deviceToken: 'ab'.repeat(32), environment: 'development',
+    })
+    expect(coordinator.enqueue({
+      eventId: 'event-a', userId: 'user-a', kind: 'chat.completed',
+      title: '## 完成 ✅', body: '**报告** [下载](https://example.com/secret)',
+    })).toBe(1)
+    const statePath = join(home, 'push', 'state.json')
+    const state = JSON.parse(readFileSync(statePath, 'utf8')) as { outbox: Array<{ title: string; body: string }> }
+    expect(state.outbox[0]).toMatchObject({ title: '完成 ✅', body: '报告 下载' })
+
+    await coordinator.flushDue()
+    const alert = (sender.requests[0]!.payload.aps as { alert: { title: string; body: string } }).alert
+    expect(alert).toEqual({ title: '完成 ✅', body: '报告 下载' })
+    coordinator.close()
+  })
+})