Skip to content
10 changes: 8 additions & 2 deletions apps/meteor/app/api/server/helpers/getUserFromParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ export async function getUserFromParams<T extends boolean = false>(
user?: string;
},
full?: T,
): Promise<T extends true ? IUser : Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusText' | 'roles'>> {
): Promise<
T extends true
? IUser
: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusText' | 'statusSource' | 'statusEmoji' | 'statusExpiresAt' | 'roles'>
> {
let user;

const projection = full ? {} : { username: 1, name: 1, status: 1, statusText: 1, roles: 1 };
const projection = full
? {}
: { username: 1, name: 1, status: 1, statusText: 1, statusSource: 1, statusEmoji: 1, statusExpiresAt: 1, roles: 1 };
if (params.userId?.trim()) {
user = await Users.findOneById(params.userId, { projection });
} else if (params.username?.trim()) {
Expand Down
125 changes: 52 additions & 73 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services';
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
import { MeteorError, Presence, Team } from '@rocket.chat/core-services';
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { Users, Subscriptions, Sessions } from '@rocket.chat/models';
import {
isUserCreateParamsPOST,
Expand Down Expand Up @@ -29,7 +30,7 @@ import {
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools';
import { getLoginExpirationInMs } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
Expand Down Expand Up @@ -66,7 +67,6 @@ import { saveCustomFieldsWithoutValidation } from '../../../lib/server/functions
import { saveUser } from '../../../lib/server/functions/saveUser';
import { sendWelcomeEmail } from '../../../lib/server/functions/saveUser/sendUserEmail';
import { canEditExtension } from '../../../lib/server/functions/saveUser/validateUserEditing';
import { setStatusText } from '../../../lib/server/functions/setStatusText';
import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar';
import { setUsernameWithValidation } from '../../../lib/server/functions/setUsername';
import { validateCustomFields } from '../../../lib/server/functions/validateCustomFields';
Expand Down Expand Up @@ -1870,6 +1870,28 @@ API.v1

const statusType = { type: 'string', enum: ['online', 'offline', 'away', 'busy'] } as const;

const getStatusResponseSchema = ajv.compile<{
_id: string;
status: string;
connectionStatus?: string;
statusSource?: string;
statusEmoji?: string;
statusExpiresAt?: string;
}>({
type: 'object',
properties: {
_id: { type: 'string' },
status: statusType,
connectionStatus: { type: 'string', nullable: true },
statusSource: { type: 'string', nullable: true },
statusEmoji: { type: 'string', nullable: true },
statusExpiresAt: { type: 'string', nullable: true },
success: { type: 'boolean', enum: [true] },
},
required: ['_id', 'status', 'success'],
additionalProperties: false,
});

API.v1
.get(
'users.getPresence',
Expand Down Expand Up @@ -1920,6 +1942,8 @@ API.v1
body: ajv.compile<{
status?: UserStatus;
message?: string;
emoji?: string;
expiresAt?: string;
userId?: string;
username?: string;
user?: string;
Expand All @@ -1928,10 +1952,13 @@ API.v1
properties: {
status: { type: 'string', enum: ['online', 'away', 'offline', 'busy'] },
message: { type: 'string', nullable: true },
emoji: { type: 'string', nullable: true },
expiresAt: { type: 'string', format: 'date-time', nullable: true },
userId: { type: 'string' },
username: { type: 'string' },
user: { type: 'string' },
},
anyOf: [{ required: ['status'] }, { required: ['message'] }],
additionalProperties: false,
}),
response: {
Expand All @@ -1942,20 +1969,6 @@ API.v1
},
},
async function action() {
check(
this.bodyParams,
Match.OneOf(
Match.ObjectIncluding({
status: Match.Maybe(String),
message: String,
}),
Match.ObjectIncluding({
status: String,
message: Match.Maybe(String),
}),
),
);

if (!settings.get('Accounts_AllowUserStatusMessageChange')) {
throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', {
method: 'users.setStatus',
Expand All @@ -1975,47 +1988,19 @@ API.v1
return API.v1.forbidden();
}

const { _id, username, roles, name } = user;
let { statusText, status } = user;

if (this.bodyParams.message || this.bodyParams.message === '') {
await setStatusText(user, this.bodyParams.message, { emit: false });
statusText = this.bodyParams.message;
}

if (this.bodyParams.status) {
const validStatus = ['online', 'away', 'offline', 'busy'];
if (validStatus.includes(this.bodyParams.status)) {
status = this.bodyParams.status;

if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'users.setStatus',
});
}

await Users.updateOne(
{ _id: user._id },
{
$set: {
status,
statusDefault: status,
},
},
);

void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
} else {
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
method: 'users.setStatus',
});
}
if (this.bodyParams.status === UserStatus.OFFLINE && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'users.setStatus',
});
}

