feat: 支持QQBot多账号消息发布配置
This commit is contained in:
parent
e49da68165
commit
b1e346bfd7
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -51,6 +51,8 @@ import { SystemMessageSourceRegistry } from './application/message-push/system-m
|
|||||||
import { SystemMessageTemplateRendererService } from './application/message-push/system-message-template-renderer.service';
|
import { SystemMessageTemplateRendererService } from './application/message-push/system-message-template-renderer.service';
|
||||||
import { QqbotMessageSubscriptionService } from './application/message-push/qqbot-message-subscription.service';
|
import { QqbotMessageSubscriptionService } from './application/message-push/qqbot-message-subscription.service';
|
||||||
import { QqbotMessageTemplateService } from './application/message-push/qqbot-message-template.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';
|
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
|
||||||
|
|
||||||
@ -90,6 +92,8 @@ export const QQBOT_CORE_PROVIDERS = [
|
|||||||
SystemMessageTemplateRendererService,
|
SystemMessageTemplateRendererService,
|
||||||
QqbotMessageSubscriptionService,
|
QqbotMessageSubscriptionService,
|
||||||
QqbotMessageTemplateService,
|
QqbotMessageTemplateService,
|
||||||
|
QqbotAccountMessagePushService,
|
||||||
|
QqbotMessageTargetOptionsService,
|
||||||
QqbotAccountService,
|
QqbotAccountService,
|
||||||
QqbotBusService,
|
QqbotBusService,
|
||||||
QqbotCommandEngineService,
|
QqbotCommandEngineService,
|
||||||
@ -114,6 +118,8 @@ export const QQBOT_CORE_EXPORTS = [
|
|||||||
SystemMessageTemplateRendererService,
|
SystemMessageTemplateRendererService,
|
||||||
QqbotMessageSubscriptionService,
|
QqbotMessageSubscriptionService,
|
||||||
QqbotMessageTemplateService,
|
QqbotMessageTemplateService,
|
||||||
|
QqbotAccountMessagePushService,
|
||||||
|
QqbotMessageTargetOptionsService,
|
||||||
QqbotAccountService,
|
QqbotAccountService,
|
||||||
QqbotConfigService,
|
QqbotConfigService,
|
||||||
QqbotDashboardService,
|
QqbotDashboardService,
|
||||||
|
|||||||
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user