This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { Types } from 'mongoose';
import type { AckDocument, AckModel } from '../../../models/chat/ack';
import { TcService, TcContext, TcDbService } from 'tailchat-server-sdk';
/**
* 消息已读管理
*/
interface AckService extends TcService, TcDbService<AckDocument, AckModel> {}
class AckService extends TcService {
get serviceName(): string {
return 'chat.ack';
}
onInit(): void {
this.registerLocalDb(require('../../../models/chat/ack').default);
// Public fields
this.registerDbField(['userId', 'converseId', 'lastMessageId']);
this.registerAction('update', this.updateAck, {
params: {
converseId: 'string',
lastMessageId: 'string',
},
});
this.registerAction('list', this.listAck, {
params: {
converseIds: {
type: 'array',
items: 'string',
},
},
});
this.registerAction('all', this.allAck);
}
/**
* 更新用户在会话中已读的最后一条消息
*/
async updateAck(
ctx: TcContext<{
converseId: string;
lastMessageId: string;
}>
) {
const { converseId, lastMessageId } = ctx.params;
const userId = ctx.meta.userId;
await this.adapter.model.updateOne(
{
converseId,
userId,
},
{
lastMessageId: new Types.ObjectId(lastMessageId),
},
{
upsert: true,
}
);
// TODO: 如果要实现消息已读可以在此处基于会话id进行通知
}
/**
* 所有的ack信息
*/
async listAck(ctx: TcContext<{ converseIds: string[] }>) {
const userId = ctx.meta.userId;
const { converseIds } = ctx.params;
const list = await this.adapter.model.find({
userId,
converseId: {
$in: [...converseIds],
},
});
return converseIds.map((converseId) => {
const lastMessageId =
list
.find((item) => String(item.converseId) === converseId)
?.lastMessageId?.toString() ?? null;
return lastMessageId
? {
converseId,
lastMessageId,
}
: null;
});
}
/**
* 所有的ack信息
*/
async allAck(ctx: TcContext) {
const userId = ctx.meta.userId;
const list = await this.adapter.model.find({
userId,
});
return await this.transformDocuments(ctx, {}, list);
}
}
export default AckService;

View File

@@ -0,0 +1,273 @@
import _ from 'lodash';
import { Types } from 'mongoose';
import {
TcDbService,
TcService,
TcContext,
UserStruct,
call,
DataNotFoundError,
NoPermissionError,
SYSTEM_USERID,
} from 'tailchat-server-sdk';
import type {
ConverseDocument,
ConverseModel,
} from '../../../models/chat/converse';
interface ConverseService
extends TcService,
TcDbService<ConverseDocument, ConverseModel> {}
class ConverseService extends TcService {
get serviceName(): string {
return 'chat.converse';
}
onInit(): void {
this.registerLocalDb(require('../../../models/chat/converse').default);
this.registerAction('createDMConverse', this.createDMConverse, {
params: {
/**
* 创建私人会话的参与者ID列表
*/
memberIds: { type: 'array', items: 'string' },
},
});
this.registerAction(
'appendDMConverseMembers',
this.appendDMConverseMembers,
{
params: {
converseId: 'string',
memberIds: 'array',
},
}
);
this.registerAction('findConverseInfo', this.findConverseInfo, {
params: {
converseId: 'string',
},
});
this.registerAction('findAndJoinRoom', this.findAndJoinRoom);
}
async createDMConverse(ctx: TcContext<{ memberIds: string[] }>) {
const userId = ctx.meta.userId;
const memberIds = ctx.params.memberIds;
const t = ctx.meta.t;
const participantList = _.uniq([userId, ...memberIds]);
if (participantList.length < 2) {
throw new Error(t('成员数异常,无法创建会话'));
}
let converse: ConverseDocument;
if (participantList.length === 2) {
// 私信会话
converse = await this.adapter.model.findConverseWithMembers(
participantList
);
if (converse === null) {
// 创建新的会话
converse = await this.adapter.model.create({
type: 'DM',
members: participantList.map((id) => new Types.ObjectId(id)),
});
}
}
if (participantList.length > 2) {
// 多人会话
converse = await this.adapter.model.create({
type: 'Multi',
members: participantList.map((id) => new Types.ObjectId(id)),
});
}
const roomId = String(converse._id);
await Promise.all(
participantList.map((memberId) =>
call(ctx).joinSocketIORoom([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(
_.without(participantList, userId).map<Promise<UserStruct>>(
(memberId) => call(ctx).getUserInfo(memberId)
)
).then((infoList) => {
return call(ctx).sendSystemMessage(
t('{{user}} 邀请 {{others}} 加入会话', {
user: ctx.meta.user.nickname,
others: infoList.map((info) => info.nickname).join(', '),
}),
roomId
);
});
}
return await this.transformDocuments(ctx, {}, converse);
}
/**
* 在多人会话中添加成员
*/
async appendDMConverseMembers(
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) {
throw new DataNotFoundError();
}
if (!converse.members.map(String).includes(userId)) {
throw new Error('不是会话参与者, 无法添加成员');
}
converse.members.push(...memberIds.map((uid) => new Types.ObjectId(uid)));
await converse.save();
await Promise.all(
memberIds.map((uid) =>
call(ctx).joinSocketIORoom([String(converseId)], uid)
)
);
// 广播更新会话列表
await this.roomcastNotify(
ctx,
converseId,
'updateDMConverse',
converse.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);
}
})
);
// 发送系统消息, 异步处理
await Promise.all(
memberIds.map<Promise<UserStruct>>((memberId) =>
ctx.call('user.getUserInfo', { userId: memberId })
)
).then((infoList) => {
return call(ctx).sendSystemMessage(
`${ctx.meta.user.nickname} 邀请 ${infoList
.map((info) => info.nickname)
.join(', ')} 加入会话`,
converseId
);
});
return converse;
}
/**
* 查找会话
*/
async findConverseInfo(
ctx: TcContext<{
converseId: string;
}>
) {
const converseId = ctx.params.converseId;
const userId = ctx.meta.userId;
const t = ctx.meta.t;
const converse = await this.adapter.findById(converseId);
if (userId !== SYSTEM_USERID) {
// not system, check permission
const memebers = converse.members ?? [];
if (!memebers.map((member) => String(member)).includes(userId)) {
throw new NoPermissionError(t('没有获取会话信息权限'));
}
}
return await this.transformDocuments(ctx, {}, converse);
}
/**
* 查找用户相关的所有会话并加入房间
* @returns 返回相关信息
*/
async findAndJoinRoom(ctx: TcContext) {
const userId = ctx.meta.userId;
const dmConverseIds = await this.adapter.model.findAllJoinedConverseId(
userId
);
// 获取群组列表
const { groupIds, textPanelIds, subscribeFeaturePanelIds } =
await ctx.call<{
groupIds: string[];
textPanelIds: string[];
subscribeFeaturePanelIds: string[];
}>('group.getJoinedGroupAndPanelIds');
await call(ctx).joinSocketIORoom([
...dmConverseIds,
...groupIds,
...textPanelIds,
...subscribeFeaturePanelIds,
]);
return {
dmConverseIds,
groupIds,
textPanelIds,
subscribeFeaturePanelIds,
};
}
}
export default ConverseService;

View File

