diff --git a/fluxer_api/src/api/user/services/UserChannelService.ts b/fluxer_api/src/api/user/services/UserChannelService.ts index a89f12bad..abed71305 100644 --- a/fluxer_api/src/api/user/services/UserChannelService.ts +++ b/fluxer_api/src/api/user/services/UserChannelService.ts @@ -23,6 +23,7 @@ import {requireEmailVerified} from '../../auth/EmailVerificationUtils'; import type {ChannelID, UserID} from '../../BrandedTypes'; import {createChannelID, createMessageID, createUserID} from '../../BrandedTypes'; import {mapChannelToResponse} from '../../channel/ChannelMappers'; +import {SYSTEM_USER_ID} from '../../constants/Core'; import type {IChannelRepository} from '../../channel/IChannelRepository'; import type {ChannelService} from '../../channel/services/ChannelService'; import {dispatchMessageCreateBroadcast} from '../../channel/services/message/MessageGatewayDispatch'; @@ -161,7 +162,7 @@ export class UserChannelService { requestCache, }); } - if (!data.recipient_id) { + if (data.recipient_id == null) { throw InputValidationError.fromCode('recipient_id', ValidationErrorCodes.RECIPIENT_IDS_CANNOT_BE_EMPTY); } const recipientId = createUserID(data.recipient_id); @@ -179,6 +180,9 @@ export class UserChannelService { } const targetUser = await this.userRepository.findUnique(recipientId); if (!targetUser) throw new UnknownUserError(); + if (recipientId === SYSTEM_USER_ID) { + return await this.createNewDMChannel({userId, recipientId, userCacheService, requestCache}); + } await this.validateNewDmAllowed({sender: callingUser, recipient: targetUser}); const channel = await this.createNewDMChannel({userId, recipientId, userCacheService, requestCache}); return channel; diff --git a/fluxer_api/src/api/user/tests/UserChannelService.test.ts b/fluxer_api/src/api/user/tests/UserChannelService.test.ts index 5c38e3c8b..8bb522025 100644 --- a/fluxer_api/src/api/user/tests/UserChannelService.test.ts +++ b/fluxer_api/src/api/user/tests/UserChannelService.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {UserFlags} from '@fluxer/constants/src/UserConstants'; import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; @@ -13,10 +14,14 @@ import { createFriendship, createGroupDmChannel, createGuild, + deleteChannel, getChannel, type MinimalChannelResponse, sendChannelMessage, } from '../../channel/tests/ChannelTestUtils'; +import {createChannelID, createUserID} from '../../BrandedTypes'; +import {SYSTEM_USER_ID} from '../../constants/Core'; +import {UserRepository} from '../../user/repositories/UserRepository'; import {ensureSessionStarted} from '../../message/tests/MessageTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {HTTP_STATUS} from '../../test/TestConstants'; @@ -149,6 +154,32 @@ describe('UserChannelService', () => { const channel2 = await createDmChannel(harness, user1.token, user2.userId); expect(channel2.id).toBe(channel1.id); }); + test('reopening closed system user DM accepts recipient_id 0', async () => { + const user = await createTestAccount(harness); + const userId = createUserID(BigInt(user.userId)); + const channelId = createChannelID(1000000000000000001n); + const userRepository = new UserRepository(); + const channel = await userRepository.createDmChannelAndState(userId, SYSTEM_USER_ID, channelId); + await userRepository.openPrivateChannelForUser(userId, channel); + await deleteChannel(harness, user.token, channelId.toString()); + const reopened = await createBuilder(harness, user.token) + .post('/users/@me/channels') + .body({recipient_id: FLUXERBOT_ID}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(reopened.id).toBe(channelId.toString()); + expect(reopened.type).toBe(ChannelTypes.DM); + }); + test('can create new system user DM without friendship or mutual guilds', async () => { + const user = await createTestAccount(harness); + const channel = await createBuilder(harness, user.token) + .post('/users/@me/channels') + .body({recipient_id: FLUXERBOT_ID}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(channel.id).toBeDefined(); + expect(channel.type).toBe(ChannelTypes.DM); + }); }); describe('Group DM creation', () => { test('can create group DM with friends', async () => { diff --git a/packages/schema/src/domains/tests/UserRequestSchemas.test.ts b/packages/schema/src/domains/tests/UserRequestSchemas.test.ts index 34ecdc757..06efd8ca0 100644 --- a/packages/schema/src/domains/tests/UserRequestSchemas.test.ts +++ b/packages/schema/src/domains/tests/UserRequestSchemas.test.ts @@ -79,4 +79,11 @@ describe('CreatePrivateChannelRequest', () => { ); expect(CreatePrivateChannelRequest.safeParse({recipients}).success).toBe(false); }); + it('allows DM requests to the system user id 0', () => { + const parsed = CreatePrivateChannelRequest.safeParse({recipient_id: '0'}); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.recipient_id).toBe(0n); + } + }); }); diff --git a/packages/schema/src/domains/user/UserRequestSchemas.ts b/packages/schema/src/domains/user/UserRequestSchemas.ts index f8ad42dae..6887174e9 100644 --- a/packages/schema/src/domains/user/UserRequestSchemas.ts +++ b/packages/schema/src/domains/user/UserRequestSchemas.ts @@ -272,9 +272,13 @@ export const CreatePrivateChannelRequest = z .optional() .describe(`Array of user IDs for creating a group DM (max ${MAX_GROUP_DM_OTHER_RECIPIENTS})`), }) - .refine((data) => (data.recipient_id && !data.recipients) || (!data.recipient_id && data.recipients), { - message: 'Either recipient_id or recipients must be provided, but not both', - }); + .refine( + (data) => + (data.recipient_id != null && data.recipients == null) || (data.recipient_id == null && data.recipients != null), + { + message: 'Either recipient_id or recipients must be provided, but not both', + }, + ); export type CreatePrivateChannelRequest = z.infer;