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

1
server/models/README.md Normal file
View File

@@ -0,0 +1 @@
Reference: https://typegoose.github.io/typegoose/docs/guides/quick-start-guide

44
server/models/chat/ack.ts Normal file
View File

@@ -0,0 +1,44 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
index,
} from '@typegoose/typegoose';
import type { Base } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { User } from '../user/user';
import { Converse } from './converse';
import { Message } from './message';
/**
* 消息已读管理
*/
@index({ userId: 1, converseId: 1 }, { unique: true }) // 一组userId和converseId应当唯一(用户为先)
export class Ack implements Base {
_id: Types.ObjectId;
id: string;
@prop({
ref: () => User,
})
userId: Ref<User>;
@prop({
ref: () => Converse,
})
converseId: Ref<Converse>;
@prop({
ref: () => Message,
})
lastMessageId: Ref<Message>;
}
export type AckDocument = DocumentType<Ack>;
const model = getModelForClass(Ack);
export type AckModel = typeof model;
export default model;

View File

@@ -0,0 +1,98 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
ReturnModelType,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import { Types } from 'mongoose';
import { NAME_REGEXP } from '../../lib/const';
import { User } from '../user/user';
/**
* 设计参考: https://discord.com/developers/docs/resources/channel
*/
const converseType = [
'DM', // 私信
'Multi', // 多人会话
'Group', // 群组
] as const;
/**
* 聊天会话
*/
export class Converse extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop({
trim: true,
match: NAME_REGEXP,
})
name?: string;
/**
* 会话类型
*/
@prop({
enum: converseType,
type: () => String,
})
type!: (typeof converseType)[number];
/**
* 会话参与者
* DM会话与多人会话有值
*/
@prop({ ref: () => User })
members?: Ref<User>[];
/**
* 查找固定成员已存在的会话
*/
static async findConverseWithMembers(
this: ReturnModelType<typeof Converse>,
members: string[]
): Promise<DocumentType<Converse> | null> {
const converse = await this.findOne({
members: {
$all: [...members],
$size: members.length,
},
});
return converse;
}
/**
* 获取用户所有加入的会话
*/
static async findAllJoinedConverseId(
this: ReturnModelType<typeof Converse>,
userId: string
): Promise<string[]> {
const conserves = await this.find(
{
members: new Types.ObjectId(userId),
},
{
_id: 1,
}
);
return conserves
.map((c) => c.id)
.filter(Boolean)
.map(String);
}
}
export type ConverseDocument = DocumentType<Converse>;
const model = getModelForClass(Converse);
export type ConverseModel = typeof model;
export default model;

View File

@@ -0,0 +1,99 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
index,
modelOptions,
Severity,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import type { InboxStruct } from 'tailchat-server-sdk';
import { User } from '../user/user';
/**
* @deprecated use InboxStruct directly
*/
interface InboxMessage {
/**
* 消息所在群组Id
*/
groupId?: string;
/**
* 消息所在会话Id
*/
converseId: string;
messageId: string;
/**
* 消息发送者
*/
messageAuthor: string;
/**
* 消息片段,用于消息的预览/发送通知
*/
messageSnippet: string;
/**
* 消息去除富文本标记的原始内容,用于推送
*/
messagePlainContent: string;
}
/**
* 收件箱管理
*/
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
@index({ userId: 1 })
export class Inbox extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
/**
* 接收方的id
*/
@prop({
ref: () => User,
})
userId: Ref<User>;
@prop({
type: () => String,
})
type: string;
/**
* @deprecated please use payload
*/
@prop()
message?: InboxMessage;
/**
* 信息体,没有类型
*/
@prop()
payload?: InboxStruct['payload'];
/**
* 是否已读
*/
@prop({
default: false,
})
readed: boolean;
}
export type InboxDocument = DocumentType<Inbox>;
const model = getModelForClass(Inbox);
export type InboxModel = typeof model;
export default model;

View File