@@ -0,0 +1,281 @@
import type { InboxDocument, InboxModel } from '../../../models/chat/inbox';
import {
TcService,
TcContext,
TcDbService,
TcPureContext,
InboxStruct,
} from 'tailchat-server-sdk';
import pMap from 'p-map';
/**
* 收件箱管理
*/
interface InboxService
extends TcService,
TcDbService<InboxDocument, InboxModel> {}
class InboxService extends TcService {
get serviceName(): string {
return 'chat.inbox';
}
onInit(): void {
this.registerLocalDb(require('../../../models/chat/inbox').default);
this.registerEventListener(
'chat.message.updateMessage',
async (payload, ctx) => {
if (
Array.isArray(payload.meta.mentions) &&
payload.meta.mentions.length > 0
) {
const mentions = payload.meta.mentions;
if (payload.type === 'add') {
await Promise.all(
mentions.map((userId) => {
return ctx.call('chat.inbox.append', {
userId,
type: 'message',
payload: {
groupId: payload.groupId,
converseId: payload.converseId,
messageId: payload.messageId,
messageAuthor: payload.author,
messageSnippet: payload.content,
messagePlainContent: payload.plain,
},
});
})
);
} else if (payload.type === 'delete') {
await Promise.all(
mentions.map((userId) => {
return ctx.call('chat.inbox.removeMessage', {
userId,
groupId: payload.groupId,
converseId: payload.converseId,
messageId: payload.messageId,
});
})
);
}
}
}
);
this.registerAction('append', this.append, {
visibility: 'public',
params: {
userId: { type: 'string', optional: true },
type: 'string',
payload: 'any',
},
});
this.registerAction('batchAppend', this.batchAppend, {
visibility: 'public',
params: {
userIds: { type: 'array', items: 'string' },
type: 'string',
payload: 'any',
},
});
this.registerAction('removeMessage', this.removeMessage, {
visibility: 'public',
params: {
userId: { type: 'string', optional: true },
groupId: { type: 'string', optional: true },
converseId: 'string',
messageId: 'string',
},
});
this.registerAction('all', this.all);
this.registerAction('ack', this.ack, {
params: {
inboxItemIds: { type: 'array', items: 'string' },
},
});
this.registerAction('clear', this.clear);
}
/**
* 通用的增加inbox的接口
* 用于内部,插件化的形式
*/
async append(
ctx: TcContext<{
userId?: string;
type: string;
payload: Record<string, any>;
}>
) {
const { userId = ctx.meta.userId, type, payload } = ctx.params;
const doc = await this.adapter.model.create({
userId,
type,
payload,
});
const inboxItem = await this.transformDocuments(ctx, {}, doc);
await this.notifyUsersInboxAppend(ctx, [userId], inboxItem);
await this.emitInboxAppendEvent(ctx, inboxItem);
return true;
}
/**
* append 的多用户版本
*/
async batchAppend(
ctx: TcContext<{
userIds: string[];
type: string;
payload: Record<string, any>;
}>
) {
const { userIds, type, payload } = ctx.params;
const docs = await this.adapter.model.create(
userIds.map((userId) => ({
userId,
type,
payload,
}))
);
const inboxItems: InboxStruct[] = await this.transformDocuments(
ctx,
{},
docs
);
pMap(
inboxItems,
async (inboxItem) => {
await Promise.all([
this.notifyUsersInboxAppend(ctx, [inboxItem.userId], inboxItem),
this.emitInboxAppendEvent(ctx, inboxItem),
]);
},
{
concurrency: 10,
}
);
return true;
}
async removeMessage(
ctx: TcContext<{
userId?: string;
groupId?: string;
converseId: string;
messageId: string;
}>
) {
const {
userId = ctx.meta.userId,
groupId,
converseId,
messageId,
} = ctx.params;
await this.adapter.model.remove({
userId,
type: 'message',
payload: {
groupId,
converseId,
messageId,
},
});
await this.notifyUsersInboxUpdate(ctx, [userId]); // not good, 后面最好修改为发送删除的项而不是所有
return true;
}
/**
* 获取用户收件箱中所有内容
*/
async all(ctx: TcContext<{}>) {
const userId = ctx.meta.userId;
const list = await this.adapter.model.find({
userId,
});
return await this.transformDocuments(ctx, {}, list);
}
/**
* 标记收件箱内容已读
*/
async ack(ctx: TcContext<{ inboxItemIds: string[] }>) {
const inboxItemIds = ctx.params.inboxItemIds;
const userId = ctx.meta.userId;
await this.adapter.model.updateMany(
{
_id: {
$in: [...inboxItemIds],
},
userId,
},
{
readed: true,
}
);
return true;
}
/**
* 清空所有的收件箱内容
*/
async clear(ctx: TcContext) {
const userId = ctx.meta.userId;
await this.adapter.model.deleteMany({
userId,
});
await this.notifyUsersInboxUpdate(ctx, [userId]);
return true;
}
/**
* 通知用户收件箱追加了新的内容
*/
private async notifyUsersInboxAppend(
ctx: TcPureContext,
userIds: string[],
data: any
): Promise<void> {
await this.listcastNotify(ctx, userIds, 'append', { ...data });
}
/**
* 通知用户收件箱有新的内容
*/
private async notifyUsersInboxUpdate(
ctx: TcPureContext,
userIds: string[]
): Promise<void> {
await this.listcastNotify(ctx, userIds, 'updated', {});
}
/**
* 向微服务通知有新的内容产生
*/
private async emitInboxAppendEvent(
ctx: TcPureContext,
inboxItem: InboxStruct
) {
await ctx.emit('chat.inbox.append', inboxItem);
}
}
export default InboxService;

View File

