mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(admin): allow system DMs to all users (#3073)
This commit is contained in:
@@ -178,20 +178,20 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async sendSystemDm(
|
||||
data: {content: string; userIds: Array<string>},
|
||||
data: {content: string; recipients: {kind: 'all'} | {kind: 'list'; userIds: Array<string>}},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<SendSystemDmResponse> {
|
||||
const recipientCount = data.recipients.kind === 'all' ? null : data.recipients.userIds.length;
|
||||
await this.apiContext.services.worker.addJob(
|
||||
'sendSystemDm',
|
||||
{
|
||||
content: data.content,
|
||||
user_ids: data.userIds,
|
||||
},
|
||||
data.recipients.kind === 'all'
|
||||
? {content: data.content, all_users: true}
|
||||
: {content: data.content, user_ids: data.recipients.userIds},
|
||||
{requireLedger: true},
|
||||
);
|
||||
const metadata = new Map<string, string>([
|
||||
['recipient_count', data.userIds.length.toString()],
|
||||
['recipient_count', recipientCount === null ? 'all' : recipientCount.toString()],
|
||||
['content_length', data.content.length.toString()],
|
||||
]);
|
||||
await this.auditService.createAuditLog({
|
||||
@@ -202,6 +202,6 @@ export class AdminService {
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
return {recipient_count: data.userIds.length};
|
||||
return {recipient_count: recipientCount};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function SystemDmAdminController(app: HonoApp) {
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
|
||||
'Queue a worker job that delivers the same content to every listed user, or to every user when all_users is set, as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
@@ -31,7 +31,12 @@ export function SystemDmAdminController(app: HonoApp) {
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const payload = ctx.req.valid('json');
|
||||
const result = await adminService.sendSystemDm(
|
||||
{content: payload.content, userIds: payload.user_ids.map((id) => id.toString())},
|
||||
{
|
||||
content: payload.content,
|
||||
recipients: payload.all_users
|
||||
? {kind: 'all'}
|
||||
: {kind: 'list', userIds: (payload.user_ids ?? []).map((id) => id.toString())},
|
||||
},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
@@ -2,19 +2,65 @@
|
||||
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {UserChannelService} from '@app/api/user/services/UserChannelService';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {JobCancelledError, type WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {z} from 'zod';
|
||||
|
||||
const SYSTEM_USER_ID: UserID = createUserID(0n);
|
||||
const PayloadSchema = z.object({
|
||||
content: z.string().min(1).max(4000),
|
||||
user_ids: z.array(z.string().regex(/^\d+$/)).min(1),
|
||||
});
|
||||
const ALL_USERS_PAGE_SIZE = 100;
|
||||
const CURSOR_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
const INELIGIBLE_FLAGS = UserFlags.DELETED | UserFlags.SELF_DELETED | UserFlags.DISABLED;
|
||||
const PayloadSchema = z.union([
|
||||
z.object({
|
||||
content: z.string().min(1).max(4000),
|
||||
user_ids: z.array(z.string().regex(/^\d+$/)).min(1),
|
||||
}),
|
||||
z.object({
|
||||
content: z.string().min(1).max(4000),
|
||||
all_users: z.literal(true),
|
||||
}),
|
||||
]);
|
||||
|
||||
function isEligibleRecipient(user: User): boolean {
|
||||
return user.id !== SYSTEM_USER_ID && !user.isBot && !user.isSystem && (user.flags & INELIGIBLE_FLAGS) === 0n;
|
||||
}
|
||||
|
||||
async function* allUserRecipients(helpers: WorkerTaskHelpers): AsyncGenerator<UserID> {
|
||||
const {userRepository, kvClient} = getWorkerDependencies();
|
||||
const cursorKey = `system_dm:all_users_cursor:${helpers.jobId}`;
|
||||
let pageState = await kvClient.get(cursorKey);
|
||||
if (pageState !== null) {
|
||||
helpers.logger.info('Resuming system DM broadcast from saved cursor');
|
||||
}
|
||||
do {
|
||||
const page = await userRepository.scanAllUsersPage(ALL_USERS_PAGE_SIZE, pageState);
|
||||
for (const user of page.users) {
|
||||
if (isEligibleRecipient(user)) {
|
||||
yield user.id;
|
||||
}
|
||||
}
|
||||
pageState = page.pageState;
|
||||
if (pageState !== null) {
|
||||
await kvClient.setex(cursorKey, CURSOR_TTL_SECONDS, pageState);
|
||||
}
|
||||
} while (pageState !== null);
|
||||
await kvClient.del(cursorKey);
|
||||
}
|
||||
|
||||
async function* listedRecipients(userIds: Array<string>): AsyncGenerator<UserID> {
|
||||
for (const raw of userIds) {
|
||||
yield createUserID(BigInt(raw));
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSystemDm(payload: unknown, helpers: WorkerTaskHelpers): Promise<void> {
|
||||
const {content, user_ids} = PayloadSchema.parse(payload);
|
||||
const parsed = PayloadSchema.parse(payload);
|
||||
const {content} = parsed;
|
||||
const total = 'user_ids' in parsed ? parsed.user_ids.length : null;
|
||||
const recipients = 'user_ids' in parsed ? listedRecipients(parsed.user_ids) : allUserRecipients(helpers);
|
||||
const deps = getWorkerDependencies();
|
||||
const systemUser = await deps.userRepository.findUniqueAssert(SYSTEM_USER_ID);
|
||||
const userChannelService = new UserChannelService(
|
||||
@@ -29,16 +75,12 @@ export async function sendSystemDm(payload: unknown, helpers: WorkerTaskHelpers)
|
||||
const requestCache = createRequestCache();
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const raw of user_ids) {
|
||||
for await (const recipientId of recipients) {
|
||||
if (await helpers.shouldCancel()) {
|
||||
helpers.logger.info(
|
||||
{sent, failed, remaining: user_ids.length - sent - failed},
|
||||
'System DM job cancelled mid-flight',
|
||||
);
|
||||
helpers.logger.info({sent, failed, total}, 'System DM job cancelled mid-flight');
|
||||
requestCache.clear();
|
||||
throw new JobCancelledError();
|
||||
}
|
||||
const recipientId = createUserID(BigInt(raw));
|
||||
try {
|
||||
const channel = await userChannelService.ensureDmOpenForBothUsers({
|
||||
userId: SYSTEM_USER_ID,
|
||||
@@ -55,9 +97,13 @@ export async function sendSystemDm(payload: unknown, helpers: WorkerTaskHelpers)
|
||||
sent += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
helpers.logger.warn({recipientId: raw, error}, 'System DM send failed for recipient');
|
||||
helpers.logger.warn({recipientId: recipientId.toString(), error}, 'System DM send failed for recipient');
|
||||
}
|
||||
if ((sent + failed) % ALL_USERS_PAGE_SIZE === 0) {
|
||||
requestCache.clear();
|
||||
await helpers.reportProgress(sent + failed, total, `${sent} sent, ${failed} failed`);
|
||||
}
|
||||
}
|
||||
requestCache.clear();
|
||||
helpers.logger.info({sent, failed, total: user_ids.length}, 'System DM job complete');
|
||||
helpers.logger.info({sent, failed, total: sent + failed}, 'System DM job complete');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import {sendSystemDm} from '@app/api/worker/tasks/SendSystemDm';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {WorkerRunner} from '@app/api/worker/WorkerRunner';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {JsMsg} from '@nats-io/jetstream';
|
||||
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
@@ -71,11 +72,11 @@ function createWorkerDependencies() {
|
||||
return {sentChannelIds, sentUserIds};
|
||||
}
|
||||
|
||||
function createJobMessage() {
|
||||
function createJobMessage(recipients: Record<string, unknown> = {user_ids: ['11', '12', '13']}) {
|
||||
const envelope = {
|
||||
payload: {
|
||||
content: 'scheduled maintenance tonight',
|
||||
user_ids: ['11', '12', '13'],
|
||||
...recipients,
|
||||
__jobId: LEDGER_JOB_ID.toString(),
|
||||
},
|
||||
max_attempts: 5,
|
||||
@@ -164,4 +165,61 @@ describe('System DM cancellation', () => {
|
||||
|
||||
expect(deps.sentUserIds).toEqual([0n, 0n, 0n]);
|
||||
});
|
||||
|
||||
it('broadcasts to every eligible user when all_users is set', async () => {
|
||||
const user = (id: bigint, extra: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
isBot: false,
|
||||
isSystem: false,
|
||||
flags: 0n,
|
||||
...extra,
|
||||
});
|
||||
const pages = [
|
||||
{users: [user(0n, {isSystem: true}), user(21n), user(22n, {isBot: true})], pageState: 'page-2'},
|
||||
{
|
||||
users: [user(23n, {flags: UserFlags.DELETED}), user(24n), user(25n, {flags: UserFlags.DISABLED})],
|
||||
pageState: null,
|
||||
},
|
||||
];
|
||||
const kv = new Map<string, string>();
|
||||
const kvClient = {
|
||||
get: async (key: string) => kv.get(key) ?? null,
|
||||
setex: async (key: string, _ttl: number, value: string) => {
|
||||
kv.set(key, value);
|
||||
},
|
||||
del: async (key: string) => (kv.delete(key) ? 1 : 0),
|
||||
};
|
||||
const recipientIds: Array<bigint> = [];
|
||||
const systemUser = {id: 0n, username: 'Fluxer', bot: true, system: true};
|
||||
const userRepository = {
|
||||
findUnique: async () => systemUser,
|
||||
findUniqueAssert: async () => systemUser,
|
||||
findExistingDmState: async (_userId: bigint, recipientId: bigint) => {
|
||||
recipientIds.push(recipientId);
|
||||
return {id: 500n};
|
||||
},
|
||||
isDmChannelOpen: async () => true,
|
||||
scanAllUsersPage: async (_limit: number, pageState: string | null) =>
|
||||
pageState === 'page-2' ? pages[1] : pages[0],
|
||||
} as unknown as UserRepository;
|
||||
const channelService = {
|
||||
messages: {send: {sendMessage: async () => {}}},
|
||||
} as unknown as ChannelService;
|
||||
setWorkerDependenciesForTest({userRepository, channelService, kvClient} as never);
|
||||
const {ledger, markSucceeded} = createLedgerStub(Number.POSITIVE_INFINITY);
|
||||
const runner = new TestWorkerRunner({
|
||||
tasks: {[TASK_TYPE]: sendSystemDm},
|
||||
queue: queueStub,
|
||||
consumerName: 'workers_batch',
|
||||
laneName: 'batch',
|
||||
ledger,
|
||||
concurrency: 1,
|
||||
});
|
||||
|
||||
await expect(runner.runJob(TASK_TYPE, createJobMessage({all_users: true}) as unknown as JsMsg)).resolves.toBe(true);
|
||||
|
||||
expect(recipientIds).toEqual([21n, 24n]);
|
||||
expect(markSucceeded).toHaveBeenCalledTimes(1);
|
||||
expect(kv.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user