@@ -0,0 +1,107 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
ReturnModelType,
modelOptions,
Severity,
index,
} from '@typegoose/typegoose';
import { Group } from '../group/group';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import { Converse } from './converse';
import { User } from '../user/user';
import type { FilterQuery, Types } from 'mongoose';
import type { MessageMetaStruct } from 'tailchat-server-sdk';
class MessageReaction {
/**
* 消息反应名
* 可以直接为emoji表情
*/
@prop()
name: string;
@prop({ ref: () => User })
author?: Ref<User>;
}
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
@index({ createdAt: -1 })
@index({ converseId: 1, _id: -1 }) // for fetchConverseMessage
export class Message extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop()
content: string;
@prop({ ref: () => User })
author?: Ref<User>;
@prop({ ref: () => Group })
groupId?: Ref<Group>;
/**
* 会话ID 必填
* 私信的本质就是创建一个双人的会话
*/
@prop({ ref: () => Converse, index: true })
converseId!: Ref<Converse>;
@prop({ type: () => MessageReaction })
reactions?: MessageReaction[];
/**
* 是否已撤回
*/
@prop({
default: false,
})
hasRecall: boolean;
/**
* 消息的其他数据
*/
@prop()
meta?: MessageMetaStruct;
/**
* 获取会话消息
*/
static async fetchConverseMessage(
this: ReturnModelType<typeof Message>,
converseId: string,
startId: string | null,
limit = 50
) {
const conditions: FilterQuery<MessageDocument> = {
converseId,
};
if (startId !== null) {
conditions['_id'] = {
$lt: startId,
};
}
const res = await this.find({ ...conditions })
.sort({ _id: -1 })
.limit(limit)
.exec();
return res;
}
}
export type MessageDocument = DocumentType<Message>;
const model = getModelForClass(Message);
export type MessageModel = typeof model;
export default model;

79
server/models/config.ts Normal file
View File

@@ -0,0 +1,79 @@
import {
getModelForClass,
prop,
DocumentType,
modelOptions,
Severity,
ReturnModelType,
index,
} from '@typegoose/typegoose';
import type { Base } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
@index({ name: 1 })
export class Config implements Base {
_id: Types.ObjectId;
id: string;
static globalClientConfigName = '__client_config__';
@prop()
name: string;
/**
* config data
*/
@prop()
data: object;
static async getAllClientPersistConfig(
this: ReturnModelType<typeof Config>
): Promise<object> {
const config = await this.findOne({
name: Config.globalClientConfigName,
});
return config?.data ?? {};
}
/**
* set global client persist config from mongodb
*
* return all config from db
*/
static async setClientPersistConfig(
this: ReturnModelType<typeof Config>,
key: string,
value: any
): Promise<Record<string, any>> {
const newConfig = await this.findOneAndUpdate(
{
name: Config.globalClientConfigName,
},
{
$set: {
[`data.${key}`]: value,
},
},
{
upsert: true,
new: true,
}
);
return newConfig.data;
}
}
export type ConfigDocument = DocumentType<Config>;
const model = getModelForClass(Config);
export type ConfigModel = typeof model;
export default model;

76
server/models/file.ts Normal file
View File

@@ -0,0 +1,76 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
modelOptions,
Severity,
index,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { User } from './user/user';
/**
* 聊天会话
*/
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
@index({ bucketName: 1, objectName: 1 })
@index({ url: 1 })
export class File extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop()
etag: string;
@prop({ ref: () => User })
userId?: Ref<User>;
@prop()
bucketName: string;
@prop()
objectName: string;
@prop()
url: string;
/**
* 文件大小, 单位: Byte
*/
@prop()
size: number;
/**
* 浏览量
*/
@prop({
default: 0,
})
views: number;
@prop()
metaData: object;
/**
* 这个文件是用于哪里
* for example: chat, group, user
*/
@prop({
default: 'unknown',
})
usage: string;
}
export type FileDocument = DocumentType<File>;
const model = getModelForClass(File);
export type FileModel = typeof model;
export default model;

View File

@@ -0,0 +1,51 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
modelOptions,
Severity,
index,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
/**
* Group Extra Data
* Use to storage large data when panel load or others
*/
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
@index({ groupId: 1, panelId: 1, name: 1 })
export class GroupExtra extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop({
ref: () => GroupExtra,
})
groupId: Ref<GroupExtra>;
@prop()
panelId?: string;
@prop()
name: string;
/**
* Only allow string
*/
@prop()
data: string;
}
export type GroupExtraDocument = DocumentType<GroupExtra>;
const model = getModelForClass(GroupExtra);
export type GroupExtraModel = typeof model;
export default model;

View File