@@ -0,0 +1,624 @@
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('getMessage', this.getMessage, {
params: {
messageId: 'string',
},
});
this.registerAction('deleteMessage', this.deleteMessage, {
params: {
messageId: 'string',
},
});
this.registerAction('searchMessage', this.searchMessage, {
params: {
groupId: { type: 'string', optional: true },
converseId: 'string',
text: '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: groupId ? String(groupId) : undefined,
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: groupId ? String(groupId) : undefined,
converseId: String(converseId),
messageId: String(message._id),
meta: message.meta ?? {},
});
return json;
}
/**
* 获取消息
*/
async getMessage(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 converseId = String(message.converseId);
const groupId = message.groupId;
// 鉴权
if (!groupId) {
// 私人会话
const converseInfo = await call(ctx).getConverseInfo(converseId);
if (!converseInfo.members.map((m) => String(m)).includes(userId)) {
throw new NoPermissionError(t('没有当前会话权限'));
}
} else {
// 群组会话
const groupInfo = await call(ctx).getGroupInfo(String(groupId));
if (!groupInfo.members.map((m) => m.userId).includes(userId)) {
throw new NoPermissionError(t('没有当前会话权限'));
}
}
return message;
}
/**
* 删除消息
* 仅支持群组
*/
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 converseId = String(message.converseId);
const groupId = message.groupId;
if (!groupId) {
// 私人会话
if (userId !== SYSTEM_USERID) {
// 如果是私人发起的, 则直接抛出异常
throw new Error(t('无法删除私人信息'));
}
} else {
// 群组会话, 进行权限校验
const [hasPermission] = await call(ctx).checkUserPermissions(
String(groupId),
userId,
[PERMISSION.core.deleteMessage]
);
if (!hasPermission) {
throw new NoPermissionError(t('没有删除权限')); // 仅管理员允许删除
}
}
await this.adapter.removeById(messageId); // TODO: 考虑是否要改为软删除
this.roomcastNotify(ctx, converseId, 'delete', { converseId, messageId });
ctx.emit('chat.message.updateMessage', {
type: 'delete',
groupId: groupId ? String(groupId) : undefined,
converseId: String(converseId),
messageId: String(message._id),
meta: message.meta ?? {},
});
return true;
}
/**
* 搜索消息
*/
async searchMessage(
ctx: TcContext<{ groupId?: string; converseId: string; text: string }>
) {
const { groupId, converseId, text } = ctx.params;
const userId = ctx.meta.userId;
const t = ctx.meta.t;
if (groupId) {
const groupInfo = await call(ctx).getGroupInfo(groupId);
if (!groupInfo.members.map((m) => m.userId).includes(userId)) {
throw new Error(t('不是群组成员无法搜索消息'));
}
}
const messages = this.adapter.model
.find({
groupId: groupId ?? null,
converseId,
content: {
$regex: text,
},
author: {
$not: {
$eq: SYSTEM_USERID,
},
},
})
.sort({ _id: -1 })
.limit(10)
.maxTimeMS(5 * 1000); // 超过5s的查询直接放弃
return messages;
}
/**
* 基于会话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.map((item) =>
item
? {
converseId: String(item.converseId),
lastMessageId: String(item._id),
}
: null
);
}
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;

View File

@@ -0,0 +1,142 @@
import _ from 'lodash';
import {
TcService,
TcPureContext,
config,
TcDbService,
TcContext,
} from 'tailchat-server-sdk';
import type { ConfigDocument, ConfigModel } from '../../models/config';
/**
* 配置服务器
*/
interface ConfigService
extends TcService,
TcDbService<ConfigDocument, ConfigModel> {}
class ConfigService extends TcService {
config = {}; // 自管理的配置项globalConfig是同步过来的
get serviceName(): string {
return 'config';
}
onInit(): void {
this.registerLocalDb(require('../../models/config').default);
this.registerAction('client', this.client, {
cache: {
keys: [],
ttl: 24 * 60 * 60, // 1 day
},
});
this.registerAction('setClientConfig', this.setClientConfig, {
params: {
key: 'string',
value: 'any',
},
visibility: 'public',
});
this.registerAction('all', this.all, {
visibility: 'public',
});
this.registerAction('get', this.get, {
visibility: 'public',
params: {
key: 'string',
},
});
this.registerAction('set', this.set, {
visibility: 'public',
params: {
key: 'string',
value: 'any',
},
});
this.registerAction('addToSet', this.addToSet, {
visibility: 'public',
params: {
key: 'string',
value: 'any',
},
});
this.registerAuthWhitelist(['/client']);
if (config.env === 'development') {
this.cleanActionCache('client'); // 初始化时清理缓存
}
}
/**
* 全局配置
*
* 用于提供给前端使用
*
* NOTICE: 返回内容比较简单,因此不建议增加缓存
*/
async client(ctx: TcPureContext) {
const persistConfig = await this.adapter.model.getAllClientPersistConfig();
return {
tianji: config.tianji,
uploadFileLimit: config.storage.limit,
emailVerification: config.emailVerification,
disableMsgpack: config.feature.disableMsgpack,
disableUserRegister: config.feature.disableUserRegister,
disableGuestLogin: config.feature.disableGuestLogin,
disableCreateGroup: config.feature.disableCreateGroup,
disablePluginStore: config.feature.disablePluginStore,
disableAddFriend: config.feature.disableAddFriend,
disableTelemetry: config.feature.disableTelemetry,
...persistConfig,
};
}
/**
* set client config in tailchat network
*
* usually call from admin
*/
async setClientConfig(
ctx: TcContext<{
key: string;
value: any;
}>
) {
const { key, value } = ctx.params;
const newConfig = await this.adapter.model.setClientPersistConfig(
key,
value
);
await this.cleanActionCache('client', []);
this.broadcastNotify(ctx, 'updateClientConfig', newConfig);
}
async all(ctx: TcContext) {
return this.config;
}
async get(ctx: TcContext<{ key: string }>) {
return this.config[ctx.params.key] ?? null;
}
async set(ctx: TcContext<{ key: string; value: any }>) {
const { key, value } = ctx.params;
_.set(this.config, key, value);
await this.broker.broadcast('config.updated', { config: this.config });
}
/**
* 添加到设置但不重复
*/
async addToSet(ctx: TcContext<{ key: string; value: any }>) {
const { key, value } = ctx.params;
const originConfig = _.get(this.config, key) ?? [];
_.set(this.config, key, _.uniq([...originConfig, value]));
await this.broker.broadcast('config.updated', { config: this.config });
}
}
export default ConfigService;

View File

