feat: 支持QQBot多账号消息发布配置

This commit is contained in:
sunlei 2026-07-24 07:09:34 +08:00
parent e49da68165
commit b1e346bfd7
5 changed files with 962 additions and 0 deletions

View File

@ -0,0 +1,585 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { throwVbenError } from '@/common';
import { Repository, type EntityManager } from 'typeorm';
import {
SystemMessageContractError,
type QqbotMessagePublishBindingInput,
type QqbotMessagePublishBindingView,
type QqbotMessagePublishTargetInput,
type QqbotMessagePublishTargetView,
type QqbotMessagePushTargetType,
} from '../../contract/message-push/qqbot-message-push.types';
import { QqbotAccountService } from '../account/qqbot-account.service';
import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
import { QqbotMessagePublishTarget } from '../../infrastructure/persistence/message-push/qqbot-message-publish-target.entity';
import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity';
import { QqbotMessageTemplate } from '../../infrastructure/persistence/message-push/qqbot-message-template.entity';
import { QqbotMessageSubscriptionService } from './qqbot-message-subscription.service';
import { QqbotMessageTemplateService } from './qqbot-message-template.service';
import { SystemMessageSourceRegistry } from './system-message-source.registry';
import { SystemMessageTemplateRendererService } from './system-message-template-renderer.service';
const TARGET_ID_PATTERN = /^[1-9]\d{4,19}$/;
type NormalizedTarget = {
targetId: string;
targetName: null | string;
targetType: QqbotMessagePushTargetType;
};
/** Manages one QQBot account's publish bindings and their atomic target snapshots. */
@Injectable()
export class QqbotAccountMessagePushService {
/** Initializes account-scoped binding persistence and the shared dependency gates. */
constructor(
@InjectRepository(QqbotMessagePublishBinding)
private readonly bindingRepository: Repository<QqbotMessagePublishBinding>,
@InjectRepository(QqbotMessagePublishTarget)
private readonly targetRepository: Repository<QqbotMessagePublishTarget>,
@InjectRepository(QqbotMessageSubscription)
private readonly subscriptionRepository: Repository<QqbotMessageSubscription>,
@InjectRepository(QqbotMessageTemplate)
private readonly templateRepository: Repository<QqbotMessageTemplate>,
private readonly accountService: QqbotAccountService,
private readonly subscriptionService: QqbotMessageSubscriptionService,
private readonly templateService: QqbotMessageTemplateService,
private readonly sourceRegistry: SystemMessageSourceRegistry,
private readonly renderer: SystemMessageTemplateRendererService,
) {}
/** Lists active bindings belonging only to the requested account in deterministic order. */
async listBindings(
selfId: string,
): Promise<QqbotMessagePublishBindingView[]> {
const account = await this.requireAccount(selfId);
const bindings = await this.bindingRepository.find({
order: { createTime: 'ASC', id: 'ASC' },
where: { accountId: String(account.id), isDeleted: false },
});
return Promise.all(bindings.map((binding) => this.toView(binding)));
}
/** Creates or revives the account/subscription binding and replaces its targets atomically. */
async createBinding(
selfId: string,
input: QqbotMessagePublishBindingInput,
): Promise<QqbotMessagePublishBindingView> {
const account = await this.requireAccount(selfId);
const normalizedTargets = this.normalizeTargets(input.targets);
try {
const binding = await this.bindingRepository.manager.transaction(
async (manager) => {
const bindings = manager.getRepository(QqbotMessagePublishBinding);
const activeKey = this.bindingActiveKey(
account.id,
input.subscriptionId,
);
const active = await bindings.findOne({
where: { activeKey, isDeleted: false },
});
if (active) this.throwNaturalKeyConflict();
const historicalCandidate = await bindings.findOne({
order: { updateTime: 'DESC', id: 'DESC' },
where: {
accountId: String(account.id),
isDeleted: true,
subscriptionId: input.subscriptionId,
},
});
const subscription =
await this.subscriptionService.requireAvailableForBinding(
manager,
input.subscriptionId,
input.enabled,
);
await this.templateService.requireAvailableForBinding(
manager,
input.templateId,
subscription.sourceKey,
input.enabled,
);
let binding: QqbotMessagePublishBinding;
if (historicalCandidate) {
const historical = await bindings.findOne({
lock: { mode: 'pessimistic_write' },
where: { id: historicalCandidate.id, isDeleted: true },
});
if (historical) {
Object.assign(
historical,
this.toBindingFields(account, input, activeKey),
{
isDeleted: false,
},
);
binding = await bindings.save(historical);
} else {
binding = await bindings.save(
bindings.create({
...this.toBindingFields(account, input, activeKey),
isDeleted: false,
}),
);
}
} else {
binding = await bindings.save(
bindings.create({
...this.toBindingFields(account, input, activeKey),
isDeleted: false,
}),
);
}
await this.synchronizeTargets(manager, binding, normalizedTargets);
return binding;
},
);
return this.toView(binding);
} catch (error) {
if (this.isDuplicateKeyError(error)) this.throwNaturalKeyConflict();
throw error;
}
}
/** Replaces an account-scoped binding's dependencies, enabled switch, and targets atomically. */
async updateBinding(
selfId: string,
id: string,
input: QqbotMessagePublishBindingInput,
): Promise<QqbotMessagePublishBindingView> {
const account = await this.requireAccount(selfId);
const normalizedTargets = this.normalizeTargets(input.targets);
try {
const binding = await this.bindingRepository.manager.transaction(
async (manager) => {
const bindings = manager.getRepository(QqbotMessagePublishBinding);
const snapshot = await bindings.findOne({
where: { accountId: String(account.id), id, isDeleted: false },
});
if (!snapshot) this.throwBindingUnavailable();
const subscription =
await this.subscriptionService.requireAvailableForBinding(
manager,
input.subscriptionId,
input.enabled,
);
await this.templateService.requireAvailableForBinding(
manager,
input.templateId,
subscription.sourceKey,
input.enabled,
);
const current = await this.findBindingForWrite(
bindings,
account.id,
id,
);
this.assertStableBindingSnapshot(current, snapshot!);
const activeKey = this.bindingActiveKey(
account.id,
input.subscriptionId,
);
const conflict = await bindings.findOne({
where: { activeKey, isDeleted: false },
});
if (conflict && String(conflict.id) !== String(current.id)) {
this.throwNaturalKeyConflict();
}
Object.assign(
current,
this.toBindingFields(account, input, activeKey),
);
const saved = await bindings.save(current);
await this.synchronizeTargets(manager, saved, normalizedTargets);
return saved;
},
);
return this.toView(binding);
} catch (error) {
if (this.isDuplicateKeyError(error)) this.throwNaturalKeyConflict();
throw error;
}
}
/** Changes a binding's user switch while applying both dependency gates in lock order. */
async setBindingEnabled(
selfId: string,
id: string,
enabled: boolean,
): Promise<QqbotMessagePublishBindingView> {
const account = await this.requireAccount(selfId);
const binding = await this.bindingRepository.manager.transaction(
async (manager) => {
const bindings = manager.getRepository(QqbotMessagePublishBinding);
const snapshot = await bindings.findOne({
where: { accountId: String(account.id), id, isDeleted: false },
});
if (!snapshot) this.throwBindingUnavailable();
const subscription =
await this.subscriptionService.requireAvailableForBinding(
manager,
snapshot!.subscriptionId,
enabled,
);
await this.templateService.requireAvailableForBinding(
manager,
snapshot!.templateId,
subscription.sourceKey,
enabled,
);
const current = await this.findBindingForWrite(
bindings,
account.id,
id,
);
this.assertStableBindingSnapshot(current, snapshot!);
current.enabled = enabled;
return bindings.save(current);
},
);
return this.toView(binding);
}
/** Soft-deletes one account-scoped binding and every active target in the same transaction. */
async removeBinding(selfId: string, id: string): Promise<boolean> {
const account = await this.requireAccount(selfId);
await this.bindingRepository.manager.transaction(async (manager) => {
const bindings = manager.getRepository(QqbotMessagePublishBinding);
const targets = manager.getRepository(QqbotMessagePublishTarget);
const binding = await this.findBindingForWrite(bindings, account.id, id);
const activeTargets = await targets.find({
lock: { mode: 'pessimistic_write' },
where: { bindingId: String(binding.id), isDeleted: false },
});
activeTargets.forEach((target) => {
target.activeKey = null;
target.enabled = false;
target.isDeleted = true;
});
if (activeTargets.length > 0) await targets.save(activeTargets);
binding.activeKey = null;
binding.enabled = false;
binding.isDeleted = true;
await bindings.save(binding);
});
return true;
}
/** Validates, trims, and deduplicates targets without converting IDs to numbers. */
private normalizeTargets(
inputs: QqbotMessagePublishTargetInput[],
): NormalizedTarget[] {
const targets = new Map<string, NormalizedTarget>();
inputs.forEach((input) => {
const targetId = String(input.targetId ?? '').trim();
if (!TARGET_ID_PATTERN.test(targetId)) {
throw new SystemMessageContractError('invalid_target_id');
}
if (input.targetType !== 'group' && input.targetType !== 'private') {
throw new SystemMessageContractError('invalid_target_type');
}
const target: NormalizedTarget = {
targetId,
targetName: input.targetName?.trim() || null,
targetType: input.targetType,
};
targets.set(`${target.targetType}:${target.targetId}`, target);
});
return [...targets.values()].sort((left, right) =>
this.targetActiveKey('0', left).localeCompare(
this.targetActiveKey('0', right),
),
);
}
/** Retains, revives, creates, and soft-deletes targets while holding the binding lock. */
private async synchronizeTargets(
manager: EntityManager,
binding: QqbotMessagePublishBinding,
selected: NormalizedTarget[],
): Promise<void> {
const targets = manager.getRepository(QqbotMessagePublishTarget);
const existing = await targets.find({
lock: { mode: 'pessimistic_write' },
order: { createTime: 'ASC', id: 'ASC' },
where: { bindingId: String(binding.id) },
});
const selectedByKey = new Map(
selected.map((target) => [
this.targetActiveKey(binding.id, target),
target,
]),
);
const historicalByKey = new Map(
existing
.filter((target) => target.isDeleted)
.map((target) => [this.targetActiveKey(binding.id, target), target]),
);
const activeByKey = new Map(
existing
.filter((target) => !target.isDeleted)
.map((target) => [this.targetActiveKey(binding.id, target), target]),
);
const saves: QqbotMessagePublishTarget[] = [];
selectedByKey.forEach((target, activeKey) => {
const current =
activeByKey.get(activeKey) ?? historicalByKey.get(activeKey);
if (current) {
Object.assign(current, {
activeKey,
enabled: true,
isDeleted: false,
targetName: target.targetName,
});
saves.push(current);
} else {
saves.push(
targets.create({
activeKey,
bindingId: String(binding.id),
enabled: true,
isDeleted: false,
targetId: target.targetId,
targetName: target.targetName,
targetType: target.targetType,
}),
);
}
});
activeByKey.forEach((target, activeKey) => {
if (selectedByKey.has(activeKey)) return;
Object.assign(target, {
activeKey: null,
enabled: false,
isDeleted: true,
});
saves.push(target);
});
if (saves.length > 0) await targets.save(saves);
}
/** Builds a detached binding view using live dependency availability and active targets only. */
private async toView(
binding: QqbotMessagePublishBinding,
): Promise<QqbotMessagePublishBindingView> {
const [subscription, template, targets] = await Promise.all([
this.subscriptionRepository.findOne({
where: { id: binding.subscriptionId },
}),
this.templateRepository.findOne({ where: { id: binding.templateId } }),
this.targetRepository.find({
order: { createTime: 'ASC', id: 'ASC' },
where: { bindingId: String(binding.id), isDeleted: false },
}),
]);
const state = await this.inspectAvailability(subscription, template);
return {
available: state.available,
createTime: String(binding.createTime),
enabled: binding.enabled,
id: String(binding.id),
invalidReasonCode: state.invalidReasonCode,
sourceKey: subscription?.sourceKey ?? '',
sourceName: state.sourceName,
subscriptionId: String(binding.subscriptionId),
subscriptionName: subscription?.name ?? '',
targets: targets.map((target) => this.toTargetView(target)),
templateId: String(binding.templateId),
templateName: template?.name ?? '',
updateTime: String(binding.updateTime),
};
}
/** Computes live binding eligibility without changing its independent user enabled switch. */
private async inspectAvailability(
subscription: QqbotMessageSubscription | null,
template: QqbotMessageTemplate | null,
): Promise<{
available: boolean;
invalidReasonCode: null | string;
sourceName: string;
}> {
if (!subscription || subscription.isDeleted) {
return {
available: false,
invalidReasonCode: 'invalid_source_config',
sourceName: '',
};
}
let sourceName = subscription.sourceKey;
try {
const adapter = this.sourceRegistry.get(subscription.sourceKey);
sourceName = adapter.definition.displayName;
const inspection = await adapter.inspectSubscription(
subscription.sourceConfig,
);
if (!inspection.valid) {
return {
available: false,
invalidReasonCode:
inspection.invalidReasonCode || 'invalid_source_config',
sourceName,
};
}
} catch {
return {
available: false,
invalidReasonCode: 'invalid_source_config',
sourceName,
};
}
if (!subscription.enabled) {
return {
available: false,
invalidReasonCode: 'subscription_disabled',
sourceName,
};
}
if (
!template ||
template.isDeleted ||
template.sourceKey !== subscription.sourceKey ||
!template.enabled
) {
return {
available: false,
invalidReasonCode: 'template_invalid',
sourceName,
};
}
try {
const definition = this.sourceRegistry.get(
subscription.sourceKey,
).definition;
this.renderer.validate(
template.content,
definition.variables.map((variable) => variable.key),
);
} catch {
return {
available: false,
invalidReasonCode: 'template_invalid',
sourceName,
};
}
return { available: true, invalidReasonCode: null, sourceName };
}
/** Maps one persisted target to the detached active-target response contract. */
private toTargetView(
target: QqbotMessagePublishTarget,
): QqbotMessagePublishTargetView {
return {
enabled: target.enabled,
id: String(target.id),
targetId: String(target.targetId),
targetName: target.targetName?.trim() || null,
targetType: target.targetType,
};
}
/** Loads the configured non-deleted account and preserves its string identity. */
private async requireAccount(selfId: string) {
const account = await this.accountService.findBySelfId(selfId);
if (!account) throw new SystemMessageContractError('account_unavailable');
return account;
}
/** Locks one active binding for the resolved account without allowing cross-account access. */
private async findBindingForWrite(
repository: Repository<QqbotMessagePublishBinding>,
accountId: string,
id: string,
): Promise<QqbotMessagePublishBinding> {
const binding = await repository.findOne({
lock: { mode: 'pessimistic_write' },
where: { accountId: String(accountId), id, isDeleted: false },
});
if (!binding) this.throwBindingUnavailable();
return binding!;
}
/** Refuses stale snapshots before a binding write can use changed account ownership. */
private assertStableAccountSnapshot(
binding: QqbotMessagePublishBinding,
snapshot: QqbotMessagePublishBinding,
): void {
if (
binding.accountId !== snapshot.accountId ||
binding.selfId !== snapshot.selfId
) {
this.throwBindingUnavailable();
}
}
/** Refuses stale snapshots when a toggle's dependency identities changed before its write lock. */
private assertStableBindingSnapshot(
binding: QqbotMessagePublishBinding,
snapshot: QqbotMessagePublishBinding,
): void {
this.assertStableAccountSnapshot(binding, snapshot);
if (
binding.subscriptionId !== snapshot.subscriptionId ||
binding.templateId !== snapshot.templateId
) {
this.throwBindingUnavailable();
}
}
/** Builds the active natural key for a single account and subscription. */
private bindingActiveKey(accountId: string, subscriptionId: string): string {
return `${String(accountId)}:${String(subscriptionId)}`;
}
/** Builds the active natural key for a single binding target without number coercion. */
private targetActiveKey(
bindingId: string,
target: Pick<NormalizedTarget, 'targetId' | 'targetType'>,
): string {
return `${String(bindingId)}:${target.targetType}:${target.targetId}`;
}
/** Converts complete UI input into the binding fields owned by this service. */
private toBindingFields(
account: { id: string; selfId: string },
input: QqbotMessagePublishBindingInput,
activeKey: string,
): Pick<
QqbotMessagePublishBinding,
| 'accountId'
| 'activeKey'
| 'enabled'
| 'selfId'
| 'subscriptionId'
| 'templateId'
> {
return {
accountId: String(account.id),
activeKey,
enabled: input.enabled,
selfId: String(account.selfId),
subscriptionId: String(input.subscriptionId),
templateId: String(input.templateId),
};
}
/** Maps duplicate active binding or target natural keys to the management HTTP conflict. */
private throwNaturalKeyConflict(): never {
return throwVbenError(
'同一账号订阅的消息发布配置已存在',
HttpStatus.CONFLICT,
);
}
/** Signals that a target binding is missing, deleted, or owned by another account. */
private throwBindingUnavailable(): never {
throw new SystemMessageContractError('binding_invalid');
}
/** Recognizes only MySQL's duplicate-key conflict used as final concurrency authority. */
private isDuplicateKeyError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const value = error as { code?: unknown; errno?: unknown };
return value.code === 'ER_DUP_ENTRY' || value.errno === 1062;
}
}

