You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailchat/server/services/core/chat/message.service.ts

538 lines
14 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import moment from 'moment';
import { Types } from 'mongoose';
import type {
MessageDocument,
MessageModel,
} from '../../../models/chat/message';
import {
TcService,
TcDbService,
GroupBaseInfo,
TcContext,
DataNotFoundError,
NoPermissionError,
call,
PERMISSION,
NotFoundError,
SYSTEM_USERID,
} from 'tailchat-server-sdk';
import type { Group } from '../../../models/group/group';
import { isValidStr } from '../../../lib/utils';
import _ from 'lodash';
interface MessageService
extends TcService,
TcDbService<MessageDocument, MessageModel> {}
class MessageService extends TcService {
get serviceName(): string {
return 'chat.message';
}
onInit(): void {
this.registerLocalDb(require('../../../models/chat/message').default);
this.registerAction('fetchConverseMessage', this.fetchConverseMessage, {
params: {
converseId: 'string',
startId: { type: 'string', optional: true },
},
});
this.registerAction('fetchNearbyMessage', this.fetchNearbyMessage, {
params: {
groupId: { type: 'string', optional: true },
converseId: 'string',
messageId: 'string',
num: { type: 'number', optional: true },
},
});
this.registerAction('sendMessage', this.sendMessage, {
params: {
converseId: 'string',
groupId: [{ type: 'string', optional: true }],
content: 'string',
plain: { type: 'string', optional: true },
meta: { type: 'any', optional: true },
},
});
this.registerAction('recallMessage', this.recallMessage, {
params: {
messageId: 'string',
},
});
this.registerAction('deleteMessage', this.deleteMessage, {
params: {
messageId: 'string',
},
});
this.registerAction(
'fetchConverseLastMessages',
this.fetchConverseLastMessages,
{
params: {
converseIds: 'array',
},
}
);
this.registerAction('addReaction', this.addReaction, {
params: {
messageId: 'string',
emoji: 'string',
},
});
this.registerAction('removeReaction', this.removeReaction, {
params: {
messageId: 'string',
emoji: 'string',
},
});
}
/**
* 获取会话消息
*/
async fetchConverseMessage(
ctx: TcContext<{
converseId: string;
startId?: string;
}>
) {
const { converseId, startId } = ctx.params;
const docs = await this.adapter.model.fetchConverseMessage(
converseId,
startId ?? null
);
return this.transformDocuments(ctx, {}, docs);
}
/**
* 获取一条消息附近的消息
* 以会话为准
*
* 额外需要converseId是为了防止暴力查找
*/
async fetchNearbyMessage(
ctx: TcContext<{
groupId?: string;
converseId: string;
messageId: string;
num?: number;
}>
) {
const { groupId, converseId, messageId, num = 5 } = ctx.params;
const { t } = ctx.meta;
// 鉴权是否能获取到会话内容
await this.checkConversePermission(ctx, converseId, groupId);
const message = await this.adapter.model
.findOne({
_id: new Types.ObjectId(messageId),
converseId: new Types.ObjectId(converseId),
})
.limit(1)
.exec();
if (!message) {
throw new DataNotFoundError(t('没有找到消息'));
}
const [prev, next] = await Promise.all([
this.adapter.model
.find({
_id: {
$lt: new Types.ObjectId(messageId),
},
converseId: new Types.ObjectId(converseId),
})
.sort({ _id: -1 })
.limit(num)
.exec()
.then((arr) => arr.reverse()),
this.adapter.model
.find({
_id: {
$gt: new Types.ObjectId(messageId),
},
converseId: new Types.ObjectId(converseId),
})
.sort({ _id: 1 })
.limit(num)
.exec(),
]);
console.log({ prev, next });
return this.transformDocuments(ctx, {}, [...prev, message, ...next]);
}
/**
* 发送普通消息
*/
async sendMessage(
ctx: TcContext<{
converseId: string;
groupId?: string;
content: string;
plain?: string;
meta?: object;
}>
) {
const { converseId, groupId, content, plain, meta } = ctx.params;
const userId = ctx.meta.userId;
const t = ctx.meta.t;
const isGroupMessage = isValidStr(groupId);
/**
* 鉴权
*/
await this.checkConversePermission(ctx, converseId, groupId); // 鉴权是否能获取到会话内容
if (isGroupMessage) {
// 是群组消息, 鉴权是否禁言
const groupInfo = await call(ctx).getGroupInfo(groupId);
const member = groupInfo.members.find((m) => String(m.userId) === userId);
if (member) {
// 因为有机器人,所以如果没有在成员列表中找到不报错
if (new Date(member.muteUntil).valueOf() > new Date().valueOf()) {
throw new Error(t('您因为被禁言无法发送消息'));
}
}
}
const message = await this.adapter.insert({
converseId: new Types.ObjectId(converseId),
groupId:
typeof groupId === 'string' ? new Types.ObjectId(groupId) : undefined,
author: new Types.ObjectId(userId),
content,
meta,
});
const json = await this.transformDocuments(ctx, {}, message);
if (isGroupMessage) {
this.roomcastNotify(ctx, converseId, 'add', json);
} else {
// 如果是私信的话需要基于用户去推送
// 因为用户可能不订阅消息(删除了dmlist)
const converseInfo = await call(ctx).getConverseInfo(converseId);
if (converseInfo) {
const converseMemberIds = converseInfo.members.map((m) => String(m));
call(ctx)
.isUserOnline(converseMemberIds)
.then((onlineList) => {
_.zip(converseMemberIds, onlineList).forEach(
([memberId, isOnline]) => {
if (isOnline) {
// 用户在线,则直接推送,通过客户端来创建会话
this.unicastNotify(ctx, memberId, 'add', json);
} else {
// 用户离线,确保追加到会话中
ctx.call(
'user.dmlist.addConverse',
{ converseId },
{
meta: {
userId: memberId,
},
}
);
}
}
);
});
}
}
ctx.emit('chat.message.updateMessage', {
type: 'add',
groupId: String(groupId),
converseId: String(converseId),
messageId: String(message._id),
author: userId,
content,
plain,
meta: meta ?? {},
});
return json;
}
/**
* 撤回消息
*/
async recallMessage(ctx: TcContext<{ messageId: string }>) {
const { messageId } = ctx.params;
const { t, userId } = ctx.meta;
const message = await this.adapter.model.findById(messageId);
if (!message) {
throw new DataNotFoundError(t('该消息未找到'));
}
if (message.hasRecall === true) {
throw new Error(t('该消息已被撤回'));
}
// 消息撤回限时
if (
moment().valueOf() - moment(message.createdAt).valueOf() >
15 * 60 * 1000
) {
throw new Error(t('无法撤回 {{minutes}} 分钟前的消息', { minutes: 15 }));
}
let allowToRecall = false;
//#region 撤回权限检查
const groupId = message.groupId;
if (groupId) {
// 是一条群组信息
const group: GroupBaseInfo = await ctx.call('group.getGroupBasicInfo', {
groupId: String(groupId),
});
if (String(group.owner) === userId) {
allowToRecall = true; // 是管理员 允许修改
}
}
if (String(message.author) === String(userId)) {
// 撤回者是消息所有者
allowToRecall = true;
}
if (allowToRecall === false) {
throw new NoPermissionError(t('撤回失败, 没有权限'));
}
//#endregion
const converseId = String(message.converseId);
message.hasRecall = true;
await message.save();
const json = await this.transformDocuments(ctx, {}, message);
this.roomcastNotify(ctx, converseId, 'update', json);
ctx.emit('chat.message.updateMessage', {
type: 'recall',
groupId: String(groupId),
converseId: String(converseId),
messageId: String(message._id),
meta: message.meta ?? {},
});
return json;
}
/**
* 删除消息
* 仅支持群组
*/
async deleteMessage(ctx: TcContext<{ messageId: string }>) {
const { messageId } = ctx.params;
const { t, userId } = ctx.meta;
const message = await this.adapter.model.findById(messageId);
if (!message) {
throw new DataNotFoundError(t('该消息未找到'));
}
const groupId = message.groupId;
if (!groupId) {
throw new Error(t('无法删除私人信息'));
}
const [hasPermission] = await call(ctx).checkUserPermissions(
String(groupId),
userId,
[PERMISSION.core.deleteMessage]
);
if (!hasPermission) {
throw new NoPermissionError(t('没有删除权限')); // 仅管理员允许删除
}
const converseId = String(message.converseId);
await this.adapter.removeById(messageId); // TODO: 考虑是否要改为软删除
this.roomcastNotify(ctx, converseId, 'delete', { converseId, messageId });
ctx.emit('chat.message.updateMessage', {
type: 'delete',
groupId: String(groupId),
converseId: String(converseId),
messageId: String(message._id),
meta: message.meta ?? {},
});
return true;
}
/**
* 基于会话id获取会话最后一条消息的id
*/
async fetchConverseLastMessages(ctx: TcContext<{ converseIds: string[] }>) {
const { converseIds } = ctx.params;
// 这里使用了多个请求但是通过limit=1会将查询范围降低到最低
// 这种方式会比用聚合操作实际上更加节省资源
const list = await Promise.all(
converseIds.map((id) => {
return this.adapter.model
.findOne(
{
converseId: new Types.ObjectId(id),
},
{
_id: 1,
converseId: 1,
}
)
.sort({
_id: -1,
})
.limit(1)
.exec();
})
);
return list.filter(Boolean).map((item) => ({
converseId: String(item.converseId),
lastMessageId: String(item._id),
}));
}
async addReaction(
ctx: TcContext<{
messageId: string;
emoji: string;
}>
) {
const { messageId, emoji } = ctx.params;
const userId = ctx.meta.userId;
const message = await this.adapter.model.findById(messageId);
const appendReaction = {
name: emoji,
author: new Types.ObjectId(userId),
};
await this.adapter.model.updateOne(
{
_id: messageId,
},
{
$push: {
reactions: {
...appendReaction,
},
},
}
);
const converseId = String(message.converseId);
this.roomcastNotify(ctx, converseId, 'addReaction', {
converseId,
messageId,
reaction: {
...appendReaction,
},
});
return true;
}
async removeReaction(
ctx: TcContext<{
messageId: string;
emoji: string;
}>
) {
const { messageId, emoji } = ctx.params;
const userId = ctx.meta.userId;
const message = await this.adapter.model.findById(messageId);
const removedReaction = {
name: emoji,
author: new Types.ObjectId(userId),
};
await this.adapter.model.updateOne(
{
_id: messageId,
},
{
$pull: {
reactions: {
...removedReaction,
},
},
}
);
const converseId = String(message.converseId);
this.roomcastNotify(ctx, converseId, 'removeReaction', {
converseId,
messageId,
reaction: {
...removedReaction,
},
});
return true;
}
/**
* 校验会话权限,如果没有抛出异常则视为正常
*/
private async checkConversePermission(
ctx: TcContext,
converseId: string,
groupId?: string
) {
const userId = ctx.meta.userId;
const t = ctx.meta.t;
if (userId === SYSTEM_USERID) {
return;
}
const userInfo = await call(ctx).getUserInfo(userId); // TODO: 可以通过在默认的meta信息中追加用户类型来减少一次请求来优化
if (userInfo.type === 'pluginBot') {
// 如果是插件机器人则拥有所有权限(开放平台机器人需要添加到群组才有会话权限)
return;
}
// 鉴权是否能获取到会话内容
if (groupId) {
// 是群组
const group = await call(ctx).getGroupInfo(groupId);
if (group.members.findIndex((m) => String(m.userId) === userId) === -1) {
// 不存在该用户
throw new NoPermissionError(t('没有当前会话权限'));
}
} else {
// 是普通会话
const converse = await ctx.call<
any,
{
converseId: string;
}
>('chat.converse.findConverseInfo', {
converseId,
});
if (!converse) {
throw new NotFoundError(t('没有找到会话信息'));
}
const memebers = converse.members ?? [];
if (memebers.findIndex((member) => String(member) === userId) === -1) {
throw new NoPermissionError(t('没有当前会话权限'));
}
}
}
}
export default MessageService;