@@ -0,0 +1,422 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
ReturnModelType,
modelOptions,
Severity,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import _ from 'lodash';
import { Types } from 'mongoose';
import {
allPermission,
call,
GroupPanelType,
NoPermissionError,
PERMISSION,
SYSTEM_USERID,
TcContext,
} from 'tailchat-server-sdk';
import { User } from '../user/user';
class GroupMember {
@prop({
type: () => String,
})
roles?: string[]; // 角色权限组id
@prop({
ref: () => User,
})
userId: Ref<User>;
/**
* 禁言到xxx 为止
*/
@prop()
muteUntil?: Date;
}
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
export class GroupPanel {
@prop()
id: string; // 在群组中唯一, 可以用任意方式进行生成。这里使用ObjectId, 但不是ObjectId类型
@prop()
name: string; // 用于显示的名称
@prop()
parentId?: string; // 父节点id
/**
* 面板类型:
* 0 文本频道
* 1 面板分组
* 2 插件
*
* Reference: https://discord.com/developers/docs/resources/channel#channel-object-channel-types
*/
@prop({
type: () => Number,
})
type: GroupPanelType;
@prop()
provider?: string; // 面板提供者,为插件的标识,仅面板类型为插件时有效
@prop()
pluginPanelName?: string; // 插件面板名, 如 com.msgbyte.webview/grouppanel
/**
* 面板的其他数据
*/
@prop()
meta?: object;
/**
* 身份组或者用户的权限
* 如果没有设定则应用群组权限
*
* key 为身份组id或者用户id
* value 为权限字符串列表
*/
@prop()
permissionMap?: Record<string, string[]>;
/**
* 所有人的权限列表
* 如果没有设定则应用群组权限
*/
@prop({
type: () => String,
default: () => [],
})
fallbackPermissions?: string[];
}
/**
* 群组权限组
*/
export class GroupRole implements Base {
_id: Types.ObjectId;
id: string;
@prop()
name: string; // 权限组名
@prop({
type: () => String,
})
permissions: string[]; // 拥有的权限, 是一段字符串
}
/**
* 群组
*/
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
export class Group extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop({
trim: true,
maxlength: [100, 'group name is too long'],
})
name!: string;
@prop()
avatar?: string;
@prop({
ref: () => User,
})
owner: Ref<User>;
@prop({
maxlength: 120,
})
description?: string;
@prop({ type: () => GroupMember, _id: false })
members: GroupMember[];
@prop({ type: () => GroupPanel, _id: false })
panels: GroupPanel[];
@prop({
type: () => GroupRole,
default: [],
})
roles: GroupRole[];
/**
* 所有人的权限列表
* 为群组中的最低权限
*/
@prop({
type: () => String,
default: () => [],
})
fallbackPermissions: string[];
/**
* 群组的配置信息
*/
@prop({ default: () => ({}) })
config: object;
/**
* 创建群组
*/
static async createGroup(
this: ReturnModelType<typeof Group>,
options: {
name: string;
avatarBase64?: string; // base64版本的头像字符串
panels?: GroupPanel[];
owner: string;
}
): Promise<GroupDocument> {
const { name, avatarBase64, panels = [], owner } = options;
if (typeof avatarBase64 === 'string') {
// TODO: 处理头像上传逻辑
}
// 预处理panels信息, 变换ID为objectid
const panelSectionMap: Record<string, string> = {};
panels.forEach((panel) => {
const originPanelId = panel.id;
panel.id = String(new Types.ObjectId());
if (panel.type === GroupPanelType.GROUP) {
panelSectionMap[originPanelId] = panel.id;
}
if (typeof panel.parentId === 'string') {
if (typeof panelSectionMap[panel.parentId] !== 'string') {
throw new Error('创建失败, 面板参数不合法');
}
panel.parentId = panelSectionMap[panel.parentId];
}
});
// NOTE: Expression produces a union type that is too complex to represent.
const res = await this.create({
name,
panels,
owner,
members: [
{
roles: [],
userId: owner,
},
],
});
return res;
}
/**
* 获取用户加入的群组列表
* @param userId 用户ID
*/
static async getUserGroups(
this: ReturnModelType<typeof Group>,
userId: string
): Promise<GroupDocument[]> {
return this.find({
'members.userId': userId,
});
}
/**
* 修改群组角色名
*/
static async updateGroupRoleName(
this: ReturnModelType<typeof Group>,
groupId: string,
roleId: string,
roleName: string
): Promise<Group> {
const group = await this.findById(groupId);
if (!group) {
throw new Error('Not Found Group');
}
const modifyRole = group.roles.find((role) => String(role._id) === roleId);
if (!modifyRole) {
throw new Error('Not Found Role');
}
modifyRole.name = roleName;
await group.save();
return group;
}
/**
* 修改群组角色权限
*/
static async updateGroupRolePermission(
this: ReturnModelType<typeof Group>,
groupId: string,
roleId: string,
permissions: string[]
): Promise<Group> {
const group = await this.findById(groupId);
if (!group) {
throw new Error('Not Found Group');
}
const modifyRole = group.roles.find((role) => String(role._id) === roleId);
if (!modifyRole) {
throw new Error('Not Found Role');
}
modifyRole.permissions = [...permissions];
await group.save();
return group;
}
/**
* 获取用户所有权限
*/
static async getGroupUserPermission(
this: ReturnModelType<typeof Group>,
groupId: string,
userId: string
): Promise<string[]> {
const group = await this.findById(groupId);
if (!group) {
throw new Error('Not Found Group');
}
if (userId === SYSTEM_USERID) {
return allPermission;
}
const member = group.members.find(
(member) => String(member.userId) === userId
);
if (!member) {
throw new Error('Not Found Member');
}
const allRoles = member.roles;
const allRolesPermission = allRoles.map((roleName) => {
const p = group.roles.find((r) => String(r._id) === roleName);
return p?.permissions ?? [];
});
if (String(group.owner) === userId) {
/**
* 群组管理者有所有权限
* 这里是为了避免插件权限无法预先感知到的问题
*/
return _.uniq([
...allPermission,
..._.flatten(allRolesPermission),
...group.fallbackPermissions,
]);
} else {
return _.uniq([
..._.flatten(allRolesPermission),
...group.fallbackPermissions,
]);
}
}
/**
* 检查群组字段操作权限,如果没有权限会直接抛出异常
*/
static async checkGroupFieldPermission<
K extends keyof Pick<GroupMember, 'roles' | 'muteUntil'>
>(
this: ReturnModelType<typeof Group>,
ctx: TcContext,
groupId: string,
fieldName: K
) {
const userId = ctx.meta.userId;
const t = ctx.meta.t;
if (fieldName === 'roles') {
// 检查操作用户是否有管理角色的权限
const [hasRolePermission] = await call(ctx).checkUserPermissions(
groupId,
userId,
[PERMISSION.core.manageRoles]
);
if (!hasRolePermission) {
throw new NoPermissionError(t('没有操作角色权限'));
}
} else {
// 检查操作用户是否有管理用户权限
const [hasUserPermission] = await call(ctx).checkUserPermissions(
groupId,
userId,
[PERMISSION.core.manageUser]
);
if (!hasUserPermission) {
throw new NoPermissionError(t('没有操作用户权限'));
}
}
}
/**
* 修改群组成员的字段信息
*
* 带权限验证
*/
static async updateGroupMemberField<
K extends keyof Pick<GroupMember, 'roles' | 'muteUntil'>
>(
this: ReturnModelType<typeof Group>,
ctx: TcContext,
groupId: string,
memberId: string,
fieldName: K,
fieldValue: GroupMember[K] | ((member: GroupMember) => void)
): Promise<Group> {
const group = await this.findById(groupId);
const t = ctx.meta.t;
await this.checkGroupFieldPermission(ctx, groupId, fieldName);
const member = group.members.find((m) => String(m.userId) === memberId);
if (!member) {
throw new Error(t('没有找到该成员'));
}
if (typeof fieldValue === 'function') {
fieldValue(member);
} else {
member[fieldName] = fieldValue;
}
await group.save();
return group;
}
}
export type GroupDocument = DocumentType<Group>;
const model = getModelForClass(Group);
export type GroupModel = typeof model;
export default model;