View File

@ -0,0 +1,107 @@
import { Injectable } from '@nestjs/common';
import type {
QqbotMessagePushTargetOption,
QqbotMessagePushTargetOptionsResponse,
QqbotMessagePushTargetType,
} from '../../contract/message-push/qqbot-message-push.types';
import { QqbotAccountService } from '../account/qqbot-account.service';
import { QqbotReverseWsService } from '../../infrastructure/integration/connection/qqbot-reverse-ws.service';
const TARGET_ID_PATTERN = /^[1-9]\d{4,19}$/;
/** Provides HTTP-safe OneBot group and friend candidates for exactly one configured account. */
@Injectable()
export class QqbotMessageTargetOptionsService {
/** Initializes strict account lookup and the existing account-keyed OneBot action path. */
constructor(
private readonly accountService: QqbotAccountService,
private readonly reverseWsService: QqbotReverseWsService,
) {}
/** Lists normalized group and friend candidates, returning a safe unavailable result on OneBot failure. */
async listTargetOptions(
selfId: string,
): Promise<QqbotMessagePushTargetOptionsResponse> {
const account = await this.accountService.findBySelfId(selfId);
if (!account) return this.unavailable('account_unavailable');
try {
const [groups, friends] = await Promise.all([
this.reverseWsService.sendAction(selfId, 'get_group_list', {}),
this.reverseWsService.sendAction(selfId, 'get_friend_list', {}),
]);
const options = [
...this.normalizeResponse(groups, 'group'),
...this.normalizeResponse(friends, 'private'),
];
const unique = new Map<string, QqbotMessagePushTargetOption>();
options.forEach((option) => {
unique.set(`${option.targetType}:${option.targetId}`, option);
});
return {
available: true,
options: [...unique.values()].sort((left, right) =>
`${left.targetType}:${left.label}:${left.targetId}`.localeCompare(
`${right.targetType}:${right.label}:${right.targetId}`,
),
),
reasonCode: null,
};
} catch {
return this.unavailable('onebot_unavailable');
}
}
/** Validates one successful OneBot list response and maps every row to a searchable option. */
private normalizeResponse(
response: { data?: unknown; retcode?: number; status?: string },
targetType: QqbotMessagePushTargetType,
): QqbotMessagePushTargetOption[] {
if (
response.status !== 'ok' ||
(response.retcode !== undefined && response.retcode !== 0) ||
!Array.isArray(response.data)
) {
throw new Error('OneBot candidate response is unavailable');
}
return response.data.map((item) =>
this.normalizeCandidate(item, targetType),
);
}
/** Normalizes one OneBot row while preserving its identifier as a string. */
private normalizeCandidate(
candidate: unknown,
targetType: QqbotMessagePushTargetType,
): QqbotMessagePushTargetOption {
if (!candidate || typeof candidate !== 'object') {
throw new Error('OneBot candidate is malformed');
}
const record = candidate as Record<string, unknown>;
const rawId = targetType === 'group' ? record.group_id : record.user_id;
const targetId = String(rawId).trim();
if (!TARGET_ID_PATTERN.test(targetId)) {
throw new Error('OneBot candidate ID is invalid');
}
const name =
targetType === 'group'
? this.knownName(record.group_name)
: (this.knownName(record.remark) ?? this.knownName(record.nickname));
return {
label: name ? `${name} (${targetId})` : targetId,
targetId,
targetType,
};
}
/** Trims an optional OneBot display name without inventing a replacement name. */
private knownName(value: unknown): null | string {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
/** Produces the stable HTTP-safe empty candidate response. */
private unavailable(
reasonCode: string,
): QqbotMessagePushTargetOptionsResponse {
return { available: false, options: [], reasonCode };
}
}

View File

@ -51,6 +51,8 @@ import { SystemMessageSourceRegistry } from './application/message-push/system-m
import { SystemMessageTemplateRendererService } from './application/message-push/system-message-template-renderer.service';
import { QqbotMessageSubscriptionService } from './application/message-push/qqbot-message-subscription.service';
import { QqbotMessageTemplateService } from './application/message-push/qqbot-message-template.service';
import { QqbotAccountMessagePushService } from './application/message-push/qqbot-account-message-push.service';
import { QqbotMessageTargetOptionsService } from './application/message-push/qqbot-message-target-options.service';
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
@ -90,6 +92,8 @@ export const QQBOT_CORE_PROVIDERS = [
SystemMessageTemplateRendererService,
QqbotMessageSubscriptionService,
QqbotMessageTemplateService,
QqbotAccountMessagePushService,
QqbotMessageTargetOptionsService,
QqbotAccountService,
QqbotBusService,
QqbotCommandEngineService,
@ -114,6 +118,8 @@ export const QQBOT_CORE_EXPORTS = [
SystemMessageTemplateRendererService,
QqbotMessageSubscriptionService,
QqbotMessageTemplateService,
QqbotAccountMessagePushService,
QqbotMessageTargetOptionsService,
QqbotAccountService,
QqbotConfigService,
QqbotDashboardService,

View File

@ -0,0 +1,163 @@
import { QqbotAccountMessagePushService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service';
import { SystemMessageContractError } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.types';
import { QqbotMessagePublishBinding } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
import { QqbotMessagePublishTarget } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity';
describe('QqbotAccountMessagePushService', () => {
it('normalizes targets without number coercion and rejects invalid manual values', () => {
const service = new QqbotAccountMessagePushService(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
expect(
(service as any).normalizeTargets([
{
targetId: ' 20000000000000001 ',
targetName: ' 群 ',
targetType: 'group',
},
{ targetId: '20000000000000001', targetType: 'private' },
{
targetId: '20000000000000001',
targetName: '覆盖',
targetType: 'group',
},
]),
).toEqual([
{
targetId: '20000000000000001',
targetName: '覆盖',
targetType: 'group',
},
{
targetId: '20000000000000001',
targetName: null,
targetType: 'private',
},
]);
expect(() =>
(service as any).normalizeTargets([
{ targetId: '00001', targetType: 'group' },
]),
).toThrow(new SystemMessageContractError('invalid_target_id'));
expect(() =>
(service as any).normalizeTargets([
{ targetId: '10001', targetType: 'channel' },
]),
).toThrow(new SystemMessageContractError('invalid_target_type'));
});
it('holds subscription then template gates through multi-target persistence without coercing IDs', async () => {
const events: string[] = [];
const targetRows: any[] = [];
const subscription = {
enabled: true,
id: '2041700000000000001',
isDeleted: false,
name: '订阅',
sourceConfig: {},
sourceKey: 'network.stun.mapping-port-changed',
};
const template = {
content: '正文',
enabled: true,
id: '2041700000000000002',
isDeleted: false,
name: '模板',
sourceKey: subscription.sourceKey,
};
const bindingStore = {
create: (value: any) => ({
...value,
createTime: '2026-07-24',
id: '2041700000000000003',
updateTime: '2026-07-24',
}),
findOne: jest.fn().mockResolvedValue(null),
save: async (value: any) => {
events.push('binding');
return value;
},
};
const targetStore = {
create: (value: any) => ({
...value,
createTime: '2026-07-24',
id: `target-${targetRows.length + 1}`,
updateTime: '2026-07-24',
}),
find: jest.fn().mockResolvedValue([]),
save: async (values: any[]) => {
events.push('targets');
targetRows.splice(0, targetRows.length, ...values);
return values;
},
};
const manager = {
getRepository: (entity: unknown) => {
if (entity === QqbotMessagePublishBinding) return bindingStore;
if (entity === QqbotMessagePublishTarget) return targetStore;
throw new Error('unexpected repository');
},
};
const service = new QqbotAccountMessagePushService(
{
manager: { transaction: async (callback: any) => callback(manager) },
} as never,
{ find: async () => targetRows } as never,
{ findOne: async () => subscription } as never,
{ findOne: async () => template } as never,
{
findBySelfId: async () => ({
id: '2041700000000000000',
selfId: '10000000000000001',
}),
} as never,
{
requireAvailableForBinding: async () => {
events.push('subscription');
return subscription;
},
} as never,
{
requireAvailableForBinding: async () => {
events.push('template');
return template;
},
} as never,
{
get: () => ({
definition: { displayName: 'STUN 映射', variables: [] },
inspectSubscription: async () => ({ valid: true }),
}),
} as never,
{ validate: jest.fn() } as never,
);
await expect(
service.createBinding('10000000000000001', {
enabled: true,
subscriptionId: '2041700000000000001',
templateId: '2041700000000000002',
targets: [
{ targetId: '20000000000000001', targetType: 'group' },
{ targetId: '30000000000000001', targetType: 'private' },
],
}),
).resolves.toMatchObject({
id: '2041700000000000003',
targets: [
{ targetId: '20000000000000001', targetType: 'group' },
{ targetId: '30000000000000001', targetType: 'private' },
],
});
expect(events).toEqual(['subscription', 'template', 'binding', 'targets']);
});
});

View File

@ -0,0 +1,101 @@
import { QqbotMessageTargetOptionsService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-target-options.service';
describe('QqbotMessageTargetOptionsService', () => {
it('preserves large IDs, normalizes searchable labels, and keeps group/private IDs independent', async () => {
const accountService = {
findBySelfId: jest.fn().mockResolvedValue({ id: '1' }),
};
const reverseWsService = {
sendAction: jest
.fn()
.mockResolvedValueOnce({
data: [
{ group_id: '20000000000000001', group_name: ' KT 群 ' },
{ group_id: '20000000000000001', group_name: 'KT 群' },
],
status: 'ok',
})
.mockResolvedValueOnce({
data: [
{ nickname: ' 阿雪 ', user_id: '20000000000000001' },
{ remark: ' 备注 ', user_id: '30000000000000001' },
],
status: 'ok',
}),
};
const service = new QqbotMessageTargetOptionsService(
accountService as never,
reverseWsService as never,
);
await expect(
service.listTargetOptions('10000000000000001'),
).resolves.toEqual({
available: true,
options: [
{
label: 'KT 群 (20000000000000001)',
targetId: '20000000000000001',
targetType: 'group',
},
{
label: '备注 (30000000000000001)',
targetId: '30000000000000001',
targetType: 'private',
},
{
label: '阿雪 (20000000000000001)',
targetId: '20000000000000001',
targetType: 'private',
},
],
reasonCode: null,
});
expect(reverseWsService.sendAction).toHaveBeenNthCalledWith(
1,
'10000000000000001',
'get_group_list',
{},
);
expect(reverseWsService.sendAction).toHaveBeenNthCalledWith(
2,
'10000000000000001',
'get_friend_list',
{},
);
});
it('returns safe unavailable responses for a missing account, rejected action, and malformed payload', async () => {
const accountService = { findBySelfId: jest.fn() };
const reverseWsService = { sendAction: jest.fn() };
const service = new QqbotMessageTargetOptionsService(
accountService as never,
reverseWsService as never,
);
accountService.findBySelfId.mockResolvedValueOnce(null);
await expect(service.listTargetOptions('10001')).resolves.toEqual({
available: false,
options: [],
reasonCode: 'account_unavailable',
});
expect(reverseWsService.sendAction).not.toHaveBeenCalled();
accountService.findBySelfId.mockResolvedValue({ id: '1' });
reverseWsService.sendAction.mockRejectedValueOnce(new Error('offline'));
await expect(service.listTargetOptions('10001')).resolves.toEqual({
available: false,
options: [],
reasonCode: 'onebot_unavailable',
});
reverseWsService.sendAction
.mockResolvedValueOnce({ data: [{ group_id: 'bad' }], status: 'ok' })
.mockResolvedValueOnce({ data: [], status: 'ok' });
await expect(service.listTargetOptions('10001')).resolves.toEqual({
available: false,
options: [],
reasonCode: 'onebot_unavailable',
});
});
});