@@ -0,0 +1,401 @@
import {
TcService,
PureContext,
TcContext,
buildUploadUrl,
config,
TcDbService,
NoPermissionError,
TcMinioService,
} from 'tailchat-server-sdk';
import _ from 'lodash';
import mime from 'mime';
import type { BucketItemStat, Client as MinioClient } from 'minio';
import { isValidStaticAssetsUrl, isValidStr } from '../../lib/utils';
import path from 'node:path';
import type { FileDocument, FileModel } from '../../models/file';
import { Types } from 'mongoose';
import got from 'got';
import { Readable } from 'node:stream';
interface FileService extends TcService, TcDbService<FileDocument, FileModel> {}
class FileService extends TcService {
get serviceName(): string {
return 'file';
}
get minioClient(): MinioClient {
return this.client;
}
get bucketName(): string {
return config.storage.bucketName;
}
onInit(): void {
this.registerLocalDb(require('../../models/file').default);
this.registerMixin(TcMinioService);
const minioUrl = config.storage.minioUrl;
const [endPoint, port] = minioUrl.split(':');
// https://github.com/designtesbrot/moleculer-minio#settings
this.registerSetting('endPoint', endPoint);
this.registerSetting('port', Number(port));
this.registerSetting('useSSL', config.storage.ssl);
this.registerSetting('accessKey', config.storage.user);
this.registerSetting('secretKey', config.storage.pass);
this.registerSetting('pathStyle', config.storage.pathStyle);
this.registerAction('save', this.save);
this.registerAction('saveFileWithUrl', this.saveFileWithUrl, {
visibility: 'public',
params: {
fileUrl: 'string',
},
});
this.registerAction('get', this.get, {
params: {
objectName: 'string',
},
disableSocket: true,
});
this.registerAction('stat', this.stat, {
params: {
objectName: 'string',
},
disableSocket: true,
});
this.registerAction('delete', this.delete, {
params: {
objectName: 'string',
},
disableSocket: true,
visibility: 'public',
});
}
async onInited() {
// TODO: 看看有没有办法用一个ctx包起来
// Services Available
if (config.feature.disableFileCheck) {
return;
}
const isExists = await this.actions['bucketExists'](
{
bucketName: this.bucketName,
},
{
timeout: 20000, // 20s
}
);
if (isExists === false) {
// bucket不存在创建新的
this.logger.info(
'[File]',
'Bucket 不存在, 创建新的Bucket',
this.bucketName
);
await this.actions['makeBucket']({
bucketName: this.bucketName,
});
}
const buckets = await this.actions['listBuckets']();
this.logger.info(`[File] MinioInfo: | buckets: ${JSON.stringify(buckets)}`);
}
/**
* 通过文件流存储到本地
*/
async save(
ctx: TcContext<
{},
{
$multipart: any;
$params: any;
filename: any;
}
>
) {
const t = ctx.meta.t;
this.logger.info('Received upload $params:', ctx.meta.$params);
return new Promise(async (resolve, reject) => {
const userId = ctx.meta.userId;
this.logger.info('Received upload meta:', ctx.meta);
if (!isValidStr(userId)) {
throw new NoPermissionError(t('用户无上传权限'));
}
const originFilename = String(ctx.meta.filename);
const usage = _.get(ctx, 'meta.$multipart.usage', 'unknown');
const stream = ctx.params as NodeJS.ReadableStream;
(stream as any).on('error', (err) => {
// 这里是文件传输错误处理
// 比如文件过大
this.logger.error('File error received', err.message);
reject(err);
});
try {
const { etag, objectName, url } = await this.saveFileStream(
ctx,
originFilename,
stream,
usage
);
resolve({
etag,
path: `${this.bucketName}/${objectName}`,
url,
});
} catch (e) {
reject(e);
}
});
}
/**
* 通过url存储文件
* 仅允许内部调用
*
* NOTICE: 这里可能会有一个问题,就是可能会出现同一张图片被不同人存储多遍
* 需要优化
* @param fileUrl
*/
async saveFileWithUrl(
ctx: TcContext<{
fileUrl: string;
}>
) {
const fileUrl = ctx.params.fileUrl;
const t = ctx.meta.t;
if (!isValidStaticAssetsUrl(fileUrl)) {
throw new Error(t('文件地址不是一个合法的资源地址'));
}
return new Promise(async (resolve, reject) => {
const req = got.stream(fileUrl);
const stream = Readable.from(req);
stream.on('error', (err: Error) => {
// 这里是文件传输错误处理
// 比如文件过大
this.logger.error('File error received', err.message);
reject(err);
});
try {
const filename = _.last(fileUrl.split('/'));
const { etag, objectName, url } = await this.saveFileStream(
ctx,
filename,
stream
);
resolve({
etag,
path: `${this.bucketName}/${objectName}`,
url,
});
} catch (e) {
reject(e);
}
});
}
/**
* 保存文件流
*/
async saveFileStream(
ctx: TcContext,
filename: string,
fileStream: NodeJS.ReadableStream,
usage = 'unknown'
): Promise<{ etag: string; url: string; objectName: string }> {
const span = ctx.startSpan('file.saveFileStream');
const ext = path.extname(filename);
// 临时仓库
const tmpObjectName = `tmp/${this.randomName()}${ext}`;
const { etag } = await this.actions['putObject'](fileStream, {
meta: {
bucketName: this.bucketName,
objectName: tmpObjectName,
metaData: {
'content-type': mime.getType(ext),
},
},
parentCtx: ctx,
});
const { url, objectName } = await this.persistFile(
ctx,
tmpObjectName,
etag,
ext,
usage
);
span.finish();
return {
etag,
url,
objectName,
};
}
/**
* 持久化存储文件
*/
async persistFile(
ctx: TcContext,
tmpObjectName: string,
etag: string,
ext: string,
usage = 'unknown'
): Promise<{
url: string;
objectName: string;
}> {
const span = ctx.startSpan('file.persistFile');
const userId = ctx.meta.userId;
// 存储在上传者自己的子目录
const objectName = `files/${userId}/${etag}${ext}`;
try {
await this.actions['copyObject'](
{
bucketName: this.bucketName,
objectName,
sourceObject: `/${this.bucketName}/${tmpObjectName}`, // NOTICE: 此处要填入带bucketName的完成路径
conditions: {
matchETag: etag,
},
},
{
parentCtx: ctx,
}
);
} finally {
this.minioClient.removeObject(this.bucketName, tmpObjectName);
}
const url = buildUploadUrl(objectName);
// 异步执行, 将其存入数据库
this.minioClient
.statObject(this.bucketName, objectName)
.then((stat) =>
this.adapter.model.updateOne(
{
bucketName: this.bucketName,
objectName,
},
{
etag,
userId: new Types.ObjectId(userId),
url,
size: stat.size,
metaData: stat.metaData,
usage,
},
{
upsert: true,
}
)
)
.catch((err) => {
this.logger.error(`持久化到数据库失败: ${objectName}`, err);
});
span.finish();
return {
url,
objectName,
};
}
/**
* 获取客户端的信息
*/
async get(
ctx: PureContext<{
objectName: string;
}>
) {
const objectName = ctx.params.objectName;
const stream = await this.minioClient.getObject(
this.bucketName,
objectName
);
this.adapter.model
.updateOne(
{
bucketName: this.bucketName,
objectName,
},
{
$inc: {
views: 1,
},
}
)
.catch(() => {});
return stream;
}
/**
* 获取客户端的信息
*/
async stat(
ctx: PureContext<{
objectName: string;
}>
): Promise<BucketItemStat> {
const objectName = ctx.params.objectName;
const stat = await this.minioClient.statObject(this.bucketName, objectName);
return stat;
}
/**
* 删除文件
*/
async delete(
ctx: TcContext<{
objectName: string;
}>
) {
const objectName = ctx.params.objectName;
try {
// 先删文件再删记录,确保文件被删除
await this.minioClient.removeObject(this.bucketName, objectName);
await this.adapter.model.deleteMany({
bucketName: this.bucketName,
objectName,
});
} catch (err) {
this.logger.warn('Delete file error:', objectName, err);
}
}
private randomName() {
return `unnamed_${this.broker.nodeID}_${Date.now()}`;
}
}
export default FileService;

View File