View File

@@ -0,0 +1,112 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
ReturnModelType,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import moment from 'moment';
import type { Types } from 'mongoose';
import { nanoid } from 'nanoid';
import { User } from '../user/user';
import { Group } from './group';
import promiseRetry from 'promise-retry';
function generateCode() {
return nanoid(8);
}
export class GroupInvite extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop({
index: true,
unique: true,
default: () => generateCode(),
})
code!: string;
@prop({
ref: () => User,
})
creator: Ref<User>;
@prop({
ref: () => Group,
})
groupId!: Ref<Group>;
/**
* 过期时间,如果不存在则永不过期
*/
@prop()
expiredAt?: Date;
/**
* 被使用次数
*/
@prop({
default: 0,
})
usage: number;
/**
* 使用上限,如果为空则不限制
*/
@prop()
usageLimit?: number;
/**
* 创建群组邀请
* @param groupId 群组id
* @param type 普通(7天) 永久
*/
static async createGroupInvite(
this: ReturnModelType<typeof GroupInvite>,
groupId: string,
creator: string,
inviteType: 'normal' | 'permanent'
): Promise<GroupInviteDocument> {
let expiredAt = moment().add(7, 'day').toDate(); // 默认7天
if (inviteType === 'permanent') {
expiredAt = undefined;
}
const code = await promiseRetry(
async () => {
const code = generateCode();
const exists = await this.exists({ code });
if (exists) {
throw new Error('Cannot find unused invite code, please try again.');
}
return code;
},
{
minTimeout: 0,
maxTimeout: 0,
retries: 5,
}
);
const invite = await this.create({
groupId,
code,
creator,
expiredAt,
});
return invite;
}
}
export type GroupInviteDocument = DocumentType<GroupInvite>;
const model = getModelForClass(GroupInvite);
export type GroupInviteModel = typeof model;
export default model;