void api.broadcast('presence.status', {
user: { status, _id, username, statusText, roles, name },
previousStatus: user.status,
});
await Presence.setStatus(
user._id,
this.bodyParams.status ?? user.status ?? UserStatus.ONLINE,
this.bodyParams.message ?? user.statusText ?? '',
this.bodyParams.emoji,
this.bodyParams.expiresAt ? new Date(this.bodyParams.expiresAt) : undefined,
);

return API.v1.success();
},
Expand All @@ -2026,17 +2011,7 @@ API.v1
authRequired: true,
query: isUsersGetStatusParamsGET,
response: {
200: ajv.compile<{ _id: string; status: string; connectionStatus?: string }>({
type: 'object',
properties: {
_id: { type: 'string' },
status: statusType,
connectionStatus: { type: 'string', nullable: true },
success: { type: 'boolean', enum: [true] },
},
required: ['_id', 'status', 'success'],
additionalProperties: false,
}),
200: getStatusResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
Expand All @@ -2045,18 +2020,22 @@ API.v1
if (isUserFromParams(this.queryParams, this.userId, this.user)) {
return API.v1.success({
_id: this.userId,
// message: user.statusText,
connectionStatus: (this.user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy',
status: (this.user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
connectionStatus: this.user.statusConnection || 'offline',
status: this.user.status || 'offline',
statusSource: this.user.statusSource,
statusEmoji: this.user.statusEmoji,
statusExpiresAt: this.user.statusExpiresAt?.toISOString(),
});
}

const user = await getUserFromParams(this.queryParams);

return API.v1.success({
_id: user._id,
// message: user.statusText,
status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
status: user.status || 'offline',
statusSource: user.statusSource,
statusEmoji: user.statusEmoji,
statusExpiresAt: user.statusExpiresAt?.toISOString(),
});
},
);
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/app/apps/server/bridges/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export class AppUserBridge extends UserBridge {
const { status, statusText, ...updateFields } = fields;

if (status) {
// TODO: pass statusEmoji and statusExpiresAt when Apps Engine IUserBridge supports them
await Presence.setStatus(user.id, status as UserStatus, statusText);
} else if (typeof statusText === 'string') {
await setStatusText(
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/app/lib/server/functions/getFullUserData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const defaultFields = {
bio: 1,
reason: 1,
statusText: 1,
statusEmoji: 1,
statusSource: 1,
statusExpiresAt: 1,
avatarETag: 1,
federated: 1,
statusLivechat: 1,
Expand Down
38 changes: 24 additions & 14 deletions apps/meteor/app/user-status/server/methods/setUserStatus.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,65 @@
import { Presence } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

import { RateLimiter } from '../../../lib/server';
import { setStatusText } from '../../../lib/server/functions/setStatusText';
import { settings } from '../../../settings/server';

declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
setUserStatus(statusType: IUser['status'], statusText: IUser['statusText']): void;
setUserStatus(
statusType: IUser['status'],
statusText: IUser['statusText'],
statusEmoji?: IUser['statusEmoji'],
statusExpiresAt?: IUser['statusExpiresAt'],
): void;
}
}

export const setUserStatusMethod = async (
user: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'statusText'>,
statusType: IUser['status'],
statusText: IUser['statusText'],
statusEmoji?: IUser['statusEmoji'],
statusExpiresAt?: IUser['statusExpiresAt'],
): Promise<void> => {
if (statusType) {
if (statusType === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'setUserStatus',
});
}
await Presence.setStatus(user._id, statusType);
if (statusType === UserStatus.OFFLINE && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'setUserStatus',
});
}

if (statusText || statusText === '') {
if (statusText != null) {
check(statusText, String);

if (!settings.get('Accounts_AllowUserStatusMessageChange')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'setUserStatus',
});
}

await setStatusText(user, statusText);
}

await Presence.setStatus(
user._id,
statusType ?? user.status ?? UserStatus.ONLINE,
statusText ?? user.statusText ?? '',
statusEmoji,
statusExpiresAt,
);
};

Meteor.methods<ServerMethods>({
setUserStatus: async (statusType, statusText) => {
setUserStatus: async (statusType, statusText, statusEmoji, statusExpiresAt) => {
const user = (await Meteor.userAsync()) as IUser;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setUserStatus' });
}

await setUserStatusMethod(user, statusType, statusText);
await setUserStatusMethod(user, statusType, statusText, statusEmoji, statusExpiresAt);
},
});

Expand Down
5 changes: 3 additions & 2 deletions apps/meteor/server/methods/userPresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Meteor } from 'meteor/meteor';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'UserPresence:setDefaultStatus'(status: UserStatus): boolean | undefined;
'UserPresence:setDefaultStatus'(status: UserStatus): void;
'UserPresence:online'(): boolean | undefined;
'UserPresence:away'(): boolean | undefined;
}
Expand All @@ -18,7 +18,8 @@ Meteor.methods<ServerMethods>({
if (!userId) {
return;
}
return Presence.setStatus(userId, status);
// TODO: pass statusEmoji and statusExpiresAt when Meteor method supports them
void Presence.setStatus(userId, status);
},
'UserPresence:online'() {
const { userId, connection } = this;
Expand Down
Loading
Loading