@@ -0,0 +1,404 @@
import type { IncomingMessage, ServerResponse } from 'http';
import _ from 'lodash';
import { TcSocketIOService } from '../../mixins/socketio.mixin';
import {
TcService,
UserJWTPayload,
config,
t,
parseLanguageFromHead,
builtinAuthWhitelist,
PureContext,
ApiGatewayMixin,
ApiGatewayErrors,
} from 'tailchat-server-sdk';
import { TcHealth } from '../../mixins/health.mixin';
import type { Readable } from 'stream';
import { checkPathMatch } from '../../lib/utils';
import serve from 'serve-static';
import accepts from 'accepts';
import send from 'send';
import path from 'path';
import mime from 'mime';
export default class ApiService extends TcService {
authWhitelist = [];
get serviceName() {
return 'gateway';
}
onInit() {
this.registerMixin(ApiGatewayMixin);
this.registerMixin(
TcSocketIOService({
userAuth: async (token) => {
const user: UserJWTPayload = await this.broker.call(
'user.resolveToken',
{
token,
}
);
return user;
},
disableMsgpack: config.feature.disableMsgpack,
})
);
this.registerMixin(TcHealth());
// More info about settings: https://moleculer.services/docs/0.14/moleculer-web.html
this.registerSetting('port', config.port);
this.registerSetting('routes', this.getRoutes());
// Do not log client side errors (does not log an error response when the error.code is 400<=X<500)
this.registerSetting('log4XXResponses', false);
// Logging the request parameters. Set to any log level to enable it. E.g. "info"
this.registerSetting('logRequestParams', null);
// Logging the response data. Set to any log level to enable it. E.g. "info"
this.registerSetting('logResponseData', null);
// Serve assets from "public" folder
// this.registerSetting('assets', {
// folder: 'public',
// // Options to `server-static` module
// options: {},
// });
this.registerSetting('cors', {
// Configures the Access-Control-Allow-Origin CORS header.
origin: '*',
// Configures the Access-Control-Allow-Methods CORS header.
methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'DELETE'],
// Configures the Access-Control-Allow-Headers CORS header.
allowedHeaders: ['X-Token', 'Content-Type'],
// Configures the Access-Control-Expose-Headers CORS header.
exposedHeaders: [],
// Configures the Access-Control-Allow-Credentials CORS header.
credentials: false,
// Configures the Access-Control-Max-Age CORS header.
maxAge: 3600,
});
// this.registerSetting('rateLimit', {
// // How long to keep record of requests in memory (in milliseconds).
// // Defaults to 60000 (1 min)
// window: 60 * 1000,
// // Max number of requests during window. Defaults to 30
// limit: 60,
// // Set rate limit headers to response. Defaults to false
// headers: true,
// // Function used to generate keys. Defaults to:
// key: (req) => {
// return (
// req.headers['x-forwarded-for'] ||
// req.connection.remoteAddress ||
// req.socket.remoteAddress ||
// req.connection.socket.remoteAddress
// );
// },
// });
this.registerMethod('authorize', this.authorize);
this.registerEventListener(
'gateway.auth.addWhitelists',
({ urls = [] }) => {
this.logger.info('Add auth whitelist:', urls);
this.authWhitelist.push(...urls);
}
);
}
getRoutes() {
return [
// /api
{
path: '/api',
whitelist: [
// Access to any actions in all services under "/api" URL
'**',
],
// Route-level Express middlewares. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Middlewares
use: [],
// Enable/disable parameter merging method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Disable-merging
mergeParams: true,
// Enable authentication. Implement the logic into `authenticate` method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Authentication
authentication: false,
// Enable authorization. Implement the logic into `authorize` method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Authorization
authorization: true,
// The auto-alias feature allows you to declare your route alias directly in your services.
// The gateway will dynamically build the full routes from service schema.
autoAliases: true,
aliases: {},
/**
* Before call hook. You can check the request.
* @param {PureContext} ctx
* @param {Object} route
* @param {IncomingMessage} req
* @param {ServerResponse} res
* @param {Object} data*/
onBeforeCall(
ctx: PureContext<any, { userAgent: string; language: string }>,
route: object,
req: IncomingMessage,
res: ServerResponse
) {
// Set request headers to context meta
ctx.meta.userAgent = req.headers['user-agent'];
ctx.meta.language = parseLanguageFromHead(
req.headers['accept-language']
);
},
/**
* After call hook. You can modify the data.
* @param {PureContext} ctx
* @param {Object} route
* @param {IncomingMessage} req
* @param {ServerResponse} res
* @param {Object} data
*
*/
onAfterCall(
ctx: PureContext,
route: object,
req: IncomingMessage,
res: ServerResponse,
data: object
) {
// Async function which return with Promise
res.setHeader('X-Node-ID', ctx.nodeID);
if (data && data['__raw']) {
// 如果返回值有__raw, 则视为返回了html片段
if (data['header']) {
Object.entries(data['header']).forEach(([key, value]) => {
res.setHeader(key, String(value));
});
}
res.write(data['html'] ?? '');
res.end();
return;
}
return { code: res.statusCode, data };
},
// Calling options. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Calling-options
callingOptions: {},
bodyParsers: {
json: {
strict: false,
limit: '1MB',
},
urlencoded: {
extended: true,
limit: '1MB',
},
},
// Mapping policy setting. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Mapping-policy
mappingPolicy: 'all', // Available values: "all", "restrict"
// Enable/disable logging
logging: true,
},
// /upload
{
// Reference: https://github.com/moleculerjs/moleculer-web/blob/master/examples/file/index.js
path: '/upload',
// You should disable body parsers
bodyParsers: {
json: false,
urlencoded: false,
},
authentication: false,
authorization: true,
aliases: {
// File upload from HTML form
'POST /': {
type: 'multipart',
action: 'file.save',
},
// File upload from AJAX or cURL
'PUT /': {
type: 'stream',
action: 'file.save',
},
},
// https://github.com/mscdex/busboy#busboy-methods
busboyConfig: {
limits: {
files: 1,
fileSize: config.storage.limit,
},
onPartsLimit(busboy, alias, svc) {
this.logger.info('Busboy parts limit!', busboy);
},
onFilesLimit(busboy, alias, svc) {
this.logger.info('Busboy file limit!', busboy);
},
onFieldsLimit(busboy, alias, svc) {
this.logger.info('Busboy fields limit!', busboy);
},
},
callOptions: {
meta: {
a: 5,
},
},
mappingPolicy: 'restrict',
},
// /health
{
path: '/health',
aliases: {
'GET /': 'gateway.health',
},
mappingPolicy: 'restrict',
},
// /static 对象存储文件代理
{
path: '/static',
authentication: false,
authorization: false,
aliases: {
async 'GET /:objectName+'(
this: TcService,
req: IncomingMessage,
res: ServerResponse
) {
const objectName = _.get(req, '$params.objectName');
try {
const result: Readable = await this.broker.call(
'file.get',
{
objectName,
},
{
parentCtx: _.get(req, '$ctx'),
}
);
const ext = path.extname(objectName);
if (ext) {
res.setHeader('Content-Type', mime.getType(ext));
}
// 因为对象存储的对象名都是以文件内容hash存储的因此过期时间可以设置很大
res.setHeader('Cache-Control', 'public, max-age=315360000'); // 10 years => 60 * 60 * 24 * 365 * 10
result.pipe(res);
} catch (err) {
this.logger.error(err);
res.write('static file not found');
res.end();
}
},
},
mappingPolicy: 'restrict',
},
// 静态文件代理
{
path: '/',
authentication: false,
authorization: false,
use: [
serve('public', {
cacheControl: true,
maxAge: '1d', // 1 day for public file, include plugins
setHeaders(res: ServerResponse, path: string, stat: any) {
res.setHeader('Access-Control-Allow-Origin', '*'); // 允许跨域
},
}),
],
onError(req: IncomingMessage, res: ServerResponse, err) {
if (
String(req.method).toLowerCase() === 'get' && // get请求
accepts(req).types(['html']) && // 且请求html页面
err.code === 404
) {
// 如果没有找到, 则返回index.html(for spa)
this.logger.info('fallback to fe entry file');
send(req, './public/index.html', { root: process.cwd() }).pipe(res);
}
},
whitelist: [],
autoAliases: false,
},
];
}
/**
* 获取鉴权白名单
* 在白名单中的路由会被跳过
*/
getAuthWhitelist() {
return _.uniq([...builtinAuthWhitelist, ...this.authWhitelist]);
}
/**
* jwt秘钥
*/
get jwtSecretKey() {
return config.secret;
}
async authorize(
ctx: PureContext<{}, any>,
route: unknown,
req: IncomingMessage
) {
if (checkPathMatch(this.getAuthWhitelist(), req.url)) {
return null;
}
const token = req.headers['x-token'] as string;
if (typeof token !== 'string') {
throw new ApiGatewayErrors.UnAuthorizedError(
ApiGatewayErrors.ERR_NO_TOKEN,
{
error: 'No Token',
}
);
}
// Verify JWT token
try {
const user: UserJWTPayload = await ctx.call('user.resolveToken', {
token,
});
if (user && user._id) {
this.logger.info('[Web] Authenticated via JWT: ', user.nickname);
// Reduce user fields (it will be transferred to other nodes)
ctx.meta.user = _.pick(user, ['_id', 'nickname', 'email', 'avatar']);
ctx.meta.token = token;
ctx.meta.userId = user._id;
} else {
throw new Error(t('Token不合规'));
}
} catch (err) {
throw new ApiGatewayErrors.UnAuthorizedError(
ApiGatewayErrors.ERR_INVALID_TOKEN,
{
error: 'Invalid Token:' + String(err),
}
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
import _ from 'lodash';
import type {
GroupExtraDocument,
GroupExtraModel,
} from '../../../models/group/group-extra';
import {
TcService,
TcContext,
TcDbService,
call,
PERMISSION,
t,
NoPermissionError,
} from 'tailchat-server-sdk';
interface GroupExtraService
extends TcService,
TcDbService<GroupExtraDocument, GroupExtraModel> {}
class GroupExtraService extends TcService {
get serviceName(): string {
return 'group.extra';
}
onInit(): void {
this.registerLocalDb(require('../../../models/group/group-extra').default);
this.registerAction('getGroupData', this.getGroupData, {
params: {
groupId: 'string',
name: 'string',
},
cache: {
keys: ['groupId', 'name'],
ttl: 60 * 60, // 1 hour
},
});
this.registerAction('saveGroupData', this.saveGroupData, {
params: {
groupId: 'string',
name: 'string',
data: 'string',
},
});
this.registerAction('getPanelData', this.getPanelData, {
params: {
groupId: 'string',
panelId: 'string',
name: 'string',
},
cache: {
keys: ['groupId', 'panelId', 'name'],
ttl: 60 * 60, // 1 hour
},
});
this.registerAction('savePanelData', this.savePanelData, {
params: {
groupId: 'string',
panelId: 'string',
name: 'string',
data: 'string',
},
});
}
async getGroupData(
ctx: TcContext<{
groupId: string;
name: string;
}>
) {
const { groupId, name } = ctx.params;
const res = await this.adapter.findOne({
groupId,
panelId: null,
name,
});
return { data: res?.data ?? null };
}
async saveGroupData(
ctx: TcContext<{
groupId: string;
name: string;
data: string;
}>
) {
const { groupId, name, data } = ctx.params;
const userId = ctx.meta.userId;
const [hasPermission] = await call(ctx).checkUserPermissions(
groupId,
userId,
[PERMISSION.core.groupConfig]
);
if (!hasPermission) {
throw new NoPermissionError(t('没有操作权限'));
}
await this.adapter.model.findOneAndUpdate(
{
groupId,
panelId: null,
name,
},
{
data: String(data),
},
{
upsert: true, // Create if not exist
}
);
await this.cleanGroupDataCache(groupId, name);
return true;
}
async getPanelData(
ctx: TcContext<{
groupId: string;
panelId: string;
name: string;
}>
) {
const { groupId, panelId, name } = ctx.params;
const res = await this.adapter.findOne({
groupId,
panelId,
name,
});
return { data: res?.data ?? null };
}
async savePanelData(
ctx: TcContext<{
groupId: string;
panelId: string;
name: string;
data: string;
}>
) {
const { groupId, panelId, name, data } = ctx.params;
const userId = ctx.meta.userId;
const [hasPermission] = await call(ctx).checkUserPermissions(
groupId,
userId,
[PERMISSION.core.managePanel]
);
if (!hasPermission) {
throw new NoPermissionError(t('没有操作权限'));
}
await this.adapter.model.findOneAndUpdate(
{
groupId,
panelId,
name,
},
{
data: String(data),
},
{
upsert: true, // Create if not exist
}
);
await this.cleanGroupPanelDataCache(groupId, panelId, name);
return true;
}
private cleanGroupDataCache(groupId: string, name: string) {
return this.cleanActionCache('getGroupData', [groupId, name]);
}
private cleanGroupPanelDataCache(
groupId: string,
panelId: string,
name: string
) {
return this.cleanActionCache('getPanelData', [groupId, panelId, name]);
}
}
export default GroupExtraService;

View File

@@ -0,0 +1,263 @@
import _ from 'lodash';
import type {
GroupInvite,
GroupInviteDocument,
GroupInviteModel,
} from '../../../models/group/invite';
import {
TcService,
TcContext,
TcDbService,
PureContext,
call,
NoPermissionError,
PERMISSION,
db,
} from 'tailchat-server-sdk';
interface GroupService
extends TcService,
TcDbService<GroupInviteDocument, GroupInviteModel> {}
class GroupService extends TcService {
get serviceName(): string {
return 'group.invite';
}
onInit(): void {
this.registerLocalDb(require('../../../models/group/invite').default);
this.registerAction('createGroupInvite', this.createGroupInvite, {
params: {
groupId: 'string',
inviteType: { type: 'enum', values: ['normal', 'permanent'] },
},
});
this.registerAction('editGroupInvite', this.editGroupInvite, {
params: {
code: 'string',
groupId: 'string',
expiredAt: { type: 'number', optional: true },
usageLimit: { type: 'number', optional: true },
},
});
this.registerAction('getAllGroupInviteCode', this.getAllGroupInviteCode, {
params: {
groupId: 'string',
},
});
this.registerAction('findInviteByCode', this.findInviteByCode, {
params: {
code: 'string',
},
});
this.registerAction('applyInvite', this.applyInvite, {
params: {
code: 'string',
},
});
this.registerAction('deleteInvite', this.deleteInvite, {
params: {
groupId: 'string',
inviteId: 'string',
},
});
}
/**
* 创建群组邀请
*/
async createGroupInvite(
ctx: TcContext<{
groupId: string;
inviteType: 'normal' | 'permanent';
}>
): Promise<GroupInvite> {
const { groupId, inviteType } = ctx.params;
const { userId, t } = ctx.meta;
const [hasNormalPermission, hasUnlimitedPermission] = await call(
ctx
).checkUserPermissions(groupId, userId, [
PERMISSION.core.invite,
PERMISSION.core.unlimitedInvite,
]);
if (
(inviteType === 'normal' && !hasNormalPermission) ||
(inviteType === 'permanent' && !hasUnlimitedPermission)
) {
throw new NoPermissionError(t('没有创建邀请码权限'));
}
const invite = await this.adapter.model.createGroupInvite(
groupId,
userId,
inviteType
);
return await this.transformDocuments(ctx, {}, invite);
}
/**
* 编辑群组邀请码
*/
async editGroupInvite(
ctx: TcContext<{
code: string;
groupId: string;
expiredAt?: number; // 时间戳单位ms
usageLimit?: number;
}>
) {
const { code, groupId, expiredAt, usageLimit } = ctx.params;
const { userId, t } = ctx.meta;
// 检查权限
const [hasEditPermission] = await call(ctx).checkUserPermissions(
groupId,
userId,
[PERMISSION.core.editInvite]
);
if (!hasEditPermission) {
throw new NoPermissionError(t('没有编辑邀请码权限'));
}
const update = {};
if (expiredAt) {
_.set(update, ['expiredAt'], new Date(expiredAt));
} else {
_.set(update, ['$unset', 'expiredAt'], 1);
}
if (usageLimit) {
_.set(update, ['usageLimit'], usageLimit);
} else {
_.set(update, ['$unset', 'usageLimit'], 1);
}
await this.adapter.model.updateOne({ groupId, code }, update);
return true;
}
/**
* 获取所有群组邀请码
*/
async getAllGroupInviteCode(
ctx: TcContext<{
groupId: string;
}>
) {
const groupId = ctx.params.groupId;
const { t, userId } = ctx.meta;
const [hasPermission] = await call(ctx).checkUserPermissions(
groupId,
userId,
[PERMISSION.core.manageInvite]
);
if (!hasPermission) {
throw new NoPermissionError(t('没有查看权限'));
}
const list = await this.adapter.model.find({
groupId,
});
return await this.transformDocuments(ctx, {}, list);
}
/**
* 通过邀请码查找群组邀请信息
*/
async findInviteByCode(
ctx: PureContext<{
code: string;
}>
): Promise<GroupInvite | null> {
const code = ctx.params.code;
const invite = await this.adapter.model.findOne({
code,
});
return await this.transformDocuments(ctx, {}, invite);
}
/**
* 应用群组邀请(通过群组邀请加入群组)
*/
async applyInvite(ctx: TcContext<{ code: string }>): Promise<void> {
const code = ctx.params.code;
const t = ctx.meta.t;
const invite = await this.adapter.model.findOne({
code,
});
if (typeof invite.usageLimit === 'number') {
const usage = invite.usage || 0;
if (usage >= invite.usageLimit) {
throw new Error(t('该邀请码使用次数耗尽'));
}
}
if (new Date(invite.expiredAt).valueOf() < Date.now()) {
throw new Error(t('该邀请码已过期'));
}
const groupId = invite.groupId;
if (_.isNil(groupId)) {
throw new Error(t('群组邀请失效: 群组id为空'));
}
await ctx.call('group.joinGroup', {
groupId: String(groupId),
});
await this.adapter.model.updateOne(
{
_id: new db.Types.ObjectId(invite._id),
},
{
$inc: {
usage: 1,
},
}
);
const creatorInfo = await call(ctx).getUserInfo(String(invite.creator));
await call(ctx).addGroupSystemMessage(
String(groupId),
t('{{nickname}} 通过 {{creator}} 的邀请码加入群组', {
nickname: ctx.meta.user.nickname,
creator: creatorInfo.nickname,
})
);
}
/**
* 删除邀请码
*/
async deleteInvite(ctx: TcContext<{ groupId: string; inviteId: string }>) {
const groupId = ctx.params.groupId;
const inviteId = ctx.params.inviteId;
const { t, userId } = ctx.meta;
const [hasPermission] = await call(ctx).checkUserPermissions(
groupId,
userId,
[PERMISSION.core.manageInvite]
);
if (!hasPermission) {
throw new NoPermissionError(t('没有删除权限'));
}
await this.adapter.model.deleteOne({
_id: inviteId,
groupId,
});
}
}
export default GroupService;

View File

@@ -0,0 +1,45 @@
import type {
PluginManifest,
PluginManifestDocument,
PluginManifestModel,
} from '../../../models/plugin/manifest';
import { TcService, TcContext, TcDbService } from 'tailchat-server-sdk';
interface PluginRegistryService
extends TcService,
TcDbService<PluginManifestDocument, PluginManifestModel> {}
class PluginRegistryService extends TcService {
get serviceName(): string {
return 'plugin.registry';
}
onInit(): void {
this.registerLocalDb(require('../../../models/plugin/manifest').default);
this.registerDbField([
'label',
'name',
'url',
'icon',
'version',
'author',
'description',
'requireRestart',
]);
this.registerAction('list', this.getPluginList, {
cache: {
enabled: true,
ttl: 60 * 60, // 1 hour
},
});
}
async getPluginList(ctx: TcContext): Promise<{
list: PluginManifest[];
}> {
const docs = await this.adapter.find({});
return await this.transformDocuments(ctx, {}, docs);
}
}
export default PluginRegistryService;

View File

@@ -0,0 +1,89 @@
import type { Ref } from '@typegoose/typegoose';
import type { Converse } from '../../../models/chat/converse';
import type {
UserDMList,
UserDMListDocument,
UserDMListModel,
} from '../../../models/user/dmlist';
import { TcService, TcContext, TcDbService, db } from 'tailchat-server-sdk';
interface UserDMListService
extends TcService,
TcDbService<UserDMListDocument, UserDMListModel> {}
class UserDMListService extends TcService {
get serviceName(): string {
return 'user.dmlist';
}
onInit(): void {
this.registerLocalDb(require('../../../models/user/dmlist').default);
this.registerAction('addConverse', this.addConverse, {
params: {
converseId: 'string',
},
});
this.registerAction('removeConverse', this.removeConverse, {
params: {
converseId: 'string',
},
});
this.registerAction('getAllConverse', this.getAllConverse);
}
async addConverse(ctx: TcContext<{ converseId: string }>) {
const userId = ctx.meta.userId;
const converseId = ctx.params.converseId;
const record = await this.adapter.model.findOrCreate({
userId,
});
const res = await this.adapter.model.findByIdAndUpdate(record.doc._id, {
$addToSet: {
converseIds: new db.Types.ObjectId(converseId),
},
});
return await this.transformDocuments(ctx, {}, res);
}
/**
* 移除会话
*/
async removeConverse(ctx: TcContext<{ converseId: string }>) {
const userId = ctx.meta.userId;
const converseId = ctx.params.converseId;
const { modifiedCount } = await this.adapter.model
.updateOne(
{
userId,
},
{
$pull: {
converseIds: converseId,
},
}
)
.exec();
return { modifiedCount };
}
/**
* 获取所有会话
*/
async getAllConverse(ctx: TcContext): Promise<Ref<Converse>[]> {
const userId = ctx.meta.userId;
const doc = await this.adapter.model.findOne({
userId,
});
const res: UserDMList | null = await this.transformDocuments(ctx, {}, doc);
return res?.converseIds ?? [];
}
}
export default UserDMListService;

View File

@@ -0,0 +1,136 @@
import type {
Friend,
FriendDocument,
FriendModel,
} from '../../../models/user/friend';
import { TcService, TcDbService, TcContext } from 'tailchat-server-sdk';
import { isNil } from 'lodash';
interface FriendService
extends TcService,
TcDbService<FriendDocument, FriendModel> {}
class FriendService extends TcService {
get serviceName(): string {
return 'friend';
}
onInit(): void {
this.registerLocalDb(require('../../../models/user/friend').default);
// this.registerMixin(TcCacheCleaner(['cache.clean.friend']));
this.registerAction('getAllFriends', this.getAllFriends);
this.registerAction('buildFriendRelation', this.buildFriendRelation, {
params: {
user1: 'string',
user2: 'string',
},
});
this.registerAction('removeFriend', this.removeFriend, {
params: {
friendUserId: 'string',
},
});
this.registerAction('checkIsFriend', this.checkIsFriend, {
params: {
targetId: 'string',
},
});
this.registerAction('setFriendNickname', this.setFriendNickname, {
params: {
targetId: 'string',
nickname: 'string',
},
});
}
/**
* 获取所有好友
*/
async getAllFriends(ctx: TcContext<{}>) {
const userId = ctx.meta.userId;
const list = await this.adapter.find({
query: {
from: userId,
},
});
const records: Friend[] = await this.transformDocuments(ctx, {}, list);
const res = records.map((r) => ({
id: r.to,
nickname: r.nickname,
}));
return res;
}
/**
* 构建好友关系
*/
async buildFriendRelation(ctx: TcContext<{ user1: string; user2: string }>) {
const { user1, user2 } = ctx.params;
await this.adapter.model.buildFriendRelation(user1, user2);
this.unicastNotify(ctx, user1, 'add', {
userId: user2,
});
this.unicastNotify(ctx, user2, 'add', {
userId: user1,
});
}
/**
* 移除单项好友关系
*/
async removeFriend(ctx: TcContext<{ friendUserId: string }>) {
const { friendUserId } = ctx.params;
const { userId } = ctx.meta;
await this.adapter.model.findOneAndRemove({
from: userId,
to: friendUserId,
});
}
/**
* 检查对方是否为自己好友
*/
async checkIsFriend(ctx: TcContext<{ targetId: string }>) {
const { targetId } = ctx.params;
const userId = ctx.meta.userId;
const isFriend = await this.adapter.model.exists({
from: userId,
to: targetId,
});
return isFriend;
}
/**
* 设置好友昵称
*/
async setFriendNickname(
ctx: TcContext<{ targetId: string; nickname: string }>
) {
const { targetId, nickname } = ctx.params;
const userId = ctx.meta.userId;
const t = ctx.meta.t;
const res = await this.adapter.model.findOneAndUpdate(
{
from: userId,
to: targetId,
},
{
nickname: nickname,
}
);
if (isNil(res)) {
throw new Error(t('设置昵称失败, 没有找到好友关系信息'));
}
return true;
}
}
export default FriendService;

View File

@@ -0,0 +1,190 @@
import {
TcService,
TcDbService,
TcContext,
Errors,
DataNotFoundError,
NoPermissionError,
config,
} from 'tailchat-server-sdk';
import _ from 'lodash';
import type { FriendRequest } from '../../../models/user/friendRequest';
interface FriendService extends TcService, TcDbService<any> {}
class FriendService extends TcService {
get serviceName(): string {
return 'friend.request';
}
onInit(): void {
this.registerLocalDb(require('../../../models/user/friendRequest').default);
// this.registerMixin(TcCacheCleaner(['cache.clean.friend']));
this.registerAction('add', this.add, {
params: {
to: 'string',
message: [{ type: 'string', optional: true }],
},
});
this.registerAction('allRelated', this.allRelated);
this.registerAction('accept', this.accept, {
params: {
requestId: 'string',
},
});
this.registerAction('deny', this.deny, {
params: {
requestId: 'string',
},
});
this.registerAction('cancel', this.cancel, {
params: {
requestId: 'string',
},
});
}
/**
* 请求添加好友
*/
async add(ctx: TcContext<{ to: string; message?: string }>) {
const from = ctx.meta.userId;
const t = ctx.meta.t;
const { to, message } = ctx.params;
if (config.feature.disableAddFriend === true) {
throw new NoPermissionError(t('管理员禁止添加好友功能'));
}
if (from === to) {
throw new Errors.MoleculerError(t('不能添加自己为好友'));
}
const exist = await this.adapter.findOne({
from,
to,
});
if (exist) {
throw new Errors.MoleculerError(t('不能发送重复的好友请求'));
}
const isFriend = await ctx.call('friend.checkIsFriend', { targetId: to });
if (isFriend) {
throw new Error(t('对方已经是您的好友, 不能再次添加'));
}
const doc = await this.adapter.insert({
from,
to,
message,
});
const request = await this.transformDocuments(ctx, {}, doc);
this.listcastNotify(ctx, [from, to], 'add', request);
return request;
}
/**
* 所有与自己相关的好友请求
*/
async allRelated(ctx: TcContext) {
const userId = ctx.meta.userId;
const doc = await this.adapter.find({
query: {
$or: [{ from: userId }, { to: userId }],
},
});
const list = await await this.transformDocuments(ctx, {}, doc);
return list;
}
/**
* 接受好友请求
*/
async accept(ctx: TcContext<{ requestId: string }>) {
const requestId = ctx.params.requestId;
const request: FriendRequest = await this.adapter.findById(requestId);
if (_.isNil(request)) {
throw new DataNotFoundError('该好友请求未找到');
}
if (ctx.meta.userId !== String(request.to)) {
throw new NoPermissionError();
}
await ctx.call('friend.buildFriendRelation', {
user1: String(request.from),
user2: String(request.to),
});
await this.adapter.removeById(request._id);
this.listcastNotify(
ctx,
[String(request.from), String(request.to)],
'remove',
{
requestId,
}
);
}
/**
* 拒绝好友请求
*/
async deny(ctx: TcContext<{ requestId: string }>) {
const requestId = ctx.params.requestId;
const request: FriendRequest = await this.adapter.findById(requestId);
if (_.isNil(request)) {
throw new DataNotFoundError('该好友请求未找到');
}
if (ctx.meta.userId !== String(request.to)) {
throw new NoPermissionError();
}
await this.adapter.removeById(request._id);
this.listcastNotify(
ctx,
[String(request.from), String(request.to)],
'remove',
{
requestId,
}
);
}
/**
* 取消好友请求
*/
async cancel(ctx: TcContext<{ requestId: string }>) {
const requestId = ctx.params.requestId;
const request: FriendRequest = await this.adapter.findById(requestId);
if (_.isNil(request)) {
throw new DataNotFoundError('该好友请求未找到');
}
if (ctx.meta.userId !== String(request.from)) {
throw new NoPermissionError();
}
await this.adapter.removeById(request._id);
this.listcastNotify(
ctx,
[String(request.from), String(request.to)],
'remove',
{
requestId,
}
);
}
}
export default FriendService;

View File

@@ -0,0 +1,76 @@
import type { MailDocument, MailModel } from '../../../models/user/mail';
import { TcService, TcContext, TcDbService } from 'tailchat-server-sdk';
import ejs from 'ejs';
import path from 'path';
interface MailService extends TcService, TcDbService<MailDocument, MailModel> {}
class MailService extends TcService {
smtpServiceAvailable = false;
get serviceName(): string {
return 'mail';
}
onInit(): void {
this.registerLocalDb(require('../../../models/user/mail').default);
this.registerAction('sendMail', this.sendMail, {
visibility: 'public',
params: {
to: 'string',
subject: 'string',
html: 'string',
},
});
}
onInited() {
this.adapter.model.verifyMailService().then((available) => {
if (available) {
this.logger.info('SMTP 服务可用');
} else {
this.logger.warn('SMTP 服务不可用');
}
this.smtpServiceAvailable = available;
});
}
/**
* 发送邮件
*/
async sendMail(
ctx: TcContext<{
to: string;
subject: string;
html: string;
}>
) {
if (!this.smtpServiceAvailable) {
throw new Error('SMTP 服务不可用');
}
const { to, subject, html } = ctx.params;
const { t } = ctx.meta;
try {
const info = await this.adapter.model.sendMail({
to,
subject,
html: await ejs.renderFile(
path.resolve(__dirname, '../../../views/mail.ejs'),
{
body: html,
}
),
});
this.logger.info('sendMailSuccess:', info);
} catch (err) {
this.logger.error('sendMailFailed:', err);
throw new Error(t('邮件发送失败'));
}
}
}
export default MailService;

File diff suppressed because it is too large Load Diff