View File

@@ -0,0 +1,20 @@
import { filterAvailableAppCapability } from '../app';
describe('openapp', () => {
describe('filterAvailableAppCapability', () => {
test.each([
[['bot'], ['bot']],
[['bot', 'foo'], ['bot']],
[
['bot', 'webpage', 'oauth'],
['bot', 'webpage', 'oauth'],
],
[
['bot', 'webpage', 'oauth', 'a', 'b', 'c'],
['bot', 'webpage', 'oauth'],
],
])('%p', (input, output) => {
expect(filterAvailableAppCapability(input)).toEqual(output);
});
});
});

View File

@@ -0,0 +1,105 @@
import {
getModelForClass,
prop,
DocumentType,
index,
ReturnModelType,
Ref,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { User } from '../user/user';
const openAppCapability = [
'bot', // 机器人
'webpage', // 网页
'oauth', // 第三方登录
] as const;
type OpenAppCapability = typeof openAppCapability[number];
/**
* 确保输出类型为应用能力
*/
export function filterAvailableAppCapability(
input: string[]
): OpenAppCapability[] {
return input.filter((item) =>
openAppCapability.includes(item as OpenAppCapability)
) as OpenAppCapability[];
}
export interface OpenAppOAuth {
redirectUrls: string[];
}
export interface OpenAppBot {
callbackUrl: string;
}
/**
* 开放平台应用
*/
@index({ appId: 1 }, { unique: true })
export class OpenApp extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop({
ref: () => User,
})
owner: Ref<User>;
@prop()
appId: string;
@prop()
appSecret: string;
@prop()
appName: string;
@prop()
appDesc: string;
@prop()
appIcon: string; // url
@prop({
enum: openAppCapability,
type: () => String,
})
capability: OpenAppCapability[];
@prop()
oauth?: OpenAppOAuth;
@prop()
bot?: OpenAppBot;
/**
* 根据appId获取openapp的实例
* 用于获得获得完整数据(包括secret)
* 并顺便判断是否拥有该开放平台用户的修改权限
*/
static async findAppByIdAndOwner(
this: ReturnModelType<typeof OpenApp>,
appId: string,
ownerId: string
) {
const res = await this.findOne({
appId,
owner: ownerId,
}).exec();
return res;
}
}
export type OpenAppDocument = DocumentType<OpenApp>;
const model = getModelForClass(OpenApp);
export type OpenAppModel = typeof model;
export default model;

View File

@@ -0,0 +1,56 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
ReturnModelType,
modelOptions,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { User } from '../user/user';
export class PluginManifest extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop()
label: string;
@prop({
unique: true,
})
name: string;
/**
* 插件入口地址
*/
@prop()
url: string;
@prop()
icon?: string;
@prop()
version: string;
@prop()
author: string;
@prop()
description: string;
@prop()
requireRestart: string;
@prop({ ref: () => User })
uploader?: Ref<User>;
}
export type PluginManifestDocument = DocumentType<PluginManifest>;
const model = getModelForClass(PluginManifest);
export type PluginManifestModel = typeof model;
export default model;

View File

