feat: 实现安全消息模板
This commit is contained in:
parent
ce60b45575
commit
3686e82604
@ -0,0 +1,325 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Like, Repository, type FindOptionsWhere } from 'typeorm';
|
||||
import type {
|
||||
MessageTemplateInput,
|
||||
MessageTemplateListQuery,
|
||||
MessageTemplatePreview,
|
||||
MessageTemplateView,
|
||||
SystemMessageSourceDefinition,
|
||||
} from '../../contract/message-push/qqbot-message-push.types';
|
||||
import { SystemMessageContractError } from '../../contract/message-push/qqbot-message-push.types';
|
||||
import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
|
||||
import { QqbotMessageTemplate } from '../../infrastructure/persistence/message-push/qqbot-message-template.entity';
|
||||
import { SystemMessageSourceRegistry } from './system-message-source.registry';
|
||||
import { SystemMessageTemplateRendererService } from './system-message-template-renderer.service';
|
||||
|
||||
const DEFAULT_PAGE_NO = 1;
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Manages reusable, source-scoped safe templates and the binding availability gate.
|
||||
*
|
||||
* Repository exceptions intentionally propagate so storage failures cannot be treated
|
||||
* as valid domain state.
|
||||
*/
|
||||
@Injectable()
|
||||
export class QqbotMessageTemplateService {
|
||||
/**
|
||||
* Initializes template lifecycle dependencies.
|
||||
* @param templateRepository - Persistence for global message templates.
|
||||
* @param bindingRepository - Persistence used solely to count live template references.
|
||||
* @param sourceRegistry - Current process-local source definitions and allowed variables.
|
||||
* @param renderer - Shared strict parser and pure scalar renderer.
|
||||
*/
|
||||
constructor(
|
||||
@InjectRepository(QqbotMessageTemplate)
|
||||
private readonly templateRepository: Repository<QqbotMessageTemplate>,
|
||||
@InjectRepository(QqbotMessagePublishBinding)
|
||||
private readonly bindingRepository: Repository<QqbotMessagePublishBinding>,
|
||||
private readonly sourceRegistry: SystemMessageSourceRegistry,
|
||||
private readonly renderer: SystemMessageTemplateRendererService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Pages non-deleted templates with source display names and live binding counts.
|
||||
* @param query - Optional name, source, enabled, and page filters.
|
||||
* @returns Visible template views and their total count; deleted templates never appear.
|
||||
*/
|
||||
async page(query: MessageTemplateListQuery): Promise<{
|
||||
items: MessageTemplateView[];
|
||||
total: number;
|
||||
}> {
|
||||
const pageNo = Math.max(
|
||||
DEFAULT_PAGE_NO,
|
||||
Math.floor(query.pageNo ?? DEFAULT_PAGE_NO),
|
||||
);
|
||||
const pageSize = Math.max(
|
||||
1,
|
||||
Math.floor(query.pageSize ?? DEFAULT_PAGE_SIZE),
|
||||
);
|
||||
const where: FindOptionsWhere<QqbotMessageTemplate> = { isDeleted: false };
|
||||
if (query.name) where.name = Like(`%${query.name}%`);
|
||||
if (query.sourceKey) where.sourceKey = query.sourceKey;
|
||||
if (query.enabled !== undefined) where.enabled = query.enabled;
|
||||
|
||||
const [templates, total] = await this.templateRepository.findAndCount({
|
||||
order: { createTime: 'DESC' },
|
||||
skip: (pageNo - 1) * pageSize,
|
||||
take: pageSize,
|
||||
where,
|
||||
});
|
||||
return {
|
||||
items: await Promise.all(templates.map((item) => this.toView(item))),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one source-valid global template.
|
||||
* @param input - Template metadata, source identity, content, and initial enabled state.
|
||||
* @returns The persisted template view with a zero or current reference count.
|
||||
*/
|
||||
async create(input: MessageTemplateInput): Promise<MessageTemplateView> {
|
||||
this.validateInput(input);
|
||||
const saved = await this.templateRepository.save(
|
||||
this.templateRepository.create(this.toPersistenceInput(input)),
|
||||
);
|
||||
return this.toView(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates one non-deleted template after revalidating it against the current source.
|
||||
* @param id - String Snowflake identity of the template.
|
||||
* @param input - Complete replacement metadata and content.
|
||||
* @returns The updated template view.
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
input: MessageTemplateInput,
|
||||
): Promise<MessageTemplateView> {
|
||||
const current = await this.findActive(id);
|
||||
this.validateInput(input);
|
||||
Object.assign(current, this.toPersistenceInput(input));
|
||||
return this.toView(await this.templateRepository.save(current));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles one template, revalidating historical content before it can be enabled.
|
||||
* @param id - String Snowflake identity of the template.
|
||||
* @param enabled - Requested future enabled state.
|
||||
* @returns The persisted template view; disabling leaves existing bindings untouched.
|
||||
*/
|
||||
async setEnabled(id: string, enabled: boolean): Promise<MessageTemplateView> {
|
||||
const current = await this.findActive(id);
|
||||
if (enabled) this.validateContent(current.content, current.sourceKey);
|
||||
current.enabled = enabled;
|
||||
return this.toView(await this.templateRepository.save(current));
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes a template that has no live publish binding references.
|
||||
* @param id - String Snowflake identity of the template.
|
||||
* @returns `true` after setting both `isDeleted` and `enabled` to false-safe values.
|
||||
* @throws {SystemMessageContractError} When any non-deleted binding still references it.
|
||||
*/
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const current = await this.findActive(id);
|
||||
const referenceCount = await this.bindingRepository.count({
|
||||
where: { isDeleted: false, templateId: id },
|
||||
});
|
||||
if (referenceCount > 0)
|
||||
throw new SystemMessageContractError('template_invalid');
|
||||
current.enabled = false;
|
||||
current.isDeleted = true;
|
||||
await this.templateRepository.save(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders source examples through the production renderer without a parallel preview path.
|
||||
* @param input - Candidate content and its exact source identity.
|
||||
* @returns Rendered plain text and typed source example variables.
|
||||
*/
|
||||
preview(input: {
|
||||
content: string;
|
||||
sourceKey: string;
|
||||
}): MessageTemplatePreview {
|
||||
const definition = this.sourceRegistry.get(input.sourceKey).definition;
|
||||
const variables = this.exampleVariables(definition);
|
||||
return {
|
||||
renderedMessage: this.renderer.render(input.content, variables),
|
||||
variables,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the reusable template gate before a publish binding is created or enabled.
|
||||
* @param templateId - Candidate template's string Snowflake identity.
|
||||
* @param sourceKey - Source subscribed by the binding.
|
||||
* @param bindingEnabled - Whether the binding is being created or enabled for delivery.
|
||||
* @returns The existing non-deleted template when it is valid for this binding state.
|
||||
* @throws {SystemMessageContractError} With `template_invalid` for every unavailable binding choice.
|
||||
*/
|
||||
async requireAvailableForBinding(
|
||||
templateId: string,
|
||||
sourceKey: string,
|
||||
bindingEnabled: boolean,
|
||||
): Promise<QqbotMessageTemplate> {
|
||||
const template = await this.templateRepository.findOne({
|
||||
where: { id: templateId, isDeleted: false },
|
||||
});
|
||||
if (!template || template.sourceKey !== sourceKey) {
|
||||
throw new SystemMessageContractError('template_invalid');
|
||||
}
|
||||
if (bindingEnabled) {
|
||||
if (!template.enabled)
|
||||
throw new SystemMessageContractError('template_invalid');
|
||||
this.validateContentForBinding(template.content, template.sourceKey);
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a create or update payload using its currently registered source contract.
|
||||
* @param input - Candidate source key and template content.
|
||||
* @returns Nothing after successful strict rendering-protocol validation.
|
||||
*/
|
||||
private validateInput(input: MessageTemplateInput): void {
|
||||
const definition = this.sourceRegistry.get(input.sourceKey).definition;
|
||||
this.renderer.validate(
|
||||
input.content,
|
||||
definition.variables.map((variable) => variable.key),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidates stored content against the source definition currently in the registry.
|
||||
* @param content - Persisted or candidate template text.
|
||||
* @param sourceKey - Source whose exact current variables are authoritative.
|
||||
* @returns Nothing when content remains valid; parser errors retain their stable code.
|
||||
*/
|
||||
private validateContent(content: string, sourceKey: string): void {
|
||||
const definition = this.sourceRegistry.get(sourceKey).definition;
|
||||
this.renderer.validate(
|
||||
content,
|
||||
definition.variables.map((variable) => variable.key),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates content for a newly enabled binding and maps a missing current source to the gate error.
|
||||
* @param content - Stored template text selected by the binding.
|
||||
* @param sourceKey - Source identity already matched to the subscription.
|
||||
* @returns Nothing when the current source still accepts this template.
|
||||
* @throws {SystemMessageContractError} With `template_invalid` when its source is unavailable or content is invalid.
|
||||
*/
|
||||
private validateContentForBinding(content: string, sourceKey: string): void {
|
||||
try {
|
||||
this.validateContent(content, sourceKey);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof SystemMessageContractError &&
|
||||
error.code === 'unknown_message_source'
|
||||
) {
|
||||
throw new SystemMessageContractError('template_invalid');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes user-facing metadata into the entity fields shared by create and update.
|
||||
* @param input - Validated complete template input.
|
||||
* @returns Persistence fields with blank remarks represented as `null`.
|
||||
*/
|
||||
private toPersistenceInput(
|
||||
input: MessageTemplateInput,
|
||||
): Pick<
|
||||
QqbotMessageTemplate,
|
||||
'content' | 'enabled' | 'name' | 'remark' | 'sourceKey'
|
||||
> {
|
||||
return {
|
||||
content: input.content,
|
||||
enabled: input.enabled,
|
||||
name: input.name.trim(),
|
||||
remark: input.remark?.trim() || null,
|
||||
sourceKey: input.sourceKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an existing non-deleted template for a direct lifecycle operation.
|
||||
* @param id - String Snowflake identity to look up.
|
||||
* @returns The active entity or throws the stable template availability error.
|
||||
*/
|
||||
private async findActive(id: string): Promise<QqbotMessageTemplate> {
|
||||
const template = await this.templateRepository.findOne({
|
||||
where: { id, isDeleted: false },
|
||||
});
|
||||
if (!template) throw new SystemMessageContractError('template_invalid');
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts source definition examples to the scalar types used by actual rendering.
|
||||
* @param definition - Current source schema that owns variable examples and types.
|
||||
* @returns A source-keyed scalar object; invalid examples throw `template_invalid`.
|
||||
*/
|
||||
private exampleVariables(
|
||||
definition: SystemMessageSourceDefinition,
|
||||
): Record<string, boolean | number | string> {
|
||||
return Object.fromEntries(
|
||||
definition.variables.map((variable) => {
|
||||
if (variable.type === 'string') return [variable.key, variable.example];
|
||||
if (variable.type === 'number') {
|
||||
const value = Number(variable.example);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new SystemMessageContractError('template_invalid');
|
||||
}
|
||||
return [variable.key, value];
|
||||
}
|
||||
if (variable.example === 'true') return [variable.key, true];
|
||||
if (variable.example === 'false') return [variable.key, false];
|
||||
throw new SystemMessageContractError('template_invalid');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an entity to the external template contract without exposing datetime objects.
|
||||
* @param template - Persisted template entity.
|
||||
* @returns Serializable view including source display name and all live binding references.
|
||||
*/
|
||||
private async toView(
|
||||
template: QqbotMessageTemplate,
|
||||
): Promise<MessageTemplateView> {
|
||||
const [referenceCount, source] = await Promise.all([
|
||||
this.bindingRepository.count({
|
||||
where: { isDeleted: false, templateId: template.id },
|
||||
}),
|
||||
Promise.resolve(this.sourceRegistry.get(template.sourceKey).definition),
|
||||
]);
|
||||
return {
|
||||
content: template.content,
|
||||
createTime: this.serializeTime(template.createTime),
|
||||
enabled: template.enabled,
|
||||
id: String(template.id),
|
||||
name: template.name,
|
||||
referenceCount,
|
||||
remark: template.remark?.trim() || null,
|
||||
sourceKey: template.sourceKey,
|
||||
sourceName: source.displayName,
|
||||
updateTime: this.serializeTime(template.updateTime),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes project datetime values at the message-push JSON boundary.
|
||||
* @param value - Entity datetime value supplied by the KtDateTime TypeORM transformer.
|
||||
* @returns The project-configured KtDateTime string, without converting it to UTC ISO text.
|
||||
*/
|
||||
private serializeTime(value: QqbotMessageTemplate['createTime']): string {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,172 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
SystemMessageContractError,
|
||||
type SystemMessageTemplateToken,
|
||||
} from '../../contract/message-push/qqbot-message-push.types';
|
||||
|
||||
const DANGEROUS_VARIABLE_KEYS = new Set([
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'prototype',
|
||||
]);
|
||||
const VARIABLE_IDENTIFIER = /^[A-Za-z][A-Za-z0-9]*$/;
|
||||
const MAX_TEMPLATE_CONTENT_CODE_POINTS = 2_000;
|
||||
const MAX_VARIABLE_STRING_CODE_POINTS = 500;
|
||||
const MAX_RENDERED_MESSAGE_CODE_POINTS = 4_000;
|
||||
|
||||
/** Counts Unicode code points instead of UTF-16 code units. */
|
||||
function codePointLength(value: string): number {
|
||||
return Array.from(value).length;
|
||||
}
|
||||
|
||||
/** Throws the stable error used for every malformed template protocol input. */
|
||||
function throwTemplateInvalid(): never {
|
||||
throw new SystemMessageContractError('template_invalid');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and renders the deliberately small system-message template protocol.
|
||||
*
|
||||
* It recognizes only complete `${{identifier}}` tokens and never evaluates an
|
||||
* expression, traverses object properties, or interprets CQ-looking text.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SystemMessageTemplateRendererService {
|
||||
/**
|
||||
* Parses one template into ordered text and allowlisted variable tokens.
|
||||
* @param content - Untrusted template text, limited to 2,000 Unicode code points.
|
||||
* @param allowedVariables - Exact variable names provided by the source definition.
|
||||
* @returns Consecutive ordered text and variable tokens; malformed syntax throws `template_invalid`.
|
||||
*/
|
||||
parse(
|
||||
content: string,
|
||||
allowedVariables: readonly string[],
|
||||
): SystemMessageTemplateToken[] {
|
||||
if (typeof content !== 'string') throwTemplateInvalid();
|
||||
if (codePointLength(content) > MAX_TEMPLATE_CONTENT_CODE_POINTS) {
|
||||
throwTemplateInvalid();
|
||||
}
|
||||
|
||||
const allowed = new Set(allowedVariables);
|
||||
const tokens: SystemMessageTemplateToken[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < content.length) {
|
||||
const openingIndex = content.indexOf('${{', cursor);
|
||||
const closingIndex = content.indexOf('}}', cursor);
|
||||
if (openingIndex === -1) {
|
||||
if (closingIndex !== -1) throwTemplateInvalid();
|
||||
this.appendText(tokens, content.slice(cursor));
|
||||
break;
|
||||
}
|
||||
if (closingIndex !== -1 && closingIndex < openingIndex) {
|
||||
throwTemplateInvalid();
|
||||
}
|
||||
|
||||
this.appendText(tokens, content.slice(cursor, openingIndex));
|
||||
const tokenEnd = content.indexOf('}}', openingIndex + 3);
|
||||
const nestedOpeningIndex = content.indexOf('${{', openingIndex + 3);
|
||||
if (
|
||||
tokenEnd === -1 ||
|
||||
(nestedOpeningIndex !== -1 && nestedOpeningIndex < tokenEnd)
|
||||
) {
|
||||
throwTemplateInvalid();
|
||||
}
|
||||
|
||||
const key = content.slice(openingIndex + 3, tokenEnd);
|
||||
if (
|
||||
!VARIABLE_IDENTIFIER.test(key) ||
|
||||
DANGEROUS_VARIABLE_KEYS.has(key) ||
|
||||
!allowed.has(key)
|
||||
) {
|
||||
throwTemplateInvalid();
|
||||
}
|
||||
tokens.push({ key, kind: 'variable' });
|
||||
cursor = tokenEnd + 2;
|
||||
|
||||
// A third closing brace overlaps the token's closing pair and is still an extra `}}`.
|
||||
if (content[cursor] === '}') throwTemplateInvalid();
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the same token stream used by rendering without producing output.
|
||||
* @param content - Untrusted template text.
|
||||
* @param allowedVariables - Exact source-defined variable names.
|
||||
* @returns Nothing when the template protocol is safe; otherwise throws `template_invalid`.
|
||||
*/
|
||||
validate(content: string, allowedVariables: readonly string[]): void {
|
||||
this.parse(content, allowedVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces validated variable tokens with scalar strings and leaves all other text literal.
|
||||
* @param content - Template content following the strict `${{identifier}}` protocol.
|
||||
* @param variables - Own scalar variables available to this render operation.
|
||||
* @returns Plain rendered message text, capped at 4,000 Unicode code points.
|
||||
* @throws {SystemMessageContractError} For invalid templates, invalid values, or size limits.
|
||||
*/
|
||||
render(
|
||||
content: string,
|
||||
variables: Record<string, boolean | number | string>,
|
||||
): string {
|
||||
if (
|
||||
!variables ||
|
||||
typeof variables !== 'object' ||
|
||||
Array.isArray(variables)
|
||||
) {
|
||||
throwTemplateInvalid();
|
||||
}
|
||||
const tokens = this.parse(content, Object.keys(variables));
|
||||
for (const value of Object.values(variables)) {
|
||||
if (typeof value === 'string') {
|
||||
if (codePointLength(value) > MAX_VARIABLE_STRING_CODE_POINTS) {
|
||||
throw new SystemMessageContractError('template_variable_too_long');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof value !== 'boolean' &&
|
||||
(typeof value !== 'number' || !Number.isFinite(value))
|
||||
) {
|
||||
throwTemplateInvalid();
|
||||
}
|
||||
}
|
||||
|
||||
const rendered = tokens
|
||||
.map((token) => {
|
||||
if (token.kind === 'text') return token.value;
|
||||
const value = variables[token.key];
|
||||
if (!Object.prototype.hasOwnProperty.call(variables, token.key)) {
|
||||
throwTemplateInvalid();
|
||||
}
|
||||
return String(value);
|
||||
})
|
||||
.join('');
|
||||
if (codePointLength(rendered) > MAX_RENDERED_MESSAGE_CODE_POINTS) {
|
||||
throw new SystemMessageContractError('rendered_message_too_long');
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds literal text while preserving the compact token representation.
|
||||
* @param tokens - Mutable parser result in source order.
|
||||
* @param value - Literal source slice to append.
|
||||
* @returns Nothing; adjacent literal slices are merged in place.
|
||||
*/
|
||||
private appendText(
|
||||
tokens: SystemMessageTemplateToken[],
|
||||
value: string,
|
||||
): void {
|
||||
if (!value) return;
|
||||
const previous = tokens.at(-1);
|
||||
if (previous?.kind === 'text') {
|
||||
previous.value += value;
|
||||
return;
|
||||
}
|
||||
tokens.push({ kind: 'text', value });
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,8 @@ import { QqbotMessagePublishTarget } from '@/modules/qqbot/core/infrastructure/p
|
||||
import { QqbotMessageSubscription } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
||||
import { QqbotMessageTemplate } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
|
||||
import { SystemMessageSourceRegistry } from './application/message-push/system-message-source.registry';
|
||||
import { SystemMessageTemplateRendererService } from './application/message-push/system-message-template-renderer.service';
|
||||
import { QqbotMessageTemplateService } from './application/message-push/qqbot-message-template.service';
|
||||
|
||||
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
|
||||
|
||||
@ -84,6 +86,8 @@ export const QQBOT_CORE_CONTROLLERS = [
|
||||
|
||||
export const QQBOT_CORE_PROVIDERS = [
|
||||
SystemMessageSourceRegistry,
|
||||
SystemMessageTemplateRendererService,
|
||||
QqbotMessageTemplateService,
|
||||
QqbotAccountService,
|
||||
QqbotBusService,
|
||||
QqbotCommandEngineService,
|
||||
@ -105,6 +109,8 @@ export const QQBOT_CORE_PROVIDERS = [
|
||||
|
||||
export const QQBOT_CORE_EXPORTS = [
|
||||
SystemMessageSourceRegistry,
|
||||
SystemMessageTemplateRendererService,
|
||||
QqbotMessageTemplateService,
|
||||
QqbotAccountService,
|
||||
QqbotConfigService,
|
||||
QqbotDashboardService,
|
||||
|
||||
@ -0,0 +1,242 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
import { KtDateTime } from '../../../../src/common';
|
||||
import { SystemMessageTemplateRendererService } from '../../../../src/modules/qqbot/core/application/message-push/system-message-template-renderer.service';
|
||||
import { QqbotMessageTemplateService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-template.service';
|
||||
import { SystemMessageSourceRegistry } from '../../../../src/modules/qqbot/core/application/message-push/system-message-source.registry';
|
||||
import { QqbotMessagePublishBinding } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
|
||||
import { QqbotMessageTemplate } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
|
||||
|
||||
const SOURCE_KEY = 'network.stun.mapping-port-changed';
|
||||
const NOW = new Date('2026-07-24T00:00:00.000Z');
|
||||
|
||||
/** Creates an in-memory template fixture with explicit serializable timestamps. */
|
||||
function template(
|
||||
overrides: Partial<QqbotMessageTemplate> = {},
|
||||
): QqbotMessageTemplate {
|
||||
return {
|
||||
content: '端口 ${{port}},就绪:${{ready}}',
|
||||
createId: jest.fn(),
|
||||
createTime: NOW as never,
|
||||
enabled: true,
|
||||
id: '100',
|
||||
isDeleted: false,
|
||||
name: '端口提醒',
|
||||
remark: null,
|
||||
sourceKey: SOURCE_KEY,
|
||||
updateTime: NOW as never,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Registers the source definition used by template lifecycle tests. */
|
||||
function registry(): SystemMessageSourceRegistry {
|
||||
const value = new SystemMessageSourceRegistry();
|
||||
value.register({
|
||||
definition: {
|
||||
description: 'STUN 映射端口变化',
|
||||
displayName: 'STUN 端口变化',
|
||||
sourceKey: SOURCE_KEY,
|
||||
subscriptionFields: [],
|
||||
variables: [
|
||||
{
|
||||
description: '端口',
|
||||
example: '38213',
|
||||
key: 'port',
|
||||
label: '端口',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
description: '就绪',
|
||||
example: 'true',
|
||||
key: 'ready',
|
||||
label: '就绪',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
description: '地址',
|
||||
example: 'pal.example.com',
|
||||
key: 'endpoint',
|
||||
label: '地址',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
version: 1,
|
||||
},
|
||||
inspectSubscription: jest.fn(),
|
||||
listSubscriptionOptions: jest.fn(),
|
||||
normalizeSubscriptionConfig: jest.fn(),
|
||||
resolveDelivery: jest.fn(),
|
||||
validateEventPayload: jest.fn(),
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Constructs a template service with narrow repository fakes for unit-level lifecycle checks. */
|
||||
function setup(items: QqbotMessageTemplate[] = [template()]) {
|
||||
const templateRepository = {
|
||||
create: jest.fn((input) => template(input)),
|
||||
findAndCount: jest.fn().mockResolvedValue([items, items.length]),
|
||||
findOne: jest.fn(
|
||||
async ({ where: { id } }) =>
|
||||
items.find((item) => item.id === id && !item.isDeleted) ?? null,
|
||||
),
|
||||
save: jest.fn(async (item) => item),
|
||||
} as unknown as jest.Mocked<Repository<QqbotMessageTemplate>>;
|
||||
const bindingRepository = {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
} as unknown as jest.Mocked<Repository<QqbotMessagePublishBinding>>;
|
||||
const service = new QqbotMessageTemplateService(
|
||||
templateRepository,
|
||||
bindingRepository,
|
||||
registry(),
|
||||
new SystemMessageTemplateRendererService(),
|
||||
);
|
||||
return { bindingRepository, service, templateRepository };
|
||||
}
|
||||
|
||||
describe('QqbotMessageTemplateService', () => {
|
||||
it('pages undeleted templates with filters, source names, and every live binding reference', async () => {
|
||||
const { bindingRepository, service, templateRepository } = setup([
|
||||
template({ id: '100' }),
|
||||
template({ id: '101', name: '另一模板' }),
|
||||
]);
|
||||
bindingRepository.count.mockResolvedValueOnce(2).mockResolvedValueOnce(1);
|
||||
|
||||
await expect(
|
||||
service.page({
|
||||
enabled: true,
|
||||
name: '端口',
|
||||
pageNo: 2,
|
||||
pageSize: 10,
|
||||
sourceKey: SOURCE_KEY,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
id: '100',
|
||||
referenceCount: 2,
|
||||
sourceName: 'STUN 端口变化',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: '101',
|
||||
referenceCount: 1,
|
||||
sourceName: 'STUN 端口变化',
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
expect(templateRepository.findAndCount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ skip: 10, take: 10 }),
|
||||
);
|
||||
expect(bindingRepository.count).toHaveBeenNthCalledWith(1, {
|
||||
where: { isDeleted: false, templateId: '100' },
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes KtDateTime through the project formatter rather than UTC ISO text', async () => {
|
||||
const dateTime = new KtDateTime('2026-07-24 08:09:10');
|
||||
const { service } = setup([
|
||||
template({ createTime: dateTime, updateTime: dateTime }),
|
||||
]);
|
||||
|
||||
await expect(service.page({})).resolves.toEqual({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
createTime: String(dateTime),
|
||||
updateTime: String(dateTime),
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates and updates only templates valid for the current source variable definition', async () => {
|
||||
const { service, templateRepository } = setup();
|
||||
const input = {
|
||||
content: '地址 ${{endpoint}}',
|
||||
enabled: true,
|
||||
name: '新模板',
|
||||
remark: ' 说明 ',
|
||||
sourceKey: SOURCE_KEY,
|
||||
};
|
||||
|
||||
await expect(service.create(input)).resolves.toEqual(
|
||||
expect.objectContaining({ name: '新模板', remark: '说明' }),
|
||||
);
|
||||
await expect(
|
||||
service.update('100', { ...input, content: '端口 ${{port}}' }),
|
||||
).resolves.toEqual(expect.objectContaining({ content: '端口 ${{port}}' }));
|
||||
await expect(
|
||||
service.create({ ...input, content: '${{unknown}}' }),
|
||||
).rejects.toMatchObject({ code: 'template_invalid' });
|
||||
await expect(
|
||||
service.create({ ...input, sourceKey: 'missing' }),
|
||||
).rejects.toMatchObject({ code: 'unknown_message_source' });
|
||||
expect(templateRepository.save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('previews source-specific typed examples through the same renderer', () => {
|
||||
const { service } = setup();
|
||||
|
||||
expect(
|
||||
service.preview({
|
||||
content: '${{port}}/${{ready}}/${{endpoint}}',
|
||||
sourceKey: SOURCE_KEY,
|
||||
}),
|
||||
).toEqual({
|
||||
renderedMessage: '38213/true/pal.example.com',
|
||||
variables: { endpoint: 'pal.example.com', port: 38213, ready: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('revalidates current source contents before enabling a historical template', async () => {
|
||||
const historical = template({
|
||||
content: '${{oldVariable}}',
|
||||
enabled: false,
|
||||
});
|
||||
const { service } = setup([historical]);
|
||||
|
||||
await expect(service.setEnabled('100', true)).rejects.toMatchObject({
|
||||
code: 'template_invalid',
|
||||
});
|
||||
await expect(service.setEnabled('100', false)).resolves.toEqual(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks soft deletion when any non-deleted binding references the template, including disabled bindings', async () => {
|
||||
const { bindingRepository, service, templateRepository } = setup();
|
||||
bindingRepository.count.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
|
||||
|
||||
await expect(service.remove('100')).rejects.toMatchObject({
|
||||
code: 'template_invalid',
|
||||
});
|
||||
await expect(service.remove('100')).resolves.toBe(true);
|
||||
expect(templateRepository.save).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ enabled: false, isDeleted: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('gates publish bindings by source, deletion, enabled state, and current template validity', async () => {
|
||||
const current = template();
|
||||
const { service } = setup([current]);
|
||||
|
||||
await expect(
|
||||
service.requireAvailableForBinding('100', SOURCE_KEY, true),
|
||||
).resolves.toBe(current);
|
||||
current.enabled = false;
|
||||
await expect(
|
||||
service.requireAvailableForBinding('100', SOURCE_KEY, true),
|
||||
).rejects.toMatchObject({ code: 'template_invalid' });
|
||||
await expect(
|
||||
service.requireAvailableForBinding('100', SOURCE_KEY, false),
|
||||
).resolves.toBe(current);
|
||||
await expect(
|
||||
service.requireAvailableForBinding('100', 'other.source', false),
|
||||
).rejects.toMatchObject({ code: 'template_invalid' });
|
||||
current.isDeleted = true;
|
||||
await expect(
|
||||
service.requireAvailableForBinding('100', SOURCE_KEY, false),
|
||||
).rejects.toMatchObject({ code: 'template_invalid' });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,98 @@
|
||||
import { SystemMessageTemplateRendererService } from '../../../../src/modules/qqbot/core/application/message-push/system-message-template-renderer.service';
|
||||
|
||||
describe('SystemMessageTemplateRendererService', () => {
|
||||
const renderer = new SystemMessageTemplateRendererService();
|
||||
|
||||
it('parses exact allowlisted tokens in text order and renders only scalar replacements', () => {
|
||||
const content = '端口 ${{oldPort}} 已变更为 ${{newPort}}。';
|
||||
|
||||
expect(renderer.parse(content, ['oldPort', 'newPort'])).toEqual([
|
||||
{ kind: 'text', value: '端口 ' },
|
||||
{ key: 'oldPort', kind: 'variable' },
|
||||
{ kind: 'text', value: ' 已变更为 ' },
|
||||
{ key: 'newPort', kind: 'variable' },
|
||||
{ kind: 'text', value: '。' },
|
||||
]);
|
||||
expect(
|
||||
renderer.render('当前 STUN 端口为 ${{port}},就绪:${{ready}}', {
|
||||
port: 38213,
|
||||
ready: true,
|
||||
}),
|
||||
).toBe('当前 STUN 端口为 38213,就绪:true');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'${{missing}}',
|
||||
'${{__proto__}}',
|
||||
'${{prototype}}',
|
||||
'${{constructor}}',
|
||||
'${{a.b}}',
|
||||
'${{a[0]}}',
|
||||
'${{a()}}',
|
||||
'${{a + b}}',
|
||||
'${{nested${{a}}}}',
|
||||
'${{unclosed',
|
||||
'extra }}}',
|
||||
'prefix }} suffix',
|
||||
])('rejects unsafe or incomplete template syntax %s', (content) => {
|
||||
expect(() => renderer.validate(content, ['endpoint', 'a'])).toThrow(
|
||||
expect.objectContaining({
|
||||
code: 'template_invalid',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('counts template content by Unicode code point, including emoji', () => {
|
||||
expect(() => renderer.validate('😀'.repeat(2_000), [])).not.toThrow();
|
||||
expect(() => renderer.validate('😀'.repeat(2_001), [])).toThrow(
|
||||
expect.objectContaining({
|
||||
code: 'template_invalid',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('enforces Unicode code-point limits for variables and rendered output', () => {
|
||||
expect(() =>
|
||||
renderer.render('${{value}}', { value: '😀'.repeat(500) }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
renderer.render('${{value}}', { value: '😀'.repeat(501) }),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: 'template_variable_too_long',
|
||||
}),
|
||||
);
|
||||
const outputTemplate = '${{value}}'.repeat(8);
|
||||
expect(() =>
|
||||
renderer.render(outputTemplate, { value: '😀'.repeat(500) }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
renderer.render(`x${outputTemplate}`, { value: '😀'.repeat(500) }),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: 'rendered_message_too_long',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps CQ-looking text literal and replaces multiple tokens without evaluation', () => {
|
||||
expect(
|
||||
renderer.render('[CQ:image,file=x] ${{first}}/${{second}}', {
|
||||
first: 'alpha',
|
||||
second: 'beta',
|
||||
}),
|
||||
).toBe('[CQ:image,file=x] alpha/beta');
|
||||
});
|
||||
|
||||
it('normalizes malformed runtime values to the stable contract error', () => {
|
||||
expect(() => renderer.validate(42 as never, [])).toThrow(
|
||||
expect.objectContaining({ code: 'template_invalid' }),
|
||||
);
|
||||
expect(() => renderer.render('${{value}}', { value: Number.NaN })).toThrow(
|
||||
expect.objectContaining({ code: 'template_invalid' }),
|
||||
);
|
||||
expect(() => renderer.render('${{value}}', { value: Infinity })).toThrow(
|
||||
expect.objectContaining({ code: 'template_invalid' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user