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 { 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 { QqbotMessageTemplate } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
|
||||||
import { SystemMessageSourceRegistry } from './application/message-push/system-message-source.registry';
|
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';
|
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
|
||||||
|
|
||||||
@ -84,6 +86,8 @@ export const QQBOT_CORE_CONTROLLERS = [
|
|||||||
|
|
||||||
export const QQBOT_CORE_PROVIDERS = [
|
export const QQBOT_CORE_PROVIDERS = [
|
||||||
SystemMessageSourceRegistry,
|
SystemMessageSourceRegistry,
|
||||||
|
SystemMessageTemplateRendererService,
|
||||||
|
QqbotMessageTemplateService,
|
||||||
QqbotAccountService,
|
QqbotAccountService,
|
||||||
QqbotBusService,
|
QqbotBusService,
|
||||||
QqbotCommandEngineService,
|
QqbotCommandEngineService,
|
||||||
@ -105,6 +109,8 @@ export const QQBOT_CORE_PROVIDERS = [
|
|||||||
|
|
||||||
export const QQBOT_CORE_EXPORTS = [
|
export const QQBOT_CORE_EXPORTS = [
|
||||||
SystemMessageSourceRegistry,
|
SystemMessageSourceRegistry,
|
||||||
|
SystemMessageTemplateRendererService,
|
||||||
|
QqbotMessageTemplateService,
|
||||||
QqbotAccountService,
|
QqbotAccountService,
|
||||||
QqbotConfigService,
|
QqbotConfigService,
|
||||||
QqbotDashboardService,
|
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