@@ -0,0 +1,47 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
modelOptions,
} from '@typegoose/typegoose';
import { Base, FindOrCreate } from '@typegoose/typegoose/lib/defaultClasses';
import { Converse } from '../chat/converse';
import { User } from './user';
import findorcreate from 'mongoose-findorcreate';
import { plugin } from '@typegoose/typegoose';
import type { Types } from 'mongoose';
/**
* 用户私信列表管理
*/
@plugin(findorcreate)
@modelOptions({
schemaOptions: {
collection: 'userdmlist',
},
})
export class UserDMList extends FindOrCreate implements Base {
_id: Types.ObjectId;
id: string;
@prop({
ref: () => User,
index: true,
})
userId: Ref<User>;
@prop({
ref: () => Converse,
})
converseIds: Ref<Converse>[];
}
export type UserDMListDocument = DocumentType<UserDMList>;
const model = getModelForClass(UserDMList);
export type UserDMListModel = typeof model;
export default model;

View File

@@ -0,0 +1,67 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
plugin,
ReturnModelType,
} from '@typegoose/typegoose';
import { Base, FindOrCreate } from '@typegoose/typegoose/lib/defaultClasses';
import { User } from './user';
import findorcreate from 'mongoose-findorcreate';
import type { Types } from 'mongoose';
/**
* 好友请求
* 单向好友结构
*/
@plugin(findorcreate)
export class Friend extends FindOrCreate implements Base {
_id: Types.ObjectId;
id: string;
@prop({
ref: () => User,
index: true,
})
from: Ref<User>;
@prop({
ref: () => User,
})
to: Ref<User>;
/**
* 好友昵称, 覆盖用户自己的昵称
*/
@prop()
nickname?: string;
@prop()
createdAt: Date;
static async buildFriendRelation(
this: ReturnModelType<FriendModel>,
user1: string,
user2: string
) {
await Promise.all([
this.findOrCreate({
from: user1,
to: user2,
}),
this.findOrCreate({
from: user2,
to: user1,
}),
]);
}
}
export type FriendDocument = DocumentType<Friend>;
const model = getModelForClass(Friend);
export type FriendModel = typeof model;
export default model;

View File

@@ -0,0 +1,36 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
} from '@typegoose/typegoose';
import type { Base } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { User } from './user';
/**
* 好友请求
*/
export class FriendRequest implements Base {
_id: Types.ObjectId;
id: string;
@prop({
ref: () => User,
index: true,
})
from: Ref<User>;
@prop({
ref: () => User,
})
to: Ref<User>;
@prop()
message: string;
}
export type FriendRequestDocument = DocumentType<FriendRequest>;
export default getModelForClass(FriendRequest);

191
server/models/user/mail.ts Normal file
View File

@@ -0,0 +1,191 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
modelOptions,
Severity,
ReturnModelType,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { User } from './user';
import nodemailer, { Transporter, SendMailOptions } from 'nodemailer';
import { parseConnectionUrl } from 'nodemailer/lib/shared';
import { config } from 'tailchat-server-sdk';
import type SMTPConnection from 'nodemailer/lib/smtp-connection';
/**
* 将地址格式化
*/
function stringifyAddress(address: SendMailOptions['to']): string {
if (Array.isArray(address)) {
return address.map((a) => stringifyAddress(a)).join(',');
}
if (typeof address === 'string') {
return address;
} else if (address === undefined) {
return '';
} else if (typeof address === 'object') {
return `"${address.name}" ${address.address}`;
}
}
function getSMTPConnectionOptions(): SMTPConnection.Options | null {
if (config.smtp.connectionUrl) {
return parseConnectionUrl(config.smtp.connectionUrl);
}
return null;
}
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
export class Mail extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
/**
* 发件人邮箱
*/
@prop()
from: string;
/**
* 收件人邮箱
*/
@prop()
to: string;
/**
* 邮件主题
*/
@prop()
subject: string;
/**
* 邮件内容
*/
@prop()
body: string;
@prop()
host?: string;
@prop()
port?: string;
@prop()
secure?: boolean;
@prop()
is_success: boolean;
@prop()
data?: any;
@prop()
error?: string;
/**
* 创建邮件发送实例
*/
static createMailerTransporter(): Transporter | null {
const options = getSMTPConnectionOptions();
if (options) {
const transporter = nodemailer.createTransport(options);
return transporter;
}
return null;
}
/**
* 检查邮件服务是否可用
*/
static async verifyMailService(): Promise<boolean> {
try {
const transporter = Mail.createMailerTransporter();
if (!transporter) {
return false;
}
const verify = await transporter.verify();
return verify;
} catch (e) {
console.error(e);
return false;
}
}
/**
* 发送邮件
*/
static async sendMail(
this: ReturnModelType<typeof Mail>,
mailOptions: SendMailOptions
) {
try {
const transporter = Mail.createMailerTransporter();
if (!transporter) {
throw new Error('Mail Transporter is null');
}
const options = {
from: config.smtp.senderName,
...mailOptions,
};
const smtpOptions = getSMTPConnectionOptions();
try {
const info = await transporter.sendMail(options);
await this.create({
from: stringifyAddress(options.from),
to: stringifyAddress(options.to),
subject: options.subject,
body: options.html,
host: smtpOptions.host,
port: smtpOptions.port,
secure: smtpOptions.secure,
is_success: true,
data: info,
});
return info;
} catch (err) {
this.create({
from: stringifyAddress(options.from),
to: stringifyAddress(options.to),
subject: options.subject,
body: options.html,
host: smtpOptions.host,
port: smtpOptions.port,
secure: smtpOptions.secure,
is_success: false,
error: String(err),
});
throw err;
}
} catch (err) {
console.error(err);
throw err;
}
}
}
export type MailDocument = DocumentType<Mail>;
const model = getModelForClass(Mail);
export type MailModel = typeof model;
export default model;

