fix: standardize version parsing and simplify string escaping

This commit is contained in:
手瓜一十雪
2026-10-18 13:00:39 +08:00
parent 4e2b6b1200
commit 38a7c3da63
23 changed files with 245 additions and 137 deletions
+1
View File
@@ -36,6 +36,7 @@ This is a pnpm workspace. Package ownership matters more than directory names:
[The entry point](packages/core/src/index.ts) applies pending updates before starting runtime services. [The updater](packages/core/src/update) owns staging and replacement; [Core's build configuration](packages/core/vite.config.ts) emits the release manifest from its build outputs and runtime assets. Keep Core in one bundle and native libraries lazy-loaded so startup can recover an interrupted update before opening native files. [The entry point](packages/core/src/index.ts) applies pending updates before starting runtime services. [The updater](packages/core/src/update) owns staging and replacement; [Core's build configuration](packages/core/vite.config.ts) emits the release manifest from its build outputs and runtime assets. Keep Core in one bundle and native libraries lazy-loaded so startup can recover an interrupted update before opening native files.
Application versions come from the root `package.json` through `@snowluma/common/version`. Use `pnpm bump <version>` to update workspace manifests, or `pnpm bump` to sync them after editing the root version. Application versions come from the root `package.json` through `@snowluma/common/version`. Use `pnpm bump <version>` to update workspace manifests, or `pnpm bump` to sync them after editing the root version.
Use `@snowluma/common/semver` for application version validation and comparison. Versions must be canonical SemVer; strip GitHub's `v` tag prefix only at the API boundary.
Read the relevant owner before changing a path: Read the relevant owner before changing a path:
+1
View File
@@ -54,6 +54,7 @@ SnowLuma 是面向 QQ 客户端的 TypeScript 互操作运行时,将 QQ 原生
3. 打开 [`http://localhost:5099`](http://localhost:5099),使用启动日志中的初始密码登录 WebUI,按引导接入 QQ 进程并配置 OneBot 连接。 3. 打开 [`http://localhost:5099`](http://localhost:5099),使用启动日志中的初始密码登录 WebUI,按引导接入 QQ 进程并配置 OneBot 连接。
在「设置 → 关于 → 软件更新」下载新版本,再点「重启更新」。文件优先暂存于系统临时目录,不可用时使用安装目录的 `tmp/update`,下次启动时替换并清理,配置、日志与消息数据保留。更新沿用当前 Node.js;容器部署请更新镜像。 在「设置 → 关于 → 软件更新」下载新版本,再点「重启更新」。文件优先暂存于系统临时目录,不可用时使用安装目录的 `tmp/update`,下次启动时替换并清理,配置、日志与消息数据保留。更新沿用当前 Node.js;容器部署请更新镜像。
版本号遵循 [SemVer](https://semver.org/),在线更新仅安装正式版;构建元数据不影响版本排序。
## 接入与文档 ## 接入与文档
+1 -4
View File
@@ -6,10 +6,7 @@ import { defineConfig, globalIgnores } from 'eslint/config';
import globals from 'globals'; import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
const tsconfigRootDir = decodeURIComponent(new URL('.', import.meta.url).pathname).replace( const tsconfigRootDir = import.meta.dirname;
/^\/(.:\/)/,
'$1',
);
export const sourceIgnores = globalIgnores([ export const sourceIgnores = globalIgnores([
'**/dist/**', '**/dist/**',
+7 -1
View File
@@ -8,6 +8,10 @@
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { "exports": {
"./semver": {
"types": "./src/semver.ts",
"default": "./src/semver.ts"
},
"./version": { "./version": {
"types": "./src/version.ts", "types": "./src/version.ts",
"default": "./src/version.ts" "default": "./src/version.ts"
@@ -95,13 +99,15 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/semver": "7.7.1",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "^4.1.4" "vitest": "^4.1.4"
}, },
"dependencies": { "dependencies": {
"@zxcvbn-ts/core": "^4.1.2", "@zxcvbn-ts/core": "^4.1.2",
"@zxcvbn-ts/language-common": "^4.1.3", "@zxcvbn-ts/language-common": "^4.1.3",
"@zxcvbn-ts/language-en": "^4.1.1" "@zxcvbn-ts/language-en": "^4.1.1",
"semver": "7.7.4"
}, },
"engines": { "engines": {
"node": ">=24" "node": ">=24"
+28
View File
@@ -0,0 +1,28 @@
import parse from 'semver/functions/parse.js';
/** Parse a canonical SemVer string; tag prefixes belong at the GitHub boundary. */
export function parseVersion(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const parsed = parse(value);
if (!parsed) {
return null;
}
const canonical = parsed.version + (parsed.build.length ? `+${parsed.build.join('.')}` : '');
return value === canonical ? parsed : null;
}
export function compareVersions(a: string, b: string): number {
const left = parseVersion(a);
const right = parseVersion(b);
if (!left || !right) {
throw new TypeError('Invalid semantic version');
}
return left.compare(right);
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { compareVersions, parseVersion } from '../src/semver';
describe('parseVersion', () => {
it.each([
'0.0.0',
'1.2.3',
'1.2.3-rc.1',
'1.2.3-alpha-beta',
'1.2.3+001.build',
'1.2.3-rc.1+build.2',
])('accepts canonical SemVer and preserves its full value: %s', (version) => {
expect(parseVersion(version)?.raw).toBe(version);
});
it.each([
undefined,
null,
123,
{},
'',
'1.2',
'1.2.3.4',
'1.2.x',
'01.2.3',
'1.02.3',
'1.2.03',
'1.2.3-01',
'1.2.3-rc.01',
'1.2.3-rc..1',
'1.2.3-rc_1',
'1.2.3+build..1',
'1.2.3+build_1',
'v1.2.3',
'V1.2.3',
'=1.2.3',
' 1.2.3',
'1.2.3\n',
])('rejects noncanonical input without coercion: %j', (version) => {
expect(parseVersion(version)).toBeNull();
});
});
describe('compareVersions', () => {
it.each([
['1.2.3', '1.2.3', 0],
['1.2.4', '1.2.3', 1],
['1.2.3', '1.2.4', -1],
['1.10.0', '1.9.0', 1],
['2.0.0', '1.9.9', 1],
['3.0.0', '1.0.0', 1],
['1.0.0', '3.0.0', -1],
['1.2.3', '1.2.3-rc.1', 1],
['1.2.3-rc.1', '1.2.3', -1],
['1.2.3-rc.1', '1.2.3-rc.1', 0],
['1.2.3-rc.1', '1.2.3-rc.2', -1],
['1.2.3-rc.2', '1.2.3-rc.1', 1],
['1.2.3-rc.10', '1.2.3-rc.2', 1],
['1.2.3-rc.1+build.2', '1.2.3-rc.1+build.1', 0],
['1.2.3-alpha-beta', '1.2.3-alpha', 1],
['2.0.0-rc.1', '1.9.9', 1],
] as const)('compareVersions(%j, %j) === %s', (a, b, expected) => {
expect(compareVersions(a, b)).toBe(expected);
});
it.each(['1.2', '1.2.3.9', '1.2.x', '', 'v1.2.3', 'V1.2.3', '01.2.3', '1.2.3-rc.01'])(
'rejects invalid versions instead of guessing: %j',
(version) => {
expect(() => compareVersions(version, '1.2.3')).toThrow();
expect(() => compareVersions('1.2.3', version)).toThrow();
},
);
});
+2 -11
View File
@@ -94,16 +94,7 @@ export function defaultNotificationsConfig(): NotificationsConfig {
// ─── Template renderer ────────────────────────────────────────────────────── // ─── Template renderer ──────────────────────────────────────────────────────
/** /** Preserve unknown placeholders; escape substituted strings in JSON templates. */
* Mechanical `{key}` substitution — no logic, no conditionals, no escaping.
* A key present in `vars` is replaced by its value; an unknown `{key}` is left
* untouched (原样) so a typo'd placeholder is visible rather than silently
* blanked. Pure + total (a non-string template yields '').
*
* **JSON safety**: When the template is valid JSON (parseable by JSON.parse),
* values are escaped to prevent breaking the JSON structure — backslashes
* become `\\` and double-quotes become `\"`.
*/
export function renderTemplate(template: string, vars: Record<string, string>): string { export function renderTemplate(template: string, vars: Record<string, string>): string {
if (typeof template !== 'string') { if (typeof template !== 'string') {
return ''; return '';
@@ -124,7 +115,7 @@ export function renderTemplate(template: string, vars: Record<string, string>):
} }
const val = vars[key]; const val = vars[key];
return isJson ? val.replace(/\\/g, '\\\\').replace(/"/g, '\\"') : val; return isJson ? JSON.stringify(val).slice(1, -1) : val;
}); });
} }
+8 -45
View File
@@ -1,4 +1,5 @@
import { APP_VERSION } from '@snowluma/common/version'; import { APP_VERSION } from '@snowluma/common/version';
import { compareVersions, parseVersion } from '@snowluma/common/semver';
import { createLogger } from '@snowluma/common/logger'; import { createLogger } from '@snowluma/common/logger';
import { createSingleFlightCache } from '@snowluma/common/single-flight-cache'; import { createSingleFlightCache } from '@snowluma/common/single-flight-cache';
@@ -40,49 +41,6 @@ function isEnabled(): boolean {
return v !== '0' && v !== 'false' && v !== 'off' && v !== 'no'; return v !== '0' && v !== 'false' && v !== 'off' && v !== 'no';
} }
/**
* Compare two dotted versions. Returns >0 if `a` is newer than `b`, <0 if
* older, 0 if equal. The numeric core (major.minor.patch) compares
* numerically; a prerelease (`-rc.1`) ranks below its release. Good enough
* for "is the latest stable strictly newer than what we run" — and avoids
* pulling in a `semver` runtime dependency (the dist bundle ships none).
*/
export function compareVersions(a: string, b: string): number {
const parse = (v: string) => {
const [core = '', pre = ''] = v.replace(/^v/, '').split('-', 2);
const nums = core.split('.').map((n) => parseInt(n, 10) || 0);
while (nums.length < 3) {
nums.push(0);
}
return { nums, pre };
};
const pa = parse(a);
const pb = parse(b);
for (let i = 0; i < 3; i++) {
if (pa.nums[i] !== pb.nums[i]) {
return pa.nums[i] - pb.nums[i];
}
}
if (pa.pre === pb.pre) {
return 0;
}
if (!pa.pre) {
return 1;
} // release > prerelease
if (!pb.pre) {
return -1;
}
return pa.pre < pb.pre ? -1 : 1;
}
// TTL + single-flight: cache good results for CACHE_TTL_MS (well under GitHub's // TTL + single-flight: cache good results for CACHE_TTL_MS (well under GitHub's
// 60 req/hr limit) and collapse concurrent checks onto one fetch. Error results // 60 req/hr limit) and collapse concurrent checks onto one fetch. Error results
// are intentionally not cached (shouldCache) so the next check retries. // are intentionally not cached (shouldCache) so the next check retries.
@@ -130,13 +88,18 @@ async function fetchLatest(current: string): Promise<UpdateCheckResult> {
} }
const rel = (await res.json()) as GithubRelease; const rel = (await res.json()) as GithubRelease;
const tag = (rel.tag_name ?? '').trim(); const tag = typeof rel.tag_name === 'string' ? rel.tag_name.trim() : '';
if (!tag) { if (!tag) {
return { ...base, error: 'no tag' }; return { ...base, error: 'no tag' };
} }
const latest = tag.replace(/^v/, ''); const latest = tag.startsWith('v') ? tag.slice(1) : tag;
if (!parseVersion(latest)) {
return { ...base, error: 'invalid release version' };
}
return { return {
current, current,
latest, latest,
+2 -1
View File
@@ -3,6 +3,7 @@ import path from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { constants, existsSync } from 'node:fs'; import { constants, existsSync } from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import { parseVersion } from '@snowluma/common/semver';
export const MANIFEST_FILE = 'release.json'; export const MANIFEST_FILE = 'release.json';
@@ -86,7 +87,7 @@ export async function readManifest(root: string): Promise<ReleaseManifest> {
typeof value !== 'object' || typeof value !== 'object' ||
!('version' in value) || !('version' in value) ||
typeof value.version !== 'string' || typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(value.version) || !parseVersion(value.version) ||
!('target' in value) || !('target' in value) ||
typeof value.target !== 'string' || typeof value.target !== 'string' ||
!/^(win32-x64|linux-(x64|arm64))$/.test(value.target) || !/^(win32-x64|linux-(x64|arm64))$/.test(value.target) ||
+7 -9
View File
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
import { mkdir, rename, rm } from 'node:fs/promises'; import { mkdir, rename, rm } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { createLogger } from '@snowluma/common/logger'; import { createLogger } from '@snowluma/common/logger';
import { compareVersions } from './check'; import { parseVersion } from '@snowluma/common/semver';
import { downloadUpdate } from './download'; import { downloadUpdate } from './download';
import { exists, MANIFEST_FILE, readManifest, updateDirectory } from './files'; import { exists, MANIFEST_FILE, readManifest, updateDirectory } from './files';
@@ -76,17 +76,15 @@ export async function createUpdateService(
); );
} }
if ( const parsed = parseVersion(version);
typeof version !== 'string' ||
!/^\d+\.\d+\.\d+$/.test(version) || if (!parsed || parsed.prerelease.length || parsed.compare(current) <= 0) {
compareVersions(version, current) <= 0
) {
throw new Error('请选择更新的正式版本'); throw new Error('请选择更新的正式版本');
} }
state = { phase: 'downloading', version }; state = { phase: 'downloading', version: parsed.raw };
log.info('下载 v%s', version); log.info('下载 v%s', parsed.raw);
void prepare(version); void prepare(parsed.raw);
}, },
restart() { restart() {
if (state.phase !== 'ready') { if (state.phase !== 'ready') {
@@ -60,6 +60,12 @@ describe('renderTemplate — mechanical {key} substitution', () => {
it('does NOT escape when template is plain text', () => { it('does NOT escape when template is plain text', () => {
expect(renderTemplate('{val}', { val: 'a"b\\c' })).toBe('a"b\\c'); expect(renderTemplate('{val}', { val: 'a"b\\c' })).toBe('a"b\\c');
}); });
it('preserves control characters as JSON string escapes', () => {
const value = 'line\nnext\r\t\b\f\0"\\';
const rendered = renderTemplate('{"text":"{value}"}', { value });
expect(JSON.parse(rendered)).toEqual({ text: value });
});
}); });
describe('normalizeNotificationsConfig — total normalize', () => { describe('normalizeNotificationsConfig — total normalize', () => {
+25
View File
@@ -27,6 +27,31 @@ afterEach(async () => {
}); });
describe('staged updates', () => { describe('staged updates', () => {
it.each(['02.0.0', '2.0.0-rc.01', '2.0.0-rc_1', '2.0.0+build..1'])(
'rejects invalid manifest versions before replacing files: %s',
async (version) => {
const payload = path.join(staging, 'pending/payload');
const manifest = await readManifest(payload);
await fs.writeFile(
path.join(payload, 'release.json'),
JSON.stringify({ ...manifest, version }),
);
await expect(applyPendingUpdate(root)).rejects.toThrow('清单无效');
expect((await readManifest(root)).version).toBe('1.0.0');
},
);
it('accepts prerelease identifiers and build metadata in manifests', async () => {
const payload = path.join(staging, 'pending/payload');
const manifest = await readManifest(payload);
const version = '2.0.0-rc.10+build.01';
await fs.writeFile(
path.join(payload, 'release.json'),
JSON.stringify({ ...manifest, version }),
);
expect((await readManifest(payload)).version).toBe(version);
});
it('stages outside the installation and only renames replacements on the destination filesystem', async () => { it('stages outside the installation and only renames replacements on the destination filesystem', async () => {
expect(path.dirname(staging)).toBe(os.tmpdir()); expect(path.dirname(staging)).toBe(os.tmpdir());
expect(staging.startsWith(root + path.sep)).toBe(false); expect(staging.startsWith(root + path.sep)).toBe(false);
+30 -12
View File
@@ -71,18 +71,36 @@ describe('update service', () => {
await vi.waitFor(() => expect(service.status().phase).toBe('error')); await vi.waitFor(() => expect(service.status().phase).toBe('error'));
}); });
it.each(['1.0.0', '0.9.0', '2.0.0-rc.1', '../../outside', null])( it.each([
'rejects invalid or older version %s', '1.0.0',
async (version) => { '0.9.0',
const download = vi.fn(); '2.0.0-rc.1',
const service = await createUpdateService(root, '1.0.0', { '02.0.0',
download, '2.0.0\n',
isContainer: false, '1.0.0+build.2',
}); '../../outside',
expect(() => service.start(version)).toThrow('正式版本'); null,
expect(download).not.toHaveBeenCalled(); ])('rejects invalid or older version %s', async (version) => {
}, const download = vi.fn();
); const service = await createUpdateService(root, '1.0.0', {
download,
isContainer: false,
});
expect(() => service.start(version)).toThrow('正式版本');
expect(download).not.toHaveBeenCalled();
});
it('downloads stable versions with build metadata without dropping the tag suffix', async () => {
const download = vi.fn(async (directory: string, version: string, target: string) => {
await releaseFixture(path.join(directory, 'payload'), version, target);
await writeFile(path.join(directory, 'archive'), 'archive');
});
const service = await createUpdateService(root, '1.0.0', { download, isContainer: false });
service.start('2.0.0+build.01');
await vi.waitFor(() => expect(service.status().phase).toBe('ready'));
expect(download).toHaveBeenCalledWith(expect.any(String), '2.0.0+build.01', 'win32-x64');
expect(service.status()).toMatchObject({ version: '2.0.0+build.01' });
});
it('does not enable updates for a source checkout', async () => { it('does not enable updates for a source checkout', async () => {
const service = await createUpdateService(undefined, '1.0.0'); const service = await createUpdateService(undefined, '1.0.0');
+21 -30
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { compareVersions, getUpdateInfo } from '../src/update/check'; import { getUpdateInfo } from '../src/update/check';
const LATEST_RELEASE_URL = 'https://api.github.com/repos/SnowLuma/SnowLuma/releases/latest'; const LATEST_RELEASE_URL = 'https://api.github.com/repos/SnowLuma/SnowLuma/releases/latest';
const NOTES_MAX = 4_000; const NOTES_MAX = 4_000;
@@ -46,35 +46,6 @@ afterEach(() => {
vi.mock('@snowluma/common/version', () => ({ APP_VERSION: '0.0.0' })); vi.mock('@snowluma/common/version', () => ({ APP_VERSION: '0.0.0' }));
describe('compareVersions', () => {
it.each([
['1.2.3', '1.2.3', 0],
['v1.2.3', '1.2.3', 0],
['1.2', '1.2.0', 0],
['1.2.3.9', '1.2.3.1', 0],
['1.2.x', '1.2.0', 0],
['', '0.0.0', 0],
// Only a leading lowercase `v` is stripped; `V1.2.3` parses as 0.2.3.
['V1.2.3', '0.0.0', 2],
['V1.2.3', '0.2.3', 0],
['1.2.4', '1.2.3', 1],
['1.2.3', '1.2.4', -1],
['1.10.0', '1.9.0', 1],
['2.0.0', '1.9.9', 1],
['3.0.0', '1.0.0', 2],
['1.0.0', '3.0.0', -2],
['1.2.3', '1.2.3-rc.1', 1],
['1.2.3-rc.1', '1.2.3', -1],
['1.2.3-rc.1', '1.2.3-rc.1', 0],
['1.2.3-rc.1', '1.2.3-rc.2', -1],
['1.2.3-rc.2', '1.2.3-rc.1', 1],
['1.2.3-rc.10', '1.2.3-rc.2', -1],
['2.0.0-rc.1', '1.9.9', 1],
] as const)('compareVersions(%j, %j) === %s', (a, b, expected) => {
expect(compareVersions(a, b)).toBe(expected);
});
});
describe('getUpdateInfo', () => { describe('getUpdateInfo', () => {
it.each(['0', 'false', 'off', 'no', 'FALSE', ' Off ', 'No'] as const)( it.each(['0', 'false', 'off', 'no', 'FALSE', ' Off ', 'No'] as const)(
'skips the GitHub request when SNOWLUMA_UPDATE_CHECK=%j', 'skips the GitHub request when SNOWLUMA_UPDATE_CHECK=%j',
@@ -234,6 +205,26 @@ describe('getUpdateInfo', () => {
}, },
); );
it.each(['v01.2.3', 'v1.2.x', 'v1.2.3.4', 'v1.2.3-rc.01', 'v1.2.3+build..1'])(
'reports an invalid release tag without offering an update: %s',
async (tag) => {
mockFetch(async () => jsonResponse({ tag_name: tag }));
expect(await getUpdateInfo(true)).toMatchObject({
latest: null,
hasUpdate: false,
error: 'invalid release version',
});
},
);
it('preserves build metadata in release tags', async () => {
mockFetch(async () => jsonResponse({ tag_name: 'v1.2.3+build.01' }));
expect(await getUpdateInfo(true)).toMatchObject({
latest: '1.2.3+build.01',
hasUpdate: true,
});
});
it('uses the Error message when fetch throws an Error', async () => { it('uses the Error message when fetch throws an Error', async () => {
mockFetch(async () => { mockFetch(async () => {
throw new Error('getaddrinfo ENOTFOUND'); throw new Error('getaddrinfo ENOTFOUND');
+4 -4
View File
@@ -69,10 +69,10 @@ export function parseCQParams(raw: string): Record<string, string> {
if (eq > 0) { if (eq > 0) {
params[pair.substring(0, eq)] = pair params[pair.substring(0, eq)] = pair
.substring(eq + 1) .substring(eq + 1)
.replace(/&#91;/g, '[') .replaceAll('&#91;', '[')
.replace(/&#93;/g, ']') .replaceAll('&#93;', ']')
.replace(/&#44;/g, ',') .replaceAll('&#44;', ',')
.replace(/&amp;/g, '&'); .replaceAll('&amp;', '&');
} }
} }
+8 -8
View File
@@ -300,14 +300,14 @@ function makeMarketFaceElem(element: MessageElement): ProtoElem {
function escapeXml(value: string): string { function escapeXml(value: string): string {
return value return value
.replace(/&/g, '&amp;') .replaceAll('&', '&amp;')
.replace(/</g, '&lt;') .replaceAll('<', '&lt;')
.replace(/>/g, '&gt;') .replaceAll('>', '&gt;')
.replace(/"/g, '&quot;') .replaceAll('"', '&quot;')
.replace(/'/g, '&apos;') .replaceAll("'", '&apos;')
.replace(/\t/g, '&#x9;') .replaceAll('\t', '&#x9;')
.replace(/\n/g, '&#xA;') .replaceAll('\n', '&#xA;')
.replace(/\r/g, '&#xD;'); .replaceAll('\r', '&#xD;');
} }
function xmlPreviewText(value: string): string { function xmlPreviewText(value: string): string {
+2 -2
View File
@@ -1,11 +1,11 @@
import type { AnyMessageSegment } from '../types/index'; import type { AnyMessageSegment } from '../types/index';
export function escapeCqText(text: string): string { export function escapeCqText(text: string): string {
return text.replace(/&/g, '&amp;').replace(/\[/g, '&#91;').replace(/\]/g, '&#93;'); return text.replaceAll('&', '&amp;').replaceAll('[', '&#91;').replaceAll(']', '&#93;');
} }
export function escapeCqParam(text: string): string { export function escapeCqParam(text: string): string {
return escapeCqText(text).replace(/,/g, '&#44;'); return escapeCqText(text).replaceAll(',', '&#44;');
} }
export function segmentsToCQString(segments: AnyMessageSegment[]): string { export function segmentsToCQString(segments: AnyMessageSegment[]): string {
+2 -2
View File
@@ -59,12 +59,12 @@ function parseCQParams(raw: string): Record<string, string> {
} }
function cqUnescape(text: string): string { function cqUnescape(text: string): string {
return text.replace(/&#91;/g, '[').replace(/&#93;/g, ']').replace(/&amp;/g, '&'); return text.replaceAll('&#91;', '[').replaceAll('&#93;', ']').replaceAll('&amp;', '&');
} }
function cqUnescapeParam(text: string): string { function cqUnescapeParam(text: string): string {
// Decode ampersands last so literal entity text is not decoded twice. // Decode ampersands last so literal entity text is not decoded twice.
return cqUnescape(text.replace(/&#44;/g, ',')); return cqUnescape(text.replaceAll('&#44;', ','));
} }
function segmentFromCq(type: string, data: Record<string, string>): AnyMessageSegment { function segmentFromCq(type: string, data: Record<string, string>): AnyMessageSegment {
+1
View File
@@ -57,6 +57,7 @@ Webhook bodies and email subjects/bodies support `{uin}`, `{nickname}`, `{event}
(`offline` or `online`), `{time}`, `{status}` (Hook process state), `{pid}`, and (`offline` or `online`), `{time}`, `{status}` (Hook process state), `{pid}`, and
`{error}` (Hook process error). New webhook and email channels include process `{error}` (Hook process error). New webhook and email channels include process
status in their default body; existing saved templates are preserved. status in their default body; existing saved templates are preserved.
JSON templates escape substituted strings, including quotes, line breaks and control characters.
Process facts are resolved when the notification is dispatched, after any offline Process facts are resolved when the notification is dispatched, after any offline
debounce. Each transition carries its process PID, including the final PID detached debounce. Each transition carries its process PID, including the final PID detached
+1 -6
View File
@@ -6,11 +6,6 @@ import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config'; import { defineConfig, globalIgnores } from 'eslint/config';
const tsconfigRootDir = decodeURIComponent(new URL('.', import.meta.url).pathname).replace(
/^\/(.:\/)/,
'$1',
);
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist']),
{ {
@@ -25,7 +20,7 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
parserOptions: { parserOptions: {
tsconfigRootDir, tsconfigRootDir: import.meta.dirname,
}, },
}, },
rules: { rules: {
+11
View File
@@ -109,10 +109,16 @@ importers:
'@zxcvbn-ts/language-en': '@zxcvbn-ts/language-en':
specifier: ^4.1.1 specifier: ^4.1.1
version: 4.1.1 version: 4.1.1
semver:
specifier: 7.7.4
version: 7.7.4
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: 'catalog:' specifier: 'catalog:'
version: 24.19.2 version: 24.19.2
'@types/semver':
specifier: 7.7.1
version: 7.7.1
typescript: typescript:
specifier: 'catalog:' specifier: 'catalog:'
version: 6.0.2 version: 6.0.2
@@ -2007,6 +2013,9 @@ packages:
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@typescript-eslint/eslint-plugin@8.58.2': '@typescript-eslint/eslint-plugin@8.58.2':
resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4806,6 +4815,8 @@ snapshots:
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/semver@7.7.1': {}
'@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)': '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
+2 -1
View File
@@ -5,12 +5,13 @@ import { readFile, writeFile, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { parseVersion } from '../packages/common/src/semver.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(__dirname, '..');
const nextVersion = process.argv[2] ?? JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')).version; const nextVersion = process.argv[2] ?? JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')).version;
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(nextVersion)) { if (!parseVersion(nextVersion)) {
console.error(`Invalid semver: "${nextVersion}"`); console.error(`Invalid semver: "${nextVersion}"`);
process.exit(1); process.exit(1);
} }
+2 -1
View File
@@ -32,6 +32,7 @@ import { execSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { parseVersion } from '../packages/common/src/semver.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(__dirname, '..');
@@ -51,7 +52,7 @@ if (!version) {
console.error('Example: pnpm release 1.8.0'); console.error('Example: pnpm release 1.8.0');
process.exit(1); process.exit(1);
} }
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) { if (!parseVersion(version)) {
console.error(`Invalid semver: "${version}"`); console.error(`Invalid semver: "${version}"`);
process.exit(1); process.exit(1);
} }