fix(chat): enforce converse membership, add leave and friend-only invite (#318)

master
moonrailgun 20 hours ago committed by GitHub
parent 2d3c789749
commit dc02b00d1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -52,11 +52,13 @@ export async function getCachedUserInfo(
*
*/
export async function getCachedConverseInfo(
converseId: string
converseId: string,
refetch = false
): Promise<ChatConverseInfo> {
const data = await queryClient.fetchQuery(
[CacheKey.converse, converseId],
() => fetchConverseInfo(converseId)
() => fetchConverseInfo(converseId),
{ staleTime: refetch ? 0 : 10 * 1000 }
);
return data;

@ -1,9 +1,73 @@
import { getReduxStore, isValidStr } from '..';
import { getCachedConverseInfo, getCachedUserInfo } from '../cache/cache';
import { getReduxStore, AppStore } from '../redux/store';
import { isValidStr } from '../utils/string-helper';
import { CacheKey, getCachedUserInfo } from '../cache/cache';
import { queryClient } from '../cache';
import { t } from '../i18n';
import type { ChatConverseInfo } from '../model/converse';
import { ChatConverseInfo, fetchConverseInfo } from '../model/converse';
import { appendUserDMConverse } from '../model/user';
import type { FriendInfo } from '../redux/slices/user';
import { chatActions } from '../redux/slices/chat';
import type { RequestError } from '../api/request';
const converseRefreshes = new Map<
string,
Promise<ChatConverseInfo | undefined>
>();
export function removeDMConverseLocally(
converseId: string,
store: AppStore = getReduxStore()
) {
store.dispatch(chatActions.removeDMConverse({ converseId }));
converseRefreshes.delete(converseId);
queryClient.removeQueries([CacheKey.converse, converseId], { exact: true });
queryClient.removeQueries([CacheKey.converseAck, converseId], {
exact: true,
});
}
/**
* 退
*/
export function refreshDMConverse(
converseId: string,
currentUserId: string,
store: AppStore = getReduxStore()
): Promise<ChatConverseInfo | undefined> {
const version =
store.getState().chat.converseMembership[converseId]?.version ?? 0;
const run = async (): Promise<ChatConverseInfo | undefined> => {
// 成员验证不复用缓存;同时开始的调用等待最新请求,不互相取消。
const result = await fetchConverseInfo(converseId).then(
(converse) => ({ converse, error: undefined }),
(error: RequestError) => ({ converse: undefined, error })
);
if (
(store.getState().chat.converseMembership[converseId]?.version ?? 0) !==
version
) {
return;
}
const latestRefresh = converseRefreshes.get(converseId);
if (latestRefresh && latestRefresh !== refresh) {
return latestRefresh;
}
if (result.error && result.error.code !== 403) {
throw result.error;
}
if (!result.converse?.members.includes(currentUserId)) {
removeDMConverseLocally(converseId, store);
return;
}
store.dispatch(
chatActions.restoreDMConverse({ converse: result.converse, version })
);
return result.converse;
};
const refresh = run();
converseRefreshes.set(converseId, refresh);
return refresh;
}
/**
*
@ -12,18 +76,23 @@ export async function ensureDMConverse(
converseId: string,
currentUserId: string
): Promise<ChatConverseInfo> {
const converse = await getCachedConverseInfo(converseId);
if (converse === null) {
// TODO
throw new Error(t('找不到私信会话'));
}
if (!converse.members.includes(currentUserId)) {
const store = getReduxStore();
const version =
store.getState().chat.converseMembership[converseId]?.version ?? 0;
const converse = await refreshDMConverse(converseId, currentUserId, store);
if (!converse) {
throw new Error(t('会话没有权限'));
}
await appendUserDMConverse(converseId); // 添加到私人会话列表
if (
(store.getState().chat.converseMembership[converseId]?.version ?? 0) !==
version
) {
throw new Error(t('会话没有权限'));
}
return converse;
}
@ -72,7 +141,9 @@ export async function getDMConverseName(
});
const len = memberNicknames.length;
if (len === 1) {
if (len === 0) {
return t('多人会话');
} else if (len === 1) {
return memberNicknames[0] ?? '';
} else if (len === 2) {
return `${memberNicknames[0]}, ${memberNicknames[1]}`;

@ -25,15 +25,21 @@ export function useUserSettings() {
const [{ loading: saveLoading }, setSettings] = useAsyncRequest(
async (_settings: UserSettings) => {
const previousSettings =
client.getQueryData<UserSettings>([CacheKey.userSettings]) ?? {};
client.setQueryData([CacheKey.userSettings], () => ({
...settings,
...previousSettings,
..._settings,
})); // 让配置能够立即生效, 防止依赖配置的行为出现跳变(如GroupNav)
try {
const newSettings = await setUserSettings(_settings);
client.setQueryData([CacheKey.userSettings], () => newSettings);
sharedEvent.emit('userSettingsUpdate', newSettings);
} catch (error) {
client.setQueryData([CacheKey.userSettings], previousSettings);
throw error;
}
},
[client]
);

@ -7,6 +7,7 @@
"k11cef91e": "There are some problems with the page",
"k123852c": "Group privacy control to prevent malicious harassment of users through groups.",
"k1252f904": "Gateway",
"k12e81a9d": "Multi-person conversation",
"k131598d0": "A new version is detected, whether to refresh immediately to upgrade to the latest content",
"k13ae6a93": "Copy",
"k13bea6d2": "User not found",
@ -19,6 +20,7 @@
"k18580d81": "Create a link and send it to external friends",
"k186fec4": "Plugin failed to load",
"k1885734a": "Effective after refreshing the page",
"k1898e887": "Leave conversation",
"k18c716ce": "Password cannot be less than 6 digits",
"k19885be1": "Panel name is too long",
"k19a1647f": "1 minute",
@ -79,6 +81,7 @@
"k375be8c3": "Jump to panel",
"k378f66fc": "Unmute",
"k393892b6": "Upload original image",
"k3a1a59b7": "Leave this multi-person conversation? You will no longer receive its messages.",
"k3a31dae3": "Verification email sent",
"k3ac17670": "An exception occurred, store create failed",
"k3b4b656d": "About",
@ -308,6 +311,7 @@
"kb123dbb9": "Your personal unique identifier",
"kb12cc88f": "Unmute",
"kb2217aa7": "Currently using a temporary account, <2>claim now</2>",
"kb292488d": "When enabled, only friends can invite you to create or join multi-person conversations",
"kb3390ae7": "Are you sure you want to leave the group?",
"kb47a9aa4": "Password can not be empty",
"kb488372f": "All",
@ -356,6 +360,7 @@
"kcac0467c": "Allow members to create invitation links",
"kcb20f8ce": "The following are plugin permissions",
"kcb8e662f": "Converse Service",
"kccdd14b1": "Close conversation (new messages will show it again)",
"kceea83fc": "Join Group",
"kcefdbe2d": "Modify avatar success",
"kd0690a45": "Allow management of identity groups",
@ -414,6 +419,7 @@
"ke6da074f": "The message was withdrawn successfully",
"ke9748e88": "Custom Meta is not supported",
"keb053701": "Copy message text successfully",
"kec0ca6b4": "Only allow friends to invite me to conversations",
"kec46a57f": "Add members",
"kecb51e2c": "Old password",
"kecbb0e45": "System",
@ -459,7 +465,6 @@
"kf8de33b7": "Plugin provider not found",
"kf9235c11": "Something went wrong",
"kf94465ba": "Invite Code",
"kfa01c850": "No private message found",
"kfa493f3f": "Reply",
"kfa610536": "Alpha mode switch",
"kfaddd61d": "Chat Service",

@ -7,6 +7,7 @@
"k11cef91e": "页面出现了一些问题",
"k123852c": "群组隐私控制,防止通过群组恶意骚扰用户。",
"k1252f904": "服务网关",
"k12e81a9d": "多人会话",
"k131598d0": "检测到有新版本, 是否立即刷新以升级到最新内容",
"k13ae6a93": "复制",
"k13bea6d2": "没有找到用户",
@ -19,6 +20,7 @@
"k18580d81": "创建链接并发送给外部好友",
"k186fec4": "插件加载失败",
"k1885734a": "刷新页面后生效",
"k1898e887": "退出会话",
"k18c716ce": "密码不能低于6位",
"k19885be1": "面板名过长",
"k19a1647f": "1分钟",
@ -79,6 +81,7 @@
"k375be8c3": "跳转到面板",
"k378f66fc": "解除禁言",
"k393892b6": "上传原图",
"k3a1a59b7": "确定要退出此多人会话么?退出后将不再接收此会话的消息",
"k3a31dae3": "已发送认证邮件",
"k3ac17670": "出现异常, Store 创建失败",
"k3b4b656d": "关于",
@ -308,6 +311,7 @@
"kb123dbb9": "您的个人唯一标识",
"kb12cc88f": "取消免打扰",
"kb2217aa7": "当前使用的是一个临时账号, <2>立即认领</2>",
"kb292488d": "开启后,只有好友可以邀请你创建或加入多人会话",
"kb3390ae7": "确定要退出群组么?",
"kb47a9aa4": "密码不能为空",
"kb488372f": "所有人",
@ -356,6 +360,7 @@
"kcac0467c": "允许成员创建邀请链接",
"kcb20f8ce": "以下为插件权限",
"kcb8e662f": "会话服务",
"kccdd14b1": "关闭会话(新消息到达时会重新显示)",
"kceea83fc": "加入群组",
"kcefdbe2d": "修改头像成功",
"kd0690a45": "允许管理身份组",
@ -414,6 +419,7 @@
"ke6da074f": "消息撤回成功",
"ke9748e88": "不支持自定义Meta",
"keb053701": "复制消息文本成功",
"kec0ca6b4": "仅允许好友邀请创建会话",
"kec46a57f": "添加成员",
"kecb51e2c": "旧密码",
"kecbb0e45": "系统",
@ -459,7 +465,6 @@
"kf8de33b7": "未找到插件的提供者",
"kf9235c11": "出现了一些问题",
"kf94465ba": "邀请码",
"kfa01c850": "找不到私信会话",
"kfa493f3f": "回复",
"kfa610536": "Alpha测试开关",
"kfaddd61d": "聊天服务",

@ -52,6 +52,13 @@ export async function appendDMConverseMembers(
return data;
}
/**
* 退
*/
export async function leaveDMConverse(converseId: string): Promise<void> {
await request.post('/api/chat/converse/leaveDMConverse', { converseId });
}
/**
*
* @param converseId ID

@ -22,6 +22,11 @@ export interface UserLoginInfo extends UserBaseInfo {
}
export interface UserSettings {
/**
*
*/
onlyAllowFriendInvite?: boolean;
/**
*
*/

@ -158,8 +158,7 @@ export function useConverseMessage(context: ConverseContext) {
if (!isGroup) {
// 如果是私信会话
// Step 1. 创建会话 并确保私信列表中存在该会话
const converse = await ensureDMConverse(converseId, currentUserId);
dispatch(chatActions.setConverseInfo(converse));
await ensureDMConverse(converseId, currentUserId);
} else {
// 如果是群组会话(文本频道)
// Step 1. 确保群组会话存在

@ -19,6 +19,17 @@ import { sharedEvent } from '../event';
import type { InboxItem } from '../model/inbox';
import { useGlobalConfigStore } from '../store/globalConfig';
import type { GlobalConfig } from '../model/config';
import {
refreshDMConverse,
removeDMConverseLocally,
} from '../helper/converse-helper';
import { isCancelledError } from '@tanstack/react-query';
function handleConverseError(error: unknown) {
if (!isCancelledError(error)) {
console.error(error);
}
}
/**
* Redux
@ -87,10 +98,12 @@ function initial(socket: AppSocket, store: AppStore) {
// TODO: 待优化, 可以在后端一次性返回
try {
const converse = await getCachedConverseInfo(converseId);
store.dispatch(chatActions.setConverseInfo(converse));
const userId = store.getState().user.info?._id;
if (userId) {
await refreshDMConverse(converseId, userId, store);
}
} catch (e) {
console.error(e);
handleConverseError(e);
}
});
});
@ -130,9 +143,14 @@ function listenNotify(socket: AppSocket, store: AppStore) {
}
);
socket.listen<ChatMessage>('chat.message.add', (message) => {
socket.listen<ChatMessage>('chat.message.add', async (message) => {
// 处理接受到的消息
const converseId = message.converseId;
if (store.getState().chat.converseMembership[converseId]?.removed) {
return;
}
const version =
store.getState().chat.converseMembership[converseId]?.version ?? 0;
const converse = store.getState().chat.converses[converseId];
// 添加消息到会话中
@ -151,18 +169,38 @@ function listenNotify(socket: AppSocket, store: AppStore) {
} else if (!message.groupId) {
// 如果会话没有加载, 但是是私信消息
// 则获取会话信息后添加到会话消息中
getCachedConverseInfo(converseId).then((converse) => {
try {
const converse = await getCachedConverseInfo(converseId, true);
const membership = store.getState().chat.converseMembership[converseId];
const userId = store.getState().user.info?._id;
if (
membership?.removed ||
(membership?.version ?? 0) !== version ||
!userId ||
!converse?.members.includes(userId)
) {
return;
}
if (
[ChatConverseType.DM, ChatConverseType.Multi].includes(converse.type)
) {
// 如果是私人会话, 则添加到dmlist
appendUserDMConverse(converse._id);
await appendUserDMConverse(converse._id);
}
if (
(store.getState().chat.converseMembership[converseId]?.version ??
0) !== version
) {
return;
}
store.dispatch(chatActions.setConverseInfo(converse));
appendMessage();
});
} catch (error) {
handleConverseError(error);
return;
}
} else {
// 是群组未加载的消息面板的消息
// 设置会话信息
@ -230,8 +268,18 @@ function listenNotify(socket: AppSocket, store: AppStore) {
socket.listen<ChatConverseInfo>(
'chat.converse.updateDMConverse',
(converse) => {
store.dispatch(chatActions.setConverseInfo(converse));
const userId = store.getState().user.info?._id;
if (userId) {
refreshDMConverse(converse._id, userId, store).catch(
handleConverseError
);
}
}
);
socket.listen<{ converseId: string }>(
'chat.converse.removeDMConverse',
({ converseId }) => removeDMConverseLocally(converseId, store)
);
socket.listen<GroupInfo>('group.add', (groupInfo) => {

@ -24,6 +24,8 @@ export interface ChatConverseState extends ChatConverseInfo {
export interface ChatState {
currentConverseId: string | null; // 当前活跃的会话id
converses: Record<string, ChatConverseState>; // <会话Id, 会话信息>
// 退出后阻止旧请求恢复会话;重新邀请需校验服务端成员信息和版本。
converseMembership: Record<string, { removed: boolean; version: number }>;
ack: Record<string, string>; // <会话Id, 本地最后一条会话Id>
inbox: InboxItem[];
@ -37,6 +39,7 @@ export interface ChatState {
const initialState: ChatState = {
currentConverseId: null,
converses: {},
converseMembership: {},
ack: {},
inbox: [],
lastMessageMap: {},
@ -55,6 +58,9 @@ const chatSlice = createSlice({
*/
setConverseInfo(state, action: PayloadAction<ChatConverseInfo>) {
const converseId = action.payload._id;
if (state.converseMembership[converseId]?.removed) {
return;
}
const originInfo = state.converses[converseId]
? { ...state.converses[converseId] }
@ -220,6 +226,35 @@ const chatSlice = createSlice({
delete state.converses[converseId];
},
removeDMConverse(state, action: PayloadAction<{ converseId: string }>) {
const { converseId } = action.payload;
state.converseMembership[converseId] = {
removed: true,
version: (state.converseMembership[converseId]?.version ?? 0) + 1,
};
delete state.converses[converseId];
delete state.ack[converseId];
delete state.lastMessageMap[converseId];
if (state.currentConverseId === converseId) {
state.currentConverseId = null;
}
},
restoreDMConverse(
state,
action: PayloadAction<{ converse: ChatConverseInfo; version: number }>
) {
const { converse, version } = action.payload;
if ((state.converseMembership[converse._id]?.version ?? 0) !== version) {
return;
}
state.converseMembership[converse._id] = { removed: false, version };
chatSlice.caseReducers.setConverseInfo(
state,
chatSlice.actions.setConverseInfo(converse)
);
},
/**
*
*/

@ -0,0 +1,196 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { showAlert } from 'tailchat-shared';
import { request } from 'tailchat-shared/api/request';
import { ChatConverseType } from 'tailchat-shared/model/converse';
import { appReducer, chatActions } from 'tailchat-shared/redux/slices';
import { getReduxStore, ReduxProvider } from 'tailchat-shared/redux/store';
import { ConversePanel } from './ConversePanel';
import { queryClient } from 'tailchat-shared/cache';
import { refreshDMConverse } from 'tailchat-shared/helper/converse-helper';
jest.mock('tailchat-shared/api/request', () => ({
request: { get: jest.fn(), post: jest.fn() },
}));
jest.mock('tailchat-shared', () => ({
...jest.requireActual('tailchat-shared/redux/hooks/useAppSelector'),
model: { converse: jest.requireActual('tailchat-shared/model/converse') },
t: (text: string) => text,
useUserId: () => 'me',
useUserInfoList: () => [],
useDMConverseName: () => 'Conversation',
showAlert: jest.fn(),
}));
jest.mock('@/components/ChatBox', () => ({
ChatBox: () => <div>Messages</div>,
}));
jest.mock('@/components/ChatBox/ChatInputBox/context', () => ({
ChatInputMentionsContextProvider: ({ children }: any) => children,
}));
jest.mock('@/components/IconBtn', () => ({
IconBtn: ({ title, onClick }: any) => (
<button onClick={onClick}>{title}</button>
),
}));
jest.mock('@/components/UserListItem', () => ({ UserListItem: () => null }));
jest.mock('@/components/Modal', () => ({ openModal: jest.fn() }));
jest.mock('@/components/modals/AppendDMConverseMembers', () => ({
AppendDMConverseMembers: () => null,
}));
jest.mock('@/components/modals/CreateDMConverse', () => ({
CreateDMConverse: () => null,
}));
jest.mock('@/components/OpenedPanelTip', () => ({
OpenedPanelTip: () => null,
}));
jest.mock('@/hooks/usePanelWindow', () => ({
usePanelWindow: () => ({ hasOpenedPanel: false }),
}));
jest.mock('@/plugin/common', () => ({ pluginPanelActions: [] }));
jest.mock('../common/MessageSearch', () => ({
MessageSearchPanel: () => null,
}));
jest.mock('../common/Wrapper', () => ({
CommonPanelWrapper: ({ children, actions }: any) => (
<div>
{actions({ setRightPanel: jest.fn() })}
{children}
</div>
),
}));
function Location() {
return <output>{useLocation().pathname}</output>;
}
describe('leaving a private conversation', () => {
const store = getReduxStore();
beforeEach(() => {
jest.clearAllMocks();
queryClient.clear();
store.replaceReducer((state, action) =>
appReducer(action.type === 'test/reset' ? undefined : state, action)
);
store.dispatch({ type: 'test/reset' });
(request.post as jest.Mock).mockResolvedValue({ data: {} });
(request.get as jest.Mock).mockRejectedValue({ code: 403 });
});
afterEach(() => queryClient.clear());
function open(type = ChatConverseType.Multi) {
store.dispatch(
chatActions.setConverseInfo({
_id: 'conversation',
name: '',
type,
members: ['me', 'other'],
})
);
return render(
<ReduxProvider store={store}>
<MemoryRouter initialEntries={['/main/personal/converse/conversation']}>
<Routes>
<Route
path="/main/personal/converse/:id"
element={<ConversePanel converseId="conversation" />}
/>
<Route path="/main/personal/friends" element={<div>Friends</div>} />
</Routes>
<Location />
</MemoryRouter>
</ReduxProvider>
);
}
test('a two-member Multi keeps invite, member list and leave actions', () => {
open();
expect(screen.getByRole('button', { name: '邀请成员' })).toBeTruthy();
expect(screen.getByRole('button', { name: '成员列表' })).toBeTruthy();
expect(screen.getByRole('button', { name: '退出会话' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '创建会话' })).toBeNull();
});
test('a direct message does not offer leaving', () => {
open(ChatConverseType.DM);
expect(screen.queryByRole('button', { name: '退出会话' })).toBeNull();
expect(screen.getByRole('button', { name: '创建会话' })).toBeTruthy();
});
test('leaving waits for confirmation and server success before removing or navigating', async () => {
open();
fireEvent.click(screen.getByRole('button', { name: '退出会话' }));
expect(request.post).not.toHaveBeenCalled();
expect(store.getState().chat.converses.conversation).toBeDefined();
const { onConfirm } = (showAlert as jest.Mock).mock.calls[0][0];
(request.post as jest.Mock).mockRejectedValueOnce(new Error('failed'));
await act(async () => {
await expect(onConfirm()).rejects.toThrow('failed');
});
expect(store.getState().chat.converses.conversation).toBeDefined();
expect(screen.getByRole('status').textContent).toBe(
'/main/personal/converse/conversation'
);
await act(async () => {
await onConfirm();
});
expect(request.post).toHaveBeenLastCalledWith(
'/api/chat/converse/leaveDMConverse',
{ converseId: 'conversation' }
);
expect(store.getState().chat.converses.conversation).toBeUndefined();
expect(screen.getByRole('status').textContent).toBe(
'/main/personal/friends'
);
expect(screen.queryByText('Messages')).toBeNull();
});
test('a leave notification on another session closes the active conversation', async () => {
open();
await act(async () => {
store.dispatch(
chatActions.removeDMConverse({ converseId: 'conversation' })
);
});
expect(screen.getByRole('status').textContent).toBe(
'/main/personal/friends'
);
expect(screen.queryByText('Messages')).toBeNull();
});
test('a delayed leave response preserves a newer verified invitation', async () => {
let finishLeave!: (value: unknown) => void;
(request.post as jest.Mock).mockReturnValue(
new Promise((resolve) => {
finishLeave = resolve;
})
);
open();
fireEvent.click(screen.getByRole('button', { name: '退出会话' }));
const leaving = (showAlert as jest.Mock).mock.calls[0][0].onConfirm();
(request.get as jest.Mock).mockResolvedValue({
data: {
_id: 'conversation',
name: '',
type: ChatConverseType.Multi,
members: ['me', 'other'],
},
});
await act(async () => {
await refreshDMConverse('conversation', 'me');
});
await act(async () => {
finishLeave({ data: true });
await leaving;
});
expect(store.getState().chat.converses.conversation).toBeDefined();
expect(screen.getByRole('status').textContent).toBe(
'/main/personal/converse/conversation'
);
});
});

@ -3,6 +3,8 @@ import { UserListItem } from '@/components/UserListItem';
import React from 'react';
import {
ChatConverseState,
model,
showAlert,
t,
useAppSelector,
useDMConverseName,
@ -20,6 +22,8 @@ import { DMPluginPanelActionProps, pluginPanelActions } from '@/plugin/common';
import { CreateDMConverse } from '@/components/modals/CreateDMConverse';
import { MessageSearchPanel } from '../common/MessageSearch';
import { ChatInputMentionsContextProvider } from '@/components/ChatBox/ChatInputBox/context';
import { Navigate } from 'react-router';
import { refreshDMConverse } from 'tailchat-shared/helper/converse-helper';
const ConversePanelTitle: React.FC<{ converse: ChatConverseState }> =
React.memo(({ converse }) => {
@ -50,6 +54,9 @@ export const ConversePanel: React.FC<ConversePanelProps> = React.memo(
const converse = useAppSelector(
(state) => state.chat.converses[converseId]
);
const removed = useAppSelector(
(state) => state.chat.converseMembership[converseId]?.removed
);
const userId = useUserId();
const userInfos = useUserInfoList(
(converse?.members ?? []).filter((m) => m !== userId)
@ -57,6 +64,9 @@ export const ConversePanel: React.FC<ConversePanelProps> = React.memo(
const { hasOpenedPanel, openPanelWindow, closePanelWindow } =
usePanelWindow(`/panel/personal/converse/${converseId}`);
if (removed) {
return <Navigate to="/main/personal/friends" replace />;
}
if (hasOpenedPanel) {
return <OpenedPanelTip onClosePanelWindow={closePanelWindow} />;
}
@ -97,7 +107,7 @@ export const ConversePanel: React.FC<ConversePanelProps> = React.memo(
iconClassName="text-2xl"
onClick={openPanelWindow}
/>,
converse.members.length === 2 ? (
converse.type === model.converse.ChatConverseType.DM ? (
<IconBtn
key="create"
title={t('创建会话')}
@ -140,8 +150,7 @@ export const ConversePanel: React.FC<ConversePanelProps> = React.memo(
})
}
/>,
// 当成员数大于2时显示成员列表按钮
converse.members.length > 2 && (
converse.type === model.converse.ChatConverseType.Multi && (
<IconBtn
key="members"
title={t('成员列表')}
@ -156,6 +165,29 @@ export const ConversePanel: React.FC<ConversePanelProps> = React.memo(
}
/>
),
converse.type === model.converse.ChatConverseType.Multi && (
<IconBtn
key="leave"
title={t('退出会话')}
shape="square"
icon="mdi:logout"
iconClassName="text-2xl"
danger={true}
onClick={() =>
showAlert({
message: t(
'确定要退出此多人会话么?退出后将不再接收此会话的消息'
),
onConfirm: async () => {
await model.converse.leaveDMConverse(converseId);
if (userId) {
await refreshDMConverse(converseId, userId);
}
},
})
}
/>
),
]);
}}
>

@ -0,0 +1,46 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { request } from 'tailchat-shared/api/request';
import { setToasts } from 'tailchat-shared/manager/ui';
import { closeModal } from '../Modal';
import { AppendDMConverseMembers } from './AppendDMConverseMembers';
jest.mock('tailchat-shared/api/request', () => ({
request: { post: jest.fn() },
}));
jest.mock('tailchat-shared', () => ({
...jest.requireActual('tailchat-shared/model/converse'),
...jest.requireActual('tailchat-shared/hooks/useAsyncFn'),
...jest.requireActual('tailchat-shared/hooks/useAsyncRequest'),
t: (text: string) => text,
}));
jest.mock('../Modal', () => ({
closeModal: jest.fn(),
ModalWrapper: ({ children }: any) => children,
}));
jest.mock('../UserPicker/FriendPicker', () => ({
FriendPicker: ({ onChange }: any) => (
<button onClick={() => onChange(['other'])}>Select</button>
),
}));
test('an invitation rejected by the recipient setting shows the server error and keeps the dialog open', async () => {
const toast = jest.fn();
setToasts(toast);
(request.post as jest.Mock).mockRejectedValue(
new Error('Only friends may invite this user')
);
const errorLog = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<AppendDMConverseMembers converseId="conversation" />);
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
fireEvent.click(screen.getByRole('button', { name: /确\s*认/ }));
await waitFor(() =>
expect(toast).toHaveBeenCalledWith(
'Only friends may invite this user',
'error'
)
);
expect(closeModal).not.toHaveBeenCalled();
errorLog.mockRestore();
});

@ -1,6 +1,6 @@
import { Button } from 'antd';
import React, { useState } from 'react';
import { appendDMConverseMembers, t, useAsyncFn } from 'tailchat-shared';
import { appendDMConverseMembers, t, useAsyncRequest } from 'tailchat-shared';
import { FriendPicker } from '../UserPicker/FriendPicker';
import { closeModal, ModalWrapper } from '../Modal';
@ -17,7 +17,7 @@ export const AppendDMConverseMembers: React.FC<AppendDMConverseMembersProps> =
const { converseId, withoutUserIds = [] } = props;
const [selectedFriendIds, setSelectedFriendIds] = useState<string[]>([]);
const [{ loading }, handleConfirm] = useAsyncFn(async () => {
const [{ loading }, handleConfirm] = useAsyncRequest(async () => {
await appendDMConverseMembers(converseId, [...selectedFriendIds]);
closeModal();
}, [converseId, selectedFriendIds]);

@ -56,6 +56,22 @@ export const SettingsSystem: React.FC = React.memo(() => {
}
/>
<FullModalField
title={t('仅允许好友邀请创建会话')}
tip={t('开启后,只有好友可以邀请你创建或加入多人会话')}
content={
<Switch
aria-label={t('仅允许好友邀请创建会话')}
checked={settings.onlyAllowFriendInvite ?? false}
disabled={loading}
loading={loading}
onChange={(checked) =>
setSettings({ onlyAllowFriendInvite: checked })
}
/>
}
/>
{pluginSettings
.filter((item) => item.position === 'system')
.map((item) => {

@ -3,6 +3,7 @@ import {
ChatConverseState,
getCachedUserInfo,
model,
t,
useAppDispatch,
useAsync,
useAsyncRequest,
@ -38,6 +39,10 @@ export const SidebarDMItem: React.FC<SidebarDMItemProps> = React.memo(
)
);
if (userInfos.length === 0) {
return <Icon icon="mdi:account-group-outline" />;
}
return (
<CombinedAvatar
items={userInfos.map((user) => ({
@ -58,6 +63,7 @@ export const SidebarDMItem: React.FC<SidebarDMItemProps> = React.memo(
key={converseId}
name={name}
action={
<span title={t('关闭会话(新消息到达时会重新显示)')}>
<Icon
icon="mdi:close"
onClick={(e) => {
@ -66,6 +72,7 @@ export const SidebarDMItem: React.FC<SidebarDMItemProps> = React.memo(
handleRemove();
}}
/>
</span>
}
icon={icon}
to={`/main/personal/converse/${converseId}`}

@ -0,0 +1,258 @@
import { request } from 'tailchat-shared/api/request';
import type { AppSocket } from 'tailchat-shared/api/socket';
import { queryClient } from 'tailchat-shared/cache';
import { CacheKey } from 'tailchat-shared/cache/cache';
import { sharedEvent } from 'tailchat-shared/event';
import {
ensureDMConverse,
refreshDMConverse,
} from 'tailchat-shared/helper/converse-helper';
import { ChatConverseType } from 'tailchat-shared/model/converse';
import { setupRedux } from 'tailchat-shared/redux/setup';
import {
appReducer,
chatActions,
userActions,
} from 'tailchat-shared/redux/slices';
import * as storeModule from 'tailchat-shared/redux/store';
jest.mock('tailchat-shared/api/request', () => ({
request: { get: jest.fn(), post: jest.fn() },
}));
const converse = {
_id: 'conversation',
type: ChatConverseType.Multi,
name: '',
members: ['me', 'other'],
};
const message = {
_id: 'message',
converseId: converse._id,
author: 'other',
content: 'hello',
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
describe('private conversation membership synchronization', () => {
let store: storeModule.AppStore;
let listeners: Record<string, (data: any) => unknown>;
let receiveMessage: jest.Mock;
let socketRequest: jest.Mock;
let reconnect: () => void;
beforeEach(async () => {
queryClient.clear();
jest.clearAllMocks();
store = storeModule.getReduxStore();
store.replaceReducer((state, action) =>
appReducer(action.type === 'test/reset' ? undefined : state, action)
);
store.dispatch({ type: 'test/reset' });
store.dispatch(userActions.setUserInfo({ _id: 'me' } as any));
listeners = {};
receiveMessage = jest.fn();
sharedEvent.on('receiveMessage', receiveMessage);
(request.get as jest.Mock).mockResolvedValue({ data: converse });
(request.post as jest.Mock).mockResolvedValue({ data: {} });
socketRequest = jest.fn().mockResolvedValue([]);
setupRedux(
{
request: socketRequest,
listen: (name: string, listener: (data: any) => unknown) => {
listeners[name] = listener;
},
onReconnect: (callback: () => void) => {
reconnect = callback;
},
} as unknown as AppSocket,
store
);
await Promise.resolve();
});
afterEach(() => {
sharedEvent.off('receiveMessage', receiveMessage);
queryClient.clear();
jest.restoreAllMocks();
});
async function flush() {
// Drain query and socket continuations without advancing query retry timers.
await new Promise((resolve) => setTimeout(resolve, 0));
}
test('leaving clears the conversation, unread state and cache, and suppresses late messages', async () => {
store.dispatch(chatActions.setConverseInfo(converse));
store.dispatch(chatActions.updateCurrentConverseId(converse._id));
store.dispatch(
chatActions.setConverseAck({
converseId: converse._id,
lastMessageId: 'old',
})
);
store.dispatch(
chatActions.setLastMessageMap([
{ converseId: converse._id, lastMessageId: 'new' },
])
);
queryClient.setQueryData([CacheKey.converse, converse._id], converse);
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
listeners['chat.message.add'](message);
await flush();
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
expect(store.getState().chat.currentConverseId).toBeNull();
expect(store.getState().chat.ack[converse._id]).toBeUndefined();
expect(store.getState().chat.lastMessageMap[converse._id]).toBeUndefined();
expect(
queryClient.getQueryData([CacheKey.converse, converse._id])
).toBeUndefined();
expect(request.post).not.toHaveBeenCalled();
expect(receiveMessage).not.toHaveBeenCalled();
});
test('an in-flight message lookup cannot restore membership after leaving', async () => {
const response = deferred<{ data: typeof converse }>();
(request.get as jest.Mock).mockReturnValue(response.promise);
listeners['chat.message.add'](message);
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
response.resolve({ data: converse });
await flush();
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
expect(request.post).not.toHaveBeenCalled();
expect(receiveMessage).not.toHaveBeenCalled();
});
test('reopening a cached conversation checks current membership before adding it to the list', async () => {
queryClient.setQueryData([CacheKey.converse, converse._id], converse);
(request.get as jest.Mock).mockResolvedValue({
data: { ...converse, members: ['other'] },
});
await expect(ensureDMConverse(converse._id, 'me')).rejects.toThrow();
expect(request.get).toHaveBeenCalled();
expect(request.post).not.toHaveBeenCalled();
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
});
test('a stale update cannot restore membership but a verified new invitation can', async () => {
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
(request.get as jest.Mock).mockResolvedValue({
data: { ...converse, members: ['other'] },
});
listeners['chat.converse.updateDMConverse'](converse);
await flush();
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
(request.get as jest.Mock).mockResolvedValue({ data: converse });
listeners['chat.converse.updateDMConverse'](converse);
await flush();
expect(store.getState().chat.converses[converse._id]?.members).toEqual([
'me',
'other',
]);
});
test('leaving while an invitation refresh is pending wins over the old response', async () => {
const response = deferred<{ data: typeof converse }>();
(request.get as jest.Mock).mockReturnValue(response.promise);
listeners['chat.converse.updateDMConverse'](converse);
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
response.resolve({ data: converse });
await flush();
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
});
test('closing only hides the conversation and a later message can reopen it', async () => {
store.dispatch(chatActions.setConverseInfo(converse));
store.dispatch(chatActions.removeConverse({ converseId: converse._id }));
listeners['chat.message.add'](message);
await flush();
expect(store.getState().chat.converses[converse._id]?.messages).toEqual([
message,
]);
expect(receiveMessage).toHaveBeenCalledWith(message);
});
test('a list membership lookup started before leaving cannot restore the conversation', async () => {
const response = deferred<{ data: typeof converse }>();
(request.get as jest.Mock).mockReturnValue(response.promise);
socketRequest.mockImplementation(async (name) =>
name === 'user.dmlist.getAllConverse' ? [converse._id] : []
);
reconnect();
await flush();
expect(request.get).toHaveBeenCalled();
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
response.resolve({ data: converse });
await flush();
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
});
test('reconnect restores an invitation received while offline', async () => {
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
socketRequest.mockImplementation(async (name) =>
name === 'user.dmlist.getAllConverse' ? [converse._id] : []
);
reconnect();
await flush();
expect(store.getState().chat.converses[converse._id]?.members).toEqual(
converse.members
);
expect(store.getState().chat.converseMembership[converse._id].removed).toBe(
false
);
});
test('a pending list-add response cannot restore or notify after leaving', async () => {
const response = deferred<{ data: unknown }>();
(request.post as jest.Mock).mockReturnValue(response.promise);
listeners['chat.message.add'](message);
await flush();
expect(request.post).toHaveBeenCalled();
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
response.resolve({ data: {} });
await flush();
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
expect(receiveMessage).not.toHaveBeenCalled();
});
test('opening a conversation and a concurrent invitation refresh both complete', async () => {
const response = deferred<{ data: typeof converse }>();
(request.get as jest.Mock)
.mockReturnValueOnce(response.promise)
.mockResolvedValue({ data: converse });
const opening = ensureDMConverse(converse._id, 'me');
const updated = refreshDMConverse(converse._id, 'me');
response.resolve({ data: converse });
await expect(updated).resolves.toEqual(converse);
await expect(opening).resolves.toEqual(converse);
});
test('an older denied membership response cannot erase a newer invitation', async () => {
const response = deferred<{ data: typeof converse }>();
(request.get as jest.Mock)
.mockReturnValueOnce(response.promise)
.mockResolvedValue({ data: converse });
const oldRefresh = refreshDMConverse(converse._id, 'me');
await refreshDMConverse(converse._id, 'me');
response.reject({ code: 403 });
await oldRefresh;
expect(store.getState().chat.converses[converse._id]?.members).toEqual(
converse.members
);
});
});

@ -0,0 +1,119 @@
import React from 'react';
import {
act,
fireEvent,
render,
renderHook,
screen,
waitFor,
} from '@testing-library/react';
import { request } from 'tailchat-shared/api/request';
import { queryClient } from 'tailchat-shared/cache';
import { CacheKey } from 'tailchat-shared/cache/cache';
import { CacheProvider } from 'tailchat-shared/cache/Provider';
import { useUserSettings } from 'tailchat-shared/hooks/model/useUserSettings';
import { setStorage } from 'tailchat-shared/manager/storage';
import { setToasts } from 'tailchat-shared/manager/ui';
import { SettingsSystem } from '../src/components/modals/SettingsView/System';
jest.mock('tailchat-shared/api/request', () => ({
request: { get: jest.fn(), post: jest.fn() },
}));
jest.mock('tailchat-shared', () => ({
...jest.requireActual('tailchat-shared/hooks/model/useUserSettings'),
t: (text: string) => text,
useColorScheme: () => ({ colorScheme: 'dark', setColorScheme: jest.fn() }),
useAlphaMode: () => ({ isAlphaMode: false, setAlphaMode: jest.fn() }),
}));
jest.mock('@/components/FullModal/Factory', () => ({
FullModalFactory: () => null,
}));
jest.mock('@/components/FullModal/Field', () => ({
FullModalField: ({ title, content, tip }: any) => (
<section>
{title}
{content}
{tip}
</section>
),
}));
jest.mock('@/components/LanguageSelect', () => ({
LanguageSelect: () => null,
}));
jest.mock('@/plugin/common', () => ({
pluginColorScheme: [],
pluginSettings: [],
}));
beforeEach(() => {
setStorage(() => ({
get: async () => undefined,
set: async () => {},
save: async () => {},
remove: async () => {},
}));
setToasts(jest.fn());
queryClient.clear();
jest.clearAllMocks();
});
afterEach(() => {
queryClient.clear();
jest.restoreAllMocks();
});
test('a failed privacy setting save restores the last saved value', async () => {
queryClient.setQueryData([CacheKey.userSettings], {
onlyAllowFriendInvite: false,
});
(request.post as jest.Mock).mockRejectedValueOnce(new Error('save failed'));
const errorLog = jest.spyOn(console, 'error').mockImplementation(() => {});
const { result, unmount } = renderHook(() => useUserSettings(), {
wrapper: CacheProvider,
});
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.setSettings({ onlyAllowFriendInvite: true });
});
expect(queryClient.getQueryData([CacheKey.userSettings])).toEqual({
onlyAllowFriendInvite: false,
});
await waitFor(() =>
expect(result.current.settings.onlyAllowFriendInvite).toBe(false)
);
unmount();
errorLog.mockRestore();
queryClient.clear();
});
test('the invitation preference defaults off and persists both switch values', async () => {
queryClient.setQueryData([CacheKey.userSettings], {});
(request.post as jest.Mock)
.mockResolvedValueOnce({ data: { onlyAllowFriendInvite: true } })
.mockResolvedValueOnce({ data: { onlyAllowFriendInvite: false } });
render(
<CacheProvider>
<SettingsSystem />
</CacheProvider>
);
const toggle = screen.getByRole('switch', { name: '仅允许好友邀请创建会话' });
await waitFor(() => expect(toggle.hasAttribute('disabled')).toBe(false));
expect(toggle.getAttribute('aria-checked')).toBe('false');
fireEvent.click(toggle);
await waitFor(() => expect(toggle.getAttribute('aria-checked')).toBe('true'));
expect(request.post).toHaveBeenLastCalledWith('/api/user/setUserSettings', {
settings: { onlyAllowFriendInvite: true },
});
await waitFor(() => expect(toggle.hasAttribute('disabled')).toBe(false));
fireEvent.click(toggle);
await waitFor(() =>
expect(toggle.getAttribute('aria-checked')).toBe('false')
);
expect(request.post).toHaveBeenLastCalledWith('/api/user/setUserSettings', {
settings: { onlyAllowFriendInvite: false },
});
});

@ -69,6 +69,7 @@
"ke050bc7a": "Username does not exist",
"ke0d53ced": "The other party is already your friend and cannot be added again",
"ke19c80a5": "User has no upload permission",
"ke414da82": "This user only allows friends to invite them to multi-person conversations",
"ke5849544": "Wrong password",
"ke82b4383": "You cannot send messages because you are banned",
"ke99cd649": "No access to converse information permission",

@ -69,6 +69,7 @@
"ke050bc7a": "用户名不存在",
"ke0d53ced": "对方已经是您的好友, 不能再次添加",
"ke19c80a5": "用户无上传权限",
"ke414da82": "对方仅允许好友邀请加入多人会话",
"ke5849544": "密码错误",
"ke82b4383": "您因为被禁言无法发送消息",
"ke99cd649": "没有获取会话信息权限",

@ -57,6 +57,7 @@ export class Converse extends TimeStamps implements Base {
members: string[]
): Promise<DocumentType<Converse> | null> {
const converse = await this.findOne({
type: 'DM',
members: {
$all: [...members],
$size: members.length,

@ -21,6 +21,8 @@ export interface UserSettings {
*
*/
messageListVirtualization?: boolean;
/** Only friends may invite this user to a multi conversation; off by default. */
onlyAllowFriendInvite?: boolean;
[key: string]: any;
}

@ -559,7 +559,8 @@
"type": "string"
},
"memberIds": {
"type": "array"
"type": "array",
"items": "string"
}
}
}
@ -3395,6 +3396,24 @@
}
}
}
},
"/chat.converse/leaveDMConverse": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"converseId": {
"type": "string"
}
}
}
}
}
}
}
}
}
}

@ -14,6 +14,7 @@ import type {
ConverseDocument,
ConverseModel,
} from '../../../models/chat/converse';
import type { UserSettings } from '../../../models/user/user';
interface ConverseService
extends TcService,
@ -40,15 +41,28 @@ class ConverseService extends TcService {
{
params: {
converseId: 'string',
memberIds: 'array',
memberIds: { type: 'array', items: 'string' },
},
}
);
this.registerAction('leaveDMConverse', this.leaveDMConverse, {
params: { converseId: 'string' },
});
this.registerAction('findConverseInfo', this.findConverseInfo, {
params: {
converseId: 'string',
},
});
this.registerAction(
'syncConverseMember',
(ctx: TcContext<{ converseId: string }>) =>
this.syncConverseMember(ctx, ctx.params.converseId, ctx.meta.userId),
{
visibility: 'public',
disableSocket: true,
params: { converseId: 'string' },
}
);
this.registerAction('findAndJoinRoom', this.findAndJoinRoom);
}
@ -80,6 +94,7 @@ class ConverseService extends TcService {
if (participantList.length > 2) {
// 多人会话
await this.checkInvitationPreferences(ctx, participantList);
converse = await this.adapter.model.create({
type: 'Multi',
members: participantList.map((id) => new Types.ObjectId(id)),
@ -89,37 +104,10 @@ class ConverseService extends TcService {
const roomId = String(converse._id);
await Promise.all(
participantList.map((memberId) =>
call(ctx).joinSocketIORoom([roomId], memberId)
this.syncConverseMember(ctx, roomId, memberId)
)
);
// 广播更新消息
await this.roomcastNotify(
ctx,
roomId,
'updateDMConverse',
converse.toJSON()
);
// 更新dmlist 异步处理
Promise.all(
participantList.map(async (memberId) => {
try {
await ctx.call(
'user.dmlist.addConverse',
{ converseId: roomId },
{
meta: {
userId: memberId,
},
}
);
} catch (e) {
this.logger.error(e);
}
})
);
if (participantList.length > 2) {
// 如果创建的是一个多人会话(非双人), 发送系统消息
await Promise.all(
@ -147,52 +135,52 @@ class ConverseService extends TcService {
ctx: TcContext<{ converseId: string; memberIds: string[] }>
) {
const userId = ctx.meta.userId;
const { converseId, memberIds } = ctx.params;
const converse = await this.adapter.model.findById(converseId);
if (!converse) {
const { converseId } = ctx.params;
const current = await this.adapter.model.findById(converseId);
if (!current) {
throw new DataNotFoundError();
}
if (!converse.members.map(String).includes(userId)) {
throw new Error('不是会话参与者, 无法添加成员');
if (
current.type !== 'Multi' ||
!current.members.map(String).includes(userId)
) {
throw new NoPermissionError(ctx.meta.t('没有当前会话权限'));
}
converse.members.push(...memberIds.map((uid) => new Types.ObjectId(uid)));
await converse.save();
const requestedMemberIds = _.uniq(ctx.params.memberIds);
const memberIds = _.difference(
requestedMemberIds,
current.members.map(String)
);
await this.checkInvitationPreferences(ctx, memberIds);
const converse = await this.adapter.model.findOneAndUpdate(
{ _id: converseId, type: 'Multi', members: new Types.ObjectId(userId) },
{
$addToSet: {
members: { $each: memberIds.map((id) => new Types.ObjectId(id)) },
},
},
{ new: true }
);
if (!converse) {
throw new NoPermissionError(ctx.meta.t('没有当前会话权限'));
}
await Promise.all(
memberIds.map((uid) =>
call(ctx).joinSocketIORoom([String(converseId)], uid)
requestedMemberIds.map((id) =>
this.syncConverseMember(ctx, converseId, id)
)
);
// 广播更新会话列表
await this.roomcastNotify(
ctx,
converseId,
'updateDMConverse',
converse.toJSON()
(await this.adapter.model.findById(converseId)).toJSON()
);
// 更新dmlist 异步处理
Promise.all(
memberIds.map(async (memberId) => {
try {
await ctx.call(
'user.dmlist.addConverse',
{ converseId },
{
meta: {
userId: memberId,
},
}
);
} catch (e) {
this.logger.error(e);
if (memberIds.length === 0) {
return converse;
}
})
);
// 发送系统消息, 异步处理
await Promise.all(
@ -211,6 +199,120 @@ class ConverseService extends TcService {
return converse;
}
async leaveDMConverse(ctx: TcContext<{ converseId: string }>) {
const { converseId } = ctx.params;
const { userId, t } = ctx.meta;
const memberId = new Types.ObjectId(userId);
const converse = await this.adapter.model.findOneAndUpdate(
{ _id: converseId, type: 'Multi', members: memberId },
{ $pull: { members: memberId } },
{ new: true }
);
if (!converse) {
// 已经不是成员(重试或从未加入): 只清理本人的房间和列表, 不通知其他成员
const current = await this.adapter.model.findById(converseId);
if (current?.type !== 'Multi') {
throw new NoPermissionError(t('没有当前会话权限'));
}
await this.syncConverseMember(ctx, converseId, userId);
return true;
}
await this.syncConverseMember(ctx, converseId, userId);
await this.roomcastNotify(
ctx,
converseId,
'updateDMConverse',
converse.toJSON()
);
return true;
}
private async checkInvitationPreferences(
ctx: TcContext,
memberIds: string[]
) {
const inviterId = ctx.meta.userId;
await Promise.all(
_.without(_.uniq(memberIds), inviterId).map(async (memberId) => {
const settings = await this.broker.call<UserSettings, {}>(
'user.getUserSettings',
{},
{
meta: { ...ctx.meta, userId: memberId },
}
);
if (settings?.onlyAllowFriendInvite === true) {
const isFriend = await this.broker.call(
'friend.checkIsFriend',
{ targetId: inviterId },
{
meta: { ...ctx.meta, userId: memberId },
}
);
if (!isFriend) {
throw new NoPermissionError(
ctx.meta.t('对方仅允许好友邀请加入多人会话')
);
}
}
})
);
}
/** Recheck after side effects so a delayed join or leave follows current membership. */
private async syncConverseMember(
ctx: TcContext,
converseId: string,
userId: string
) {
// ponytail: 每轮处理一次并发的退出/重邀, 超过上限视为异常并失败关闭
for (let pass = 0; pass < 5; pass++) {
const converse = await this.adapter.model.findById(converseId);
const isMember = converse?.members.map(String).includes(userId) ?? false;
if (isMember) {
await call(ctx).joinSocketIORoom([converseId], userId);
try {
await this.broker.call(
'user.dmlist.addConverse',
{ converseId },
{ meta: { ...ctx.meta, userId } }
);
} catch (error) {
if (error.code === 403) {
continue;
}
throw error;
}
await this.unicastNotify(
ctx,
userId,
'updateDMConverse',
converse.toJSON()
);
} else {
await call(ctx).leaveSocketIORoom([converseId], userId);
await this.broker.call(
'user.dmlist.removeConverse',
{ converseId },
{ meta: { ...ctx.meta, userId } }
);
await this.unicastNotify(ctx, userId, 'removeDMConverse', {
converseId,
});
}
const latest = await this.adapter.model.findById(converseId);
if (
(latest?.members.map(String).includes(userId) ?? false) === isMember
) {
return isMember;
}
}
throw new Error(
`Converse ${converseId} membership of ${userId} did not settle`
);
}
/**
*
*/
@ -224,6 +326,9 @@ class ConverseService extends TcService {
const t = ctx.meta.t;
const converse = await this.adapter.findById(converseId);
if (!converse) {
throw new DataNotFoundError();
}
if (userId !== SYSTEM_USERID) {
// not system, check permission
@ -242,7 +347,7 @@ class ConverseService extends TcService {
*/
async findAndJoinRoom(ctx: TcContext) {
const userId = ctx.meta.userId;
const dmConverseIds = await this.adapter.model.findAllJoinedConverseId(
let dmConverseIds = await this.adapter.model.findAllJoinedConverseId(
userId
);
@ -261,6 +366,14 @@ class ConverseService extends TcService {
...subscribeFeaturePanelIds,
]);
const currentIds = await this.adapter.model.findAllJoinedConverseId(userId);
await Promise.all(
_.difference(dmConverseIds, currentIds).map((id) =>
this.syncConverseMember(ctx, id, userId)
)
);
dmConverseIds = currentIds;
return {
dmConverseIds,
groupIds,

@ -13,7 +13,6 @@ import {
NoPermissionError,
call,
PERMISSION,
NotFoundError,
SYSTEM_USERID,
RateLimitError,
getGroupPanelSlowMode,
@ -148,6 +147,7 @@ class MessageService extends TcService {
}>
) {
const { converseId, startId } = ctx.params;
await this.checkConversePermission(ctx, converseId);
const docs = await this.adapter.model.fetchConverseMessage(
converseId,
startId ?? null
@ -337,28 +337,30 @@ class MessageService extends TcService {
if (converseInfo) {
const converseMemberIds = converseInfo.members.map((m) => String(m));
// 通知失败不能影响已经持久化的消息, 因此不等待也不抛出
call(ctx)
.isUserOnline(converseMemberIds)
.then((onlineList) => {
_.zip(converseMemberIds, onlineList).forEach(
([memberId, isOnline]) => {
.then((onlineList) =>
Promise.all(
_.zip(converseMemberIds, onlineList).map(
async ([memberId, isOnline]) => {
if (isOnline) {
// 用户在线,则直接推送,通过客户端来创建会话
this.unicastNotify(ctx, memberId, 'add', json);
await this.unicastNotify(ctx, memberId, 'add', json);
} else {
// 用户离线,确保追加到会话中
ctx.call(
await this.broker.call(
'user.dmlist.addConverse',
{ converseId },
{
meta: {
userId: memberId,
},
}
{ meta: { ...ctx.meta, userId: memberId } }
);
}
}
);
)
)
)
.catch((error) => {
this.logger.error(error);
});
}
}
@ -610,6 +612,8 @@ class MessageService extends TcService {
if (!groupInfo.members.map((m) => m.userId).includes(userId)) {
throw new Error(t('不是群组成员无法搜索消息'));
}
} else {
await this.checkConversePermission(ctx, converseId);
}
const messages = this.adapter.model
@ -680,6 +684,12 @@ class MessageService extends TcService {
const userId = ctx.meta.userId;
const message = await this.adapter.model.findById(messageId);
if (!message) {
throw new DataNotFoundError();
}
if (!message.groupId) {
await this.checkConversePermission(ctx, String(message.converseId));
}
const appendReaction = {
name: emoji,
@ -721,6 +731,12 @@ class MessageService extends TcService {
const userId = ctx.meta.userId;
const message = await this.adapter.model.findById(messageId);
if (!message) {
throw new DataNotFoundError();
}
if (!message.groupId) {
await this.checkConversePermission(ctx, String(message.converseId));
}
const removedReaction = {
name: emoji,
@ -774,30 +790,40 @@ class MessageService extends TcService {
// 鉴权是否能获取到会话内容
if (groupId) {
// 是群组
// 是群组: 必须是群组成员, 且会话必须是该群组的面板, 防止用无关群组冒充私信会话
const group = await call(ctx).getGroupInfo(groupId);
if (group.members.findIndex((m) => String(m.userId) === userId) === -1) {
// 不存在该用户
if (
!group.members.some((m) => String(m.userId) === userId) ||
!group.panels.some((panel) => String(panel.id) === converseId)
) {
throw new NoPermissionError(t('没有当前会话权限'));
}
} else {
// 是普通会话
const converse = await ctx.call<
any,
{
converseId: string;
return { bypassSlowMode: false };
}
>('chat.converse.findConverseInfo', {
converseId,
});
if (!converse) {
throw new NotFoundError(t('没有找到会话信息'));
// 是普通会话: 非成员由 findConverseInfo 拒绝(403)
let converse;
try {
converse = await ctx.call<any, { converseId: string }>(
'chat.converse.findConverseInfo',
{ converseId }
);
} catch (error) {
if (error.code !== 404) {
throw error;
}
const memebers = converse.members ?? [];
if (memebers.findIndex((member) => String(member) === userId) === -1) {
throw new NoPermissionError(t('没有当前会话权限'));
}
if (converse) {
return { bypassSlowMode: false };
}
// 群组面板没有会话记录(404), 按已加入的群组面板校验
const { textPanelIds, subscribeFeaturePanelIds } = await ctx.call<{
textPanelIds: string[];
subscribeFeaturePanelIds: string[];
}>('group.getJoinedGroupAndPanelIds');
if (![...textPanelIds, ...subscribeFeaturePanelIds].includes(converseId)) {
throw new NoPermissionError(t('没有当前会话权限'));
}
return { bypassSlowMode: false };

@ -5,7 +5,13 @@ import type {
UserDMListDocument,
UserDMListModel,
} from '../../../models/user/dmlist';
import { TcService, TcContext, TcDbService, db } from 'tailchat-server-sdk';
import {
TcService,
TcContext,
TcDbService,
db,
call,
} from 'tailchat-server-sdk';
interface UserDMListService
extends TcService,
@ -34,6 +40,8 @@ class UserDMListService extends TcService {
const userId = ctx.meta.userId;
const converseId = ctx.params.converseId;
await call(ctx).getConverseInfo(converseId);
const record = await this.adapter.model.findOrCreate({
userId,
});
@ -44,6 +52,16 @@ class UserDMListService extends TcService {
},
});
try {
await call(ctx).getConverseInfo(converseId);
} catch (error) {
if (error.code === 403) {
// Reconcile against current membership: a leave or re-invite may race this insertion.
await ctx.call('chat.converse.syncConverseMember', { converseId });
}
throw error;
}
return await this.transformDocuments(ctx, {}, res);
}

@ -0,0 +1,682 @@
import { Types } from 'mongoose';
import { TcBroker } from 'tailchat-server-sdk';
import ConverseService from '../../../services/core/chat/converse.service';
import MessageService from '../../../services/core/chat/message.service';
import UserService from '../../../services/core/user/user.service';
import FriendService from '../../../services/core/user/friend.service';
import UserDMListService from '../../../services/core/user/dmlist.service';
describe('Private conversation membership and invitation preferences', () => {
const broker = new TcBroker({ logger: false, cacher: 'Memory' });
const converse = broker.createService(ConverseService) as ConverseService;
const message = broker.createService(MessageService) as MessageService;
const user = broker.createService(UserService) as UserService;
const friend = broker.createService(FriendService) as FriendService;
const dmlist = broker.createService(UserDMListService) as UserDMListService;
const notifications: any[] = [];
const rooms = new Map<string, Set<string>>();
let users: string[];
let converseIds: string[];
let beforeJoin: (userId: string) => Promise<void>;
let beforeLeave: (userId: string) => Promise<void>;
broker.createService({
name: 'gateway',
actions: {
notify(ctx) {
notifications.push(ctx.params);
},
async joinRoom(ctx) {
const userId = ctx.params.userId ?? ctx.meta.userId;
await beforeJoin?.(userId);
const joined = rooms.get(userId) ?? new Set<string>();
ctx.params.roomIds.forEach((id: string) => joined.add(id));
rooms.set(userId, joined);
},
async leaveRoom(ctx) {
await beforeLeave?.(ctx.params.userId ?? ctx.meta.userId);
ctx.params.roomIds.forEach((id: string) =>
rooms.get(ctx.params.userId ?? ctx.meta.userId)?.delete(id)
);
},
checkUserOnline(ctx) {
return ctx.params.userIds.map(() => false);
},
},
});
let joinedPanelIds: string[];
broker.createService({
name: 'group',
actions: {
getGroupInfo: () => ({
members: users.map((userId) => ({ userId })),
panels: [],
}),
getJoinedGroupAndPanelIds: () => ({
groupIds: [],
textPanelIds: joinedPanelIds,
subscribeFeaturePanelIds: [],
}),
},
});
const asUser = (userId: string) => ({
meta: { userId, user: { nickname: 'Test member' } },
});
const invoke = <T = any>(action: string, userId: string, params = {}) =>
broker.call<T, object>(action, params, asUser(userId));
const create = async (memberIds = users.slice(1)) => {
const result = await invoke('chat.converse.createDMConverse', users[0], {
memberIds,
});
converseIds.push(result._id);
return result._id as string;
};
const setPreference = (userId: string, enabled: boolean) =>
invoke('user.setUserSettings', userId, {
settings: { onlyAllowFriendInvite: enabled },
});
beforeAll(() => broker.start());
beforeEach(async () => {
converseIds = [];
notifications.length = 0;
rooms.clear();
beforeJoin = undefined;
beforeLeave = undefined;
joinedPanelIds = [];
users = await Promise.all(
Array.from({ length: 4 }, async () => {
const id = new Types.ObjectId();
await user.adapter.model.create({
_id: id,
email: `${id}@conversation.test`,
nickname: 'Test member',
password: 'unused-test-password',
discriminator: '0000',
});
return String(id);
})
);
});
afterEach(async () => {
jest.restoreAllMocks();
const created = await converse.adapter.model.find({
members: { $in: users.map((id) => new Types.ObjectId(id)) },
});
converseIds.push(...created.map((record) => String(record._id)));
await Promise.all([
message.adapter.model.deleteMany({ converseId: { $in: converseIds } }),
converse.adapter.model.deleteMany({ _id: { $in: converseIds } }),
dmlist.adapter.model.deleteMany({ userId: { $in: users } }),
friend.adapter.model.deleteMany({
$or: [{ from: { $in: users } }, { to: { $in: users } }],
}),
user.adapter.model.deleteMany({ _id: { $in: users } }),
]);
});
afterAll(() => broker.stop());
test.each([undefined, false])(
'allows nonfriend invitations when preference is %s',
async (enabled) => {
if (enabled !== undefined) {
await setPreference(users[1], enabled);
}
const id = await create(users.slice(1, 3));
await invoke('chat.converse.appendDMConverseMembers', users[0], {
converseId: id,
memberIds: [users[3]],
});
expect(
(await converse.adapter.model.findById(id)).members.map(String).sort()
).toEqual([...users].sort());
}
);
test('rejects an opted-in nonfriend carried over from an existing direct message', async () => {
await setPreference(users[1], true);
await create([users[1]]); // One-to-one messages keep their existing policy.
await expect(create(users.slice(1, 3))).rejects.toThrow();
expect(
await converse.adapter.model.countDocuments({
type: 'Multi',
members: new Types.ObjectId(users[0]),
})
).toBe(0);
});
test('uses the invited user friendship direction and respects preference changes', async () => {
await setPreference(users[1], true);
await friend.adapter.model.create({ from: users[0], to: users[1] });
await expect(create(users.slice(1, 3))).rejects.toThrow();
await friend.adapter.model.create({ from: users[1], to: users[0] });
await expect(create(users.slice(1, 3))).resolves.toEqual(
expect.any(String)
);
await friend.adapter.model.deleteMany({ from: users[1], to: users[0] });
await expect(create(users.slice(1, 3))).rejects.toThrow();
await setPreference(users[1], false);
await expect(create(users.slice(1, 3))).resolves.toEqual(
expect.any(String)
);
});
test('checks append invitations before mutating membership', async () => {
const id = await create(users.slice(1, 3));
await setPreference(users[3], true);
await expect(
invoke('chat.converse.appendDMConverseMembers', users[0], {
converseId: id,
memberIds: [users[3]],
})
).rejects.toThrow();
expect(
(await converse.adapter.model.findById(id)).members.map(String)
).not.toContain(users[3]);
await friend.adapter.model.create({ from: users[3], to: users[0] });
await invoke('chat.converse.appendDMConverseMembers', users[0], {
converseId: id,
memberIds: [users[3], users[3]],
});
expect(
(await converse.adapter.model.findById(id)).members
.map(String)
.filter((id) => id === users[3])
).toHaveLength(1);
});
test('append retries a failed socket join after membership was already added', async () => {
const id = await create(users.slice(1, 3));
const append = () =>
invoke('chat.converse.appendDMConverseMembers', users[0], {
converseId: id,
memberIds: [users[3]],
});
beforeJoin = async () => {
throw new Error('Gateway unavailable');
};
await expect(append()).rejects.toThrow('Gateway unavailable');
expect(
(await converse.adapter.model.findById(id)).members.map(String)
).toContain(users[3]);
expect(rooms.get(users[3])?.has(id)).not.toBe(true);
beforeJoin = undefined;
await setPreference(users[3], true);
const previousMessageCount = await message.adapter.model.countDocuments({
converseId: id,
});
await append();
expect(rooms.get(users[3])?.has(id)).toBe(true);
expect(
(await invoke('user.dmlist.getAllConverse', users[3])).map(String)
).toContain(id);
expect(await message.adapter.model.countDocuments({ converseId: id })).toBe(
previousMessageCount
);
});
test('leaves membership, sockets and lists and cannot restore access by reconnecting or sending messages', async () => {
const id = await create(users.slice(1, 3));
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
const stored = await converse.adapter.model.findById(id);
expect(stored.type).toBe('Multi');
expect(stored.members.map(String)).toEqual([users[0], users[2]]);
expect(rooms.get(users[1])?.has(id)).toBe(false);
expect(notifications).toContainEqual({
type: 'unicast',
target: users[1],
eventName: 'notify:chat.converse.removeDMConverse',
eventData: { converseId: id },
});
await expect(
invoke('chat.converse.findConverseInfo', users[1], { converseId: id })
).rejects.toThrow();
await expect(
invoke('chat.message.fetchConverseMessage', users[1], { converseId: id })
).rejects.toThrow();
await expect(
invoke('chat.message.searchMessage', users[1], {
converseId: id,
text: 'member',
})
).rejects.toThrow();
const previous = await message.adapter.model.findOne({ converseId: id });
for (const action of ['addReaction', 'removeReaction']) {
await expect(
invoke(`chat.message.${action}`, users[1], {
messageId: String(previous._id),
emoji: ':smile:',
})
).rejects.toThrow();
}
await expect(
invoke('chat.message.sendMessage', users[1], {
converseId: id,
content: 'Not a member',
})
).rejects.toThrow();
await expect(
invoke('user.dmlist.addConverse', users[1], { converseId: id })
).rejects.toThrow();
await invoke('chat.message.sendMessage', users[0], {
converseId: id,
content: 'Still a member',
});
expect(
(await invoke('user.dmlist.getAllConverse', users[1])).map(String)
).not.toContain(id);
expect(
(await invoke('chat.converse.findAndJoinRoom', users[1])).dmConverseIds
).not.toContain(id);
expect(rooms.get(users[1])?.has(id)).toBe(false);
await expect(
invoke('chat.message.fetchConverseMessage', users[0], { converseId: id })
).resolves.toHaveLength(2);
});
test('rejects nearby private history even when a departed member supplies an unrelated joined group', async () => {
const id = await create(users.slice(1, 3));
const previous = await message.adapter.model.findOne({ converseId: id });
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
await expect(
invoke('chat.message.fetchNearbyMessage', users[1], {
converseId: id,
messageId: String(previous._id),
groupId: String(new Types.ObjectId()),
})
).rejects.toThrow();
});
test('checks joined group panels for panel ids without a private converse document', async () => {
const panelId = new Types.ObjectId();
converseIds.push(String(panelId));
const stored = await message.adapter.model.create({
converseId: panelId,
groupId: new Types.ObjectId(),
author: new Types.ObjectId(users[0]),
content: 'Group history',
});
await expect(
invoke('chat.message.fetchConverseMessage', users[0], {
converseId: String(panelId),
})
).rejects.toThrow();
joinedPanelIds = [String(panelId)];
const result = await invoke('chat.message.fetchConverseMessage', users[0], {
converseId: String(panelId),
});
expect(result.map((item) => String(item._id))).toEqual([
String(stored._id),
]);
});
test('leave retries cleanup and does not affect another member', async () => {
const id = await create(users.slice(1, 3));
const leave = () =>
invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
await leave();
await leave();
expect(
(await converse.adapter.model.findById(id)).members.map(String)
).toEqual([users[0], users[2]]);
expect(
(await invoke('user.dmlist.getAllConverse', users[1])).map(String)
).not.toContain(id);
});
test('leave by a nonmember changes nothing and does not notify the room', async () => {
const id = await create(users.slice(1, 3));
notifications.length = 0;
await invoke('chat.converse.leaveDMConverse', users[3], { converseId: id });
expect(
(await converse.adapter.model.findById(id)).members.map(String)
).toEqual(users.slice(0, 3));
expect(notifications.filter((event) => event.type === 'roomcast')).toEqual(
[]
);
});
test('does not mutate direct messages or groups through append or leave', async () => {
const direct = await create([users[1]]);
const group = await converse.adapter.model.create({
type: 'Group',
members: users.slice(0, 2),
});
converseIds.push(String(group._id));
for (const id of [direct, String(group._id)]) {
await expect(
invoke('chat.converse.leaveDMConverse', users[0], { converseId: id })
).rejects.toThrow();
await expect(
invoke('chat.converse.appendDMConverseMembers', users[0], {
converseId: id,
memberIds: [users[2]],
})
).rejects.toThrow();
expect(
(await converse.adapter.model.findById(id)).members.map(String)
).toEqual(users.slice(0, 2));
}
});
test('does not reuse a multi conversation reduced to two members as a direct message', async () => {
const id = await create(users.slice(1, 3));
await invoke('chat.converse.leaveDMConverse', users[2], { converseId: id });
const direct = await create([users[1]]);
expect(direct).not.toBe(id);
expect((await converse.adapter.model.findById(direct)).type).toBe('DM');
});
test('concurrent appends and leaves preserve unrelated membership updates', async () => {
const id = await create(users.slice(1, 3));
await Promise.all([
invoke('chat.converse.appendDMConverseMembers', users[0], {
converseId: id,
memberIds: [users[3]],
}),
invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }),
]);
expect(
(await converse.adapter.model.findById(id)).members.map(String).sort()
).toEqual([users[0], users[2], users[3]].sort());
});
test('removes a delayed list insertion that races with leaving', async () => {
const id = await create(users.slice(1, 3));
await invoke('user.dmlist.removeConverse', users[1], { converseId: id });
const record = await dmlist.adapter.model.findOne({ userId: users[1] });
let resume: () => void;
let started: () => void;
const paused = new Promise<void>((resolve) => {
resume = resolve;
});
const inserting = new Promise<void>((resolve) => {
started = resolve;
});
const original = dmlist.adapter.model.findByIdAndUpdate.bind(
dmlist.adapter.model
);
jest.spyOn(dmlist.adapter.model, 'findByIdAndUpdate').mockImplementation(((
recordId,
...args
) => {
if (String(recordId) === String(record._id)) {
return paused.then(() => original(recordId, ...args));
}
return original(recordId, ...args);
}) as any);
const originalFind = dmlist.adapter.model.findOrCreate.bind(
dmlist.adapter.model
);
jest.spyOn(dmlist.adapter.model, 'findOrCreate').mockImplementation((async (
...args
) => {
const result = await originalFind(...args);
started();
return result;
}) as any);
const pending = invoke('user.dmlist.addConverse', users[1], {
converseId: id,
});
const rejected = expect(pending).rejects.toThrow();
await inserting;
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
resume();
await rejected;
expect(
(await dmlist.adapter.model.findById(record._id)).converseIds.map(String)
).not.toContain(id);
});
test('a delayed list compensation cannot remove a successful re-invitation', async () => {
const id = await create(users.slice(1, 3));
const record = await dmlist.adapter.model.findOne({ userId: users[1] });
let resumeInsert: () => void;
let inserted: () => void;
const insertPaused = new Promise<void>((resolve) => {
resumeInsert = resolve;
});
const insertion = new Promise<void>((resolve) => {
inserted = resolve;
});
const originalAdd = dmlist.adapter.model.findByIdAndUpdate.bind(
dmlist.adapter.model
);
let pauseInsert = true;
jest.spyOn(dmlist.adapter.model, 'findByIdAndUpdate').mockImplementation(((
recordId,
...args
) => {
if (pauseInsert && String(recordId) === String(record._id)) {
pauseInsert = false;
return (async () => {
const result = await originalAdd(recordId, ...args);
inserted();
await insertPaused;
return result;
})();
}
return originalAdd(recordId, ...args);
}) as any);
const pending = invoke('user.dmlist.addConverse', users[1], {
converseId: id,
});
const rejected = expect(pending).rejects.toThrow();
await insertion;
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
let resumeRemove: () => void;
let removing: () => void;
const removePaused = new Promise<void>((resolve) => {
resumeRemove = resolve;
});
const removal = new Promise<void>((resolve) => {
removing = resolve;
});
const originalRemove = dmlist.adapter.model.updateOne.bind(
dmlist.adapter.model
);
let pauseRemove = true;
jest.spyOn(dmlist.adapter.model, 'updateOne').mockImplementation(((
filter,
...args
) => {
if (pauseRemove && String(filter.userId) === users[1]) {
pauseRemove = false;
return {
exec: async () => {
removing();
await removePaused;
return originalRemove(filter, ...args).exec();
},
};
}
return originalRemove(filter, ...args);
}) as any);
resumeInsert();
await removal;
await invoke('chat.converse.appendDMConverseMembers', users[0], {
converseId: id,
memberIds: [users[1]],
});
resumeRemove();
await rejected;
expect(
(await invoke('user.dmlist.getAllConverse', users[1])).map(String)
).toContain(id);
expect(rooms.get(users[1]).has(id)).toBe(true);
});
test('removes a delayed reconnect join after the member leaves', async () => {
const id = await create(users.slice(1, 3));
let resume: () => void;
let started: () => void;
const paused = new Promise<void>((resolve) => {
resume = resolve;
});
const joining = new Promise<void>((resolve) => {
started = resolve;
});
beforeJoin = async (userId) => {
if (userId === users[1]) {
started();
await paused;
}
};
const reconnect = invoke('chat.converse.findAndJoinRoom', users[1]);
await joining;
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
resume();
expect((await reconnect).dmConverseIds).not.toContain(id);
expect(rooms.get(users[1]).has(id)).toBe(false);
});
test('cleans a delayed invitation join when the invited member leaves', async () => {
const id = await create(users.slice(1, 3));
let resume: () => void;
let started: () => void;
const paused = new Promise<void>((resolve) => {
resume = resolve;
});
const joining = new Promise<void>((resolve) => {
started = resolve;
});
beforeJoin = async (userId) => {
if (userId === users[3]) {
started();
await paused;
}
};
const append = invoke('chat.converse.appendDMConverseMembers', users[0], {
converseId: id,
memberIds: [users[3]],
});
await joining;
await invoke('chat.converse.leaveDMConverse', users[3], { converseId: id });
resume();
await append;
expect(
(await converse.adapter.model.findById(id)).members.map(String)
).not.toContain(users[3]);
expect(rooms.get(users[3]).has(id)).toBe(false);
expect(
(await invoke('user.dmlist.getAllConverse', users[3])).map(String)
).not.toContain(id);
});
test('rejects an append if its actor leaves during preference validation', async () => {
const id = await create(users.slice(1, 3));
let resume: () => void;
let started: () => void;
const paused = new Promise<void>((resolve) => {
resume = resolve;
});
const checking = new Promise<void>((resolve) => {
started = resolve;
});
const original = user.adapter.model.findOne.bind(user.adapter.model);
jest.spyOn(user.adapter.model, 'findOne').mockImplementation(((
filter,
...args
) => {
if (String(filter?._id) === users[3]) {
started();
return paused.then(() => original(filter, ...args));
}
return original(filter, ...args);
}) as any);
const append = invoke('chat.converse.appendDMConverseMembers', users[0], {
converseId: id,
memberIds: [users[3]],
});
const rejected = expect(append).rejects.toThrow();
await checking;
await invoke('chat.converse.leaveDMConverse', users[0], { converseId: id });
resume();
await rejected;
expect(
(await converse.adapter.model.findById(id)).members.map(String)
).toEqual(users.slice(1, 3));
});
test('restores current membership when a re-invite finishes before delayed leave cleanup', async () => {
const id = await create(users.slice(1, 3));
let resume: () => void;
let started: () => void;
const paused = new Promise<void>((resolve) => {
resume = resolve;
});
const leaving = new Promise<void>((resolve) => {
started = resolve;
});
beforeLeave = async (userId) => {
if (userId === users[1]) {
started();
await paused;
}
};
const leave = invoke('chat.converse.leaveDMConverse', users[1], {
converseId: id,
});
await leaving;
await invoke('chat.converse.appendDMConverseMembers', users[0], {
converseId: id,
memberIds: [users[1]],
});
resume();
await leave;
expect(
(await converse.adapter.model.findById(id)).members.map(String)
).toContain(users[1]);
expect(rooms.get(users[1]).has(id)).toBe(true);
expect(
(await invoke('user.dmlist.getAllConverse', users[1])).map(String)
).toContain(id);
expect(
notifications.filter((event) => event.target === users[1]).slice(-1)[0]
.eventName
).toBe('notify:chat.converse.updateDMConverse');
});
test('leave can retry a socket cleanup failure without restoring membership', async () => {
const id = await create(users.slice(1, 3));
beforeLeave = async () => {
throw new Error('Gateway unavailable');
};
await expect(
invoke('chat.converse.leaveDMConverse', users[1], { converseId: id })
).rejects.toThrow('Gateway unavailable');
expect(
(await converse.adapter.model.findById(id)).members.map(String)
).not.toContain(users[1]);
beforeLeave = undefined;
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
expect(rooms.get(users[1]).has(id)).toBe(false);
expect(
(await invoke('user.dmlist.getAllConverse', users[1])).map(String)
).not.toContain(id);
});
test('fails closed when the invitation preference cannot be loaded', async () => {
const original = user.adapter.model.findOne.bind(user.adapter.model);
jest.spyOn(user.adapter.model, 'findOne').mockImplementation(((
filter,
...args
) => {
if (String(filter?._id) === users[1]) {
throw new Error('Settings unavailable');
}
return original(filter, ...args);
}) as any);
await expect(create(users.slice(1, 3))).rejects.toThrow(
'Settings unavailable'
);
expect(
await converse.adapter.model.countDocuments({
type: 'Multi',
members: new Types.ObjectId(users[0]),
})
).toBe(0);
});
});

@ -16,8 +16,60 @@ function createTestMessage(converseId: Types.ObjectId, content = 'bar') {
}
describe('Test "chat.message" service', () => {
const { broker, service, insertTestData } =
createTestServiceBroker<MessageService>(MessageService);
const { broker, service, insertTestData, contextCallMock } =
createTestServiceBroker<MessageService>(MessageService, {
contextCallMockFn: function (
this: { meta: { userId: string } },
actionName
) {
if (actionName === 'user.getUserInfo') {
return { type: 'normal' };
}
if (actionName === 'chat.converse.findConverseInfo') {
return { type: 'Multi', members: [this.meta.userId] };
}
},
});
test('returns the persisted message when the online status lookup fails', async () => {
const converseId = String(new Types.ObjectId());
const userId = String(new Types.ObjectId());
const defaultCall = contextCallMock.getMockImplementation();
const emit = jest.spyOn(broker, 'emit');
contextCallMock.mockImplementation(function (actionName, ...args) {
if (actionName === 'gateway.checkUserOnline') {
throw new Error('Online status unavailable');
}
return defaultCall.call(this, actionName, ...args);
});
try {
const result: MessageDocument = await broker.call(
'chat.message.sendMessage',
{ converseId, content: 'Persisted message' },
{ meta: { userId } }
);
expect(String(result.converseId)).toBe(converseId);
expect(result.content).toBe('Persisted message');
const messages = await service.adapter.model.find({ converseId });
expect(messages).toHaveLength(1);
expect(String(messages[0]._id)).toBe(String(result._id));
expect(emit).toHaveBeenCalledWith(
'chat.message.updateMessage',
expect.objectContaining({
type: 'add',
converseId,
messageId: String(result._id),
}),
expect.any(Object)
);
} finally {
contextCallMock.mockImplementation(defaultCall);
emit.mockRestore();
await service.adapter.model.deleteMany({ converseId });
}
});
describe('Test slow mode bypass policy', () => {
test('plugin bots keep converse access but do not bypass slow mode', async () => {

@ -0,0 +1,26 @@
---
title: Private conversations
---
## Leave a multi-person conversation
Use **Leave conversation** in a multi-person conversation to remove yourself from
its members. After leaving, you no longer receive its messages or have access to
the conversation. Your other connected devices also remove it from the list.
Closing a conversation in the sidebar only hides it. You remain a member, and a
new message can bring it back. Leaving is available even when a multi-person
conversation has only one or two members left.
## Allow invitations only from friends
In **Settings → System settings**, enable **Only allow friends to invite me to
conversations**. This setting is off by default.
When enabled, someone must be in your friend list to include you in a new
multi-person conversation or add you to an existing one. This also applies when
an existing direct message is used to create a multi-person conversation.
The setting does not remove you from existing conversations or block one-to-one
direct messages. Leaving a conversation does not prevent a later invitation;
enable this setting if you want to reject invitations from non-friends.

@ -0,0 +1,17 @@
---
title: 私人会话
---
## 退出多人会话
在多人会话中使用**退出会话**,即可将自己从成员中移除。退出后,你将不再收到该会话的消息,也无法继续访问会话。其他已连接的设备也会将它从会话列表中移除。
侧边栏中的关闭操作只会隐藏会话,你仍然是成员,新消息可能使会话重新出现。即使多人会话只剩一两名成员,仍然可以退出。
## 仅允许好友邀请
在**设置 → 系统设置**中启用**仅允许好友邀请创建会话**。这个开关默认关闭。
开启后,只有在你的好友列表中的用户,才能将你加入新建的多人会话或已有的多人会话。从已有私聊创建多人会话时,同样遵守这个设置。
这个设置不会让你退出已经加入的会话,也不影响一对一私聊。退出会话本身不会阻止后续邀请;如果希望拒绝非好友的邀请,请开启此设置。
Loading…
Cancel
Save