diff --git a/src/modules/qqbot/core/application/message-push/qqbot-message-template.service.ts b/src/modules/qqbot/core/application/message-push/qqbot-message-template.service.ts new file mode 100644 index 0000000..ff8f08c --- /dev/null +++ b/src/modules/qqbot/core/application/message-push/qqbot-message-template.service.ts @@ -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, + @InjectRepository(QqbotMessagePublishBinding) + private readonly bindingRepository: Repository, + 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 = { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/modules/qqbot/core/application/message-push/system-message-template-renderer.service.ts b/src/modules/qqbot/core/application/message-push/system-message-template-renderer.service.ts new file mode 100644 index 0000000..e6cec50 --- /dev/null +++ b/src/modules/qqbot/core/application/message-push/system-message-template-renderer.service.ts @@ -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 { + 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 }); + } +} diff --git a/src/modules/qqbot/core/qqbot-core.module.ts b/src/modules/qqbot/core/qqbot-core.module.ts index 00db79f..b994892 100644 --- a/src/modules/qqbot/core/qqbot-core.module.ts +++ b/src/modules/qqbot/core/qqbot-core.module.ts @@ -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, diff --git a/test/modules/qqbot/message-push/qqbot-message-template.service.spec.ts b/test/modules/qqbot/message-push/qqbot-message-template.service.spec.ts new file mode 100644 index 0000000..2a53d79 --- /dev/null +++ b/test/modules/qqbot/message-push/qqbot-message-template.service.spec.ts @@ -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 { + 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>; + const bindingRepository = { + count: jest.fn().mockResolvedValue(0), + } as unknown as jest.Mocked>; + 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' }); + }); +}); diff --git a/test/modules/qqbot/message-push/system-message-template-renderer.spec.ts b/test/modules/qqbot/message-push/system-message-template-renderer.spec.ts new file mode 100644 index 0000000..7a8f734 --- /dev/null +++ b/test/modules/qqbot/message-push/system-message-template-renderer.spec.ts @@ -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' }), + ); + }); +});