191
server/models/user/user.ts Normal file
View File

@@ -0,0 +1,191 @@
import {
getModelForClass,
prop,
DocumentType,
ReturnModelType,
modelOptions,
Severity,
index,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { UserType, userType } from 'tailchat-server-sdk';
type BaseUserInfo = Pick<User, 'nickname' | 'discriminator' | 'avatar'>;
/**
* 用户设置
*/
export interface UserSettings {
/**
* 消息列表虚拟化
*/
messageListVirtualization?: boolean;
[key: string]: any;
}
export interface UserLoginRes extends User {
token: string;
}
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
@index({ avatar: 1 })
export class User extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
/**
* 用户名 不可被修改
* 与email必有一个
*/
@prop()
username?: string;
/**
* 邮箱 不可被修改
* 必填
*/
@prop({
index: true,
unique: true,
})
email: string;
@prop()
password!: string;
/**
* 可以被修改的显示名
*/
@prop({
trim: true,
maxlength: 20,
})
nickname!: string;
/**
* 识别器, 跟username构成全局唯一的用户名
* 用于搜索
* <username>#<discriminator>
*/
@prop()
discriminator: string;
/**
* 是否为临时用户
* @default false
*/
@prop({
default: false,
})
temporary: boolean;
/**
* 头像
*/
@prop()
avatar?: string;
/**
* 用户类型
*/
@prop({
enum: userType,
type: () => String,
default: 'normalUser',
})
type: UserType;
/**
* 邮箱是否可用
*/
@prop({
default: false,
})
emailVerified: boolean;
/**
* 是否被封禁
*/
@prop({
default: false,
})
banned: boolean;
/**
* 用户的额外信息
*/
@prop()
extra?: object;
/**
* 用户设置
*/
@prop({
default: {},
})
settings: UserSettings;
/**
* 生成身份识别器
* 0001 - 9999
*/
public static generateDiscriminator(
this: ReturnModelType<typeof User>,
nickname: string
): Promise<string> {
let restTimes = 10; // 最多找10次
const checkDiscriminator = async () => {
const discriminator = String(
Math.floor(Math.random() * 9999) + 1
).padStart(4, '0');
const doc = await this.findOne({
nickname,
discriminator,
}).exec();
restTimes--;
if (doc !== null) {
// 已存在, 换一个
if (restTimes <= 0) {
throw new Error('Cannot find space discriminator');
}
return checkDiscriminator();
}
return discriminator;
};
return checkDiscriminator();
}
/**
* 获取用户基本信息
*/
static async getUserBaseInfo(
this: ReturnModelType<typeof User>,
userId: string
): Promise<BaseUserInfo> {
const user = await this.findById(String(userId));
return {
nickname: user.nickname,
discriminator: user.discriminator,
avatar: user.avatar,
};
}
}
export type UserDocument = DocumentType<User>;
const model = getModelForClass(User);
export type UserModel = typeof model;
export default model;