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,5 @@
## 简述
每个`.service.ts`文件都是一个微服务
应当确保尽量不要出现一个单独的微服务承载太重的业务,而应当考虑多拆分到不同的微任务中。

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

View File

@@ -0,0 +1,36 @@
import { TcService, TcPureContext } from 'tailchat-server-sdk';
import { sleep } from '../lib/utils';
export default class TestService extends TcService {
get serviceName(): string {
return 'debug';
}
onInit(): void {
this.registerAction('hello', this.echo, {
params: {
name: [{ type: 'string', optional: true }],
},
});
this.registerAction('sleep', this.sleep, {
params: {
second: 'number',
},
});
}
// Action
echo(ctx: TcPureContext<{ name: string }>): string {
console.log(ctx.meta);
return `Hello ${
ctx.params.name ?? ctx.meta.t('匿名用户')
}, \nHere is your meta info: ${JSON.stringify(ctx.meta, null, 2)}`;
}
// Action
async sleep(ctx: TcPureContext<{ second: number }>) {
await sleep(ctx.params.second * 1000);
return true;
}
}

View File

@@ -0,0 +1,383 @@
import {
TcService,
config,
TcDbService,
TcContext,
EntityError,
NoPermissionError,
} from 'tailchat-server-sdk';
import _ from 'lodash';
import {
filterAvailableAppCapability,
OpenApp,
OpenAppBot,
OpenAppDocument,
OpenAppModel,
OpenAppOAuth,
} from '../../models/openapi/app';
import { Types } from 'mongoose';
import { nanoid } from 'nanoid';
import crypto from 'crypto';
interface OpenAppService
extends TcService,
TcDbService<OpenAppDocument, OpenAppModel> {}
class OpenAppService extends TcService {
get serviceName(): string {
return 'openapi.app';
}
onInit(): void {
if (!config.enableOpenapi) {
return;
}
this.registerLocalDb(require('../../models/openapi/app').default);
this.registerAction('authToken', this.authToken, {
params: {
appId: 'string',
token: 'string',
capability: { type: 'array', items: 'string', optional: true },
},
cache: {
keys: ['appId', 'token'],
ttl: 60 * 60, // 1 hour
},
});
this.registerAction('all', this.all);
this.registerAction('get', this.get, {
params: {
appId: 'string',
},
cache: {
keys: ['appId'],
ttl: 60 * 60, // 1 hour
},
});
this.registerAction('create', this.create, {
params: {
appName: 'string',
appDesc: 'string',
appIcon: 'string',
},
});
this.registerAction('delete', this.delete, {
params: {
appId: 'string',
},
});
this.registerAction('setAppInfo', this.setAppInfo, {
params: {
appId: 'string',
fieldName: 'string',
fieldValue: 'string',
},
});
this.registerAction('setAppCapability', this.setAppCapability, {
params: {
appId: 'string',
capability: { type: 'array', items: 'string' },
},
});
this.registerAction('setAppOAuthInfo', this.setAppOAuthInfo, {
params: {
appId: 'string',
fieldName: 'string',
fieldValue: 'any',
},
});
this.registerAction('setAppBotInfo', this.setAppBotInfo, {
params: {
appId: 'string',
fieldName: 'string',
fieldValue: 'any',
},
});
}
/**
* 校验Token 返回true/false
*
* Token 生成方式: appId + appSecret 取md5
*/
async authToken(
ctx: TcContext<{
appId: string;
token: string;
capability?: OpenAppDocument['capability'];
}>
): Promise<boolean> {
const { appId, token, capability } = ctx.params;
const app = await this.adapter.model.findOne({
appId,
});
if (!app) {
// 没有找到应用
throw new Error('Not found open app:' + appId);
}
if (Array.isArray(capability)) {
for (const item of capability) {
if (!app.capability.includes(item)) {
throw new Error('Open app not enabled capability:' + item);
}
}
}
const appSecret = app.appSecret;
if (
token ===
crypto
.createHash('md5')
.update(appId + appSecret)
.digest('hex')
) {
return true;
}
return false;
}
/**
* 获取用户参与的所有应用
*/
async all(ctx: TcContext<{}>) {
const apps = await this.adapter.model.find({
owner: ctx.meta.userId,
});
return await this.transformDocuments(ctx, {}, apps);
}
/**
* 获取应用信息
*/
async get(ctx: TcContext<{ appId: string }>) {
const appId = ctx.params.appId;
const app = await this.adapter.model.findOne(
{
appId,
},
{
appSecret: false,
}
);
return await this.transformDocuments(ctx, {}, app);
}
/**
* 创建一个第三方应用
*/
async create(
ctx: TcContext<{
appName: string;
appDesc: string;
appIcon: string;
}>
) {
const { appName, appDesc, appIcon } = ctx.params;
const userId = ctx.meta.userId;
if (!appName) {
throw new EntityError();
}
const doc = await this.adapter.model.create({
owner: String(userId),
appId: `tc_${new Types.ObjectId().toString()}`,
appSecret: nanoid(32),
appName,
appDesc,
appIcon,
});
return await this.transformDocuments(ctx, {}, doc);
}
/**
* 删除开放平台应用
*/
async delete(
ctx: TcContext<{
appId: string;
}>
) {
const { appId } = ctx.params;
const userId = ctx.meta.userId;
const t = ctx.meta.t;
const appInfo: OpenApp = await this.localCall('get', {
appId,
});
if (String(appInfo.owner) !== userId) {
throw new NoPermissionError(t('没有操作权限'));
}
// 可能会出现ws机器人不会立即中断连接的问题不重要暂时不处理
await this.adapter.model.remove({
appId,
owner: userId,
});
return true;
}
/**
* 修改应用信息
*/
async setAppInfo(
ctx: TcContext<{
appId: string;
fieldName: string;
fieldValue: string;
}>
) {
const { appId, fieldName, fieldValue } = ctx.params;
const userId = ctx.meta.userId;
const t = ctx.meta.t;
if (!['appName', 'appDesc', 'appIcon'].includes(fieldName)) {
// 只允许修改以上字段
throw new EntityError(`${t('该数据不允许修改')}: ${fieldName}`);
}
const doc = await this.adapter.model
.findOneAndUpdate(
{
appId,
owner: userId,
},
{
[fieldName]: fieldValue,
},
{
new: true,
}
)
.exec();
this.cleanAppInfoCache(appId);
return await this.transformDocuments(ctx, {}, doc);
}
/**
* 设置应用开放的能力
*/
async setAppCapability(
ctx: TcContext<{
appId: string;
capability: string[];
}>
) {
const { appId, capability } = ctx.params;
const { userId } = ctx.meta;
const openapp = await this.adapter.model.findAppByIdAndOwner(appId, userId);
if (!openapp) {
throw new Error('Not found openapp');
}
await openapp
.updateOne({
capability: filterAvailableAppCapability(_.uniq(capability)),
})
.exec();
await this.cleanAppInfoCache(appId);
return true;
}
/**
* 设置OAuth的设置信息
*/
async setAppOAuthInfo<T extends keyof OpenAppOAuth>(
ctx: TcContext<{
appId: string;
fieldName: T;
fieldValue: OpenAppOAuth[T];
}>
) {
const { appId, fieldName, fieldValue } = ctx.params;
const { userId } = ctx.meta;
if (!['redirectUrls'].includes(fieldName)) {
throw new Error('Not allowed fields');
}
if (fieldName === 'redirectUrls') {
if (!Array.isArray(fieldValue)) {
throw new Error('`redirectUrls` should be an array');
}
}
await this.adapter.model.findOneAndUpdate(
{
appId,
owner: userId,
},
{
$set: {
[`oauth.${fieldName}`]: fieldValue,
},
}
);
await this.cleanAppInfoCache(appId);
}
/**
* 设置Bot的设置信息
*/
async setAppBotInfo<T extends keyof OpenAppBot>(
ctx: TcContext<{
appId: string;
fieldName: T;
fieldValue: OpenAppBot[T];
}>
) {
const { appId, fieldName, fieldValue } = ctx.params;
const { userId } = ctx.meta;
if (!['callbackUrl'].includes(fieldName)) {
throw new Error('Not allowed fields');
}
if (fieldName === 'callbackUrl') {
if (typeof fieldValue !== 'string') {
throw new Error('`callbackUrl` should be a string');
}
}
await this.adapter.model.findOneAndUpdate(
{
appId,
owner: userId,
},
{
$set: {
[`bot.${fieldName}`]: fieldValue,
},
}
);
await this.cleanAppInfoCache(appId);
}
/**
* 清理获取开放平台应用的缓存
*/
private async cleanAppInfoCache(appId: string) {
await this.cleanActionCache('get', [String(appId)]);
}
}
export default OpenAppService;

View File

@@ -0,0 +1,163 @@
import { TcService, config, TcContext, call } from 'tailchat-server-sdk';
import { isValidStr, isValidUrl } from '../../lib/utils';
import type { OpenApp } from '../../models/openapi/app';
import got from 'got';
import _ from 'lodash';
class OpenBotService extends TcService {
get serviceName(): string {
return 'openapi.bot';
}
onInit(): void {
if (!config.enableOpenapi) {
return;
}
this.registerEventListener('chat.inbox.append', async (payload, ctx) => {
const userInfo = await call(ctx).getUserInfo(String(payload.userId));
if (!userInfo) {
return;
}
if (userInfo.type !== 'openapiBot') {
return;
}
// 开放平台机器人
const botId: string | null = await ctx.call('user.findOpenapiBotId', {
email: userInfo.email,
});
if (!(isValidStr(botId) && botId.startsWith('open_'))) {
return;
}
// 是合法的机器人id
const appId = botId.replace('open_', '');
const appInfo: OpenApp | null = await ctx.call('openapi.app.get', {
appId,
});
const callbackUrl = _.get(appInfo, 'bot.callbackUrl');
if (!isValidUrl(callbackUrl)) {
this.logger.info('机器人回调地址不是一个可用的url, skip.');
return;
}
got
.post(callbackUrl, {
json: payload,
headers: {
'X-TC-Payload-Type': 'inbox',
},
})
.then(() => {
this.logger.info('调用机器人通知接口回调成功');
})
.catch((err) => {
this.logger.error('调用机器人通知接口回调失败:', err);
});
});
this.registerAction('login', this.login, {
params: {
appId: 'string',
token: 'string',
},
});
this.registerAction('getOrCreateBotAccount', this.getOrCreateBotAccount, {
params: {
appId: 'string',
},
visibility: 'public',
});
this.registerAuthWhitelist(['/login']);
}
/**
* 登录
*
* 并自动创建机器人账号
*/
async login(ctx: TcContext<{ appId: string; token: string }>) {
const { appId, token } = ctx.params;
const valid = await ctx.call('openapi.app.authToken', {
appId,
token,
capability: ['bot'],
});
if (!valid) {
throw new Error('Auth failed.');
}
// 校验通过, 获取机器人账号存在
const { userId, email, nickname, avatar } = await this.localCall(
'getOrCreateBotAccount',
{
appId,
}
);
const jwt: string = await ctx.call('user.generateUserToken', {
userId,
email,
nickname,
avatar,
});
return { jwt, userId, email, nickname, avatar };
}
/**
* 获取或创建机器人账号
*/
async getOrCreateBotAccount(ctx: TcContext<{ appId: string }>): Promise<{
userId: string;
email: string;
nickname: string;
avatar: string;
}> {
const appId = ctx.params.appId;
await this.waitForServices(['user']);
const appInfo: OpenApp = await ctx.call('openapi.app.get', {
appId,
});
try {
const botId = 'open_' + appInfo.appId;
const nickname = appInfo.appName;
const avatar = appInfo.appIcon;
const { _id: botUserId, email } = await ctx.call<
{
_id: string;
email: string;
},
any
>('user.ensureOpenapiBot', {
botId,
nickname,
avatar,
});
this.logger.info('[getOrCreateBotAccount] Bot Id:', botUserId);
return {
userId: String(botUserId),
email,
nickname,
avatar,
};
} catch (e) {
this.logger.error(e);
throw e;
}
}
}
export default OpenBotService;

View File

@@ -0,0 +1,83 @@
import { call, DataNotFoundError, TcContext } from 'tailchat-server-sdk';
import { TcService, config } from 'tailchat-server-sdk';
import { isValidStr } from '../../lib/utils';
import type { OpenApp } from '../../models/openapi/app';
/**
* 第三方应用集成
*/
class OpenAppIntegrationService extends TcService {
get serviceName(): string {
return 'openapi.integration';
}
onInit(): void {
if (!config.enableOpenapi) {
return;
}
this.registerAction('addBotUser', this.addBotUser, {
params: {
appId: 'string',
groupId: 'string',
},
});
}
/**
* 在群组中添加机器人用户
*/
async addBotUser(
ctx: TcContext<{
appId: string;
groupId: string;
}>
) {
const appId = ctx.params.appId;
const groupId = ctx.params.groupId;
const t = ctx.meta.t;
const openapp: OpenApp = await ctx.call('openapi.app.get', {
appId,
});
if (!openapp) {
throw new DataNotFoundError();
}
if (!openapp.capability.includes('bot')) {
throw new Error(t('该应用的机器人服务尚未开通'));
}
const botAccount: any = await ctx.call(
'openapi.bot.getOrCreateBotAccount',
{
appId,
}
);
const userId = botAccount.userId;
if (!isValidStr(userId)) {
throw new Error(t('无法获取到机器人ID'));
}
await ctx.call(
'group.joinGroup',
{
groupId,
},
{
meta: {
userId,
},
}
);
await call(ctx).addGroupSystemMessage(
String(groupId),
`${ctx.meta.user.nickname} 在群组中添加了机器人 ${botAccount.nickname}`
);
}
}
export default OpenAppIntegrationService;

View File

@@ -0,0 +1,10 @@
import { User } from './model';
export async function claimUserInfo(userId: string) {
const baseUserInfo = await User.getUserBaseInfo(userId);
return {
...baseUserInfo,
sub: userId,
};
}

View File

@@ -0,0 +1,183 @@
import type { Adapter, AdapterPayload } from 'oidc-provider';
import { config } from 'tailchat-server-sdk';
import RedisClient from 'ioredis';
import _ from 'lodash';
import { OpenApp } from './model';
const client = new RedisClient(config.redisUrl, {
keyPrefix: 'tailchat:oidc:',
});
const grantable = new Set([
'AccessToken',
'AuthorizationCode',
'RefreshToken',
'DeviceCode',
'BackchannelAuthenticationRequest',
]);
const consumable = new Set([
'AuthorizationCode',
'RefreshToken',
'DeviceCode',
'BackchannelAuthenticationRequest',
]);
function grantKeyFor(id) {
return `grant:${id}`;
}
function userCodeKeyFor(userCode) {
return `userCode:${userCode}`;
}
function uidKeyFor(uid) {
return `uid:${uid}`;
}
function parseImageUrl(input: string | undefined) {
if (typeof input === 'string') {
return input.replace('{BACKEND}', config.apiUrl); // 因为/open接口是在服务端的所以该标识直接移除即可
}
return input;
}
/**
* Reference: https://github.com/panva/node-oidc-provider/blob/main/example/my_adapter.js
*/
export class TcOIDCAdapter implements Adapter {
constructor(public name: string) {}
async upsert(
id: string,
payload: AdapterPayload,
expiresIn: number
): Promise<undefined | void> {
const key = this.key(id);
const multi = client.multi();
if (consumable.has(this.name)) {
multi['hmset'](key, { payload: JSON.stringify(payload) });
} else {
multi['set'](key, JSON.stringify(payload));
}
if (expiresIn) {
multi.expire(key, expiresIn);
}
if (grantable.has(this.name) && payload.grantId) {
const grantKey = grantKeyFor(payload.grantId);
multi.rpush(grantKey, key);
// if you're seeing grant key lists growing out of acceptable proportions consider using LTRIM
// here to trim the list to an appropriate length
const ttl = await client.ttl(grantKey);
if (expiresIn > ttl) {
multi.expire(grantKey, expiresIn);
}
}
if (payload.userCode) {
const userCodeKey = userCodeKeyFor(payload.userCode);
multi.set(userCodeKey, id);
multi.expire(userCodeKey, expiresIn);
}
if (payload.uid) {
const uidKey = uidKeyFor(payload.uid);
multi.set(uidKey, id);
multi.expire(uidKey, expiresIn);
}
await multi.exec();
}
async find(id: string): Promise<AdapterPayload | undefined | void> {
if (this.name === 'Client') {
return this.findClient(id);
}
const data = consumable.has(this.name)
? await client.hgetall(this.key(id))
: await client.get(this.key(id));
if (_.isEmpty(data)) {
return undefined;
}
if (typeof data === 'string') {
return JSON.parse(data);
}
const { payload, ...rest } = data;
return {
...rest,
...JSON.parse(payload),
};
}
async findByUid(uid: string): Promise<AdapterPayload | undefined | void> {
const id = await client.get(uidKeyFor(uid));
return this.find(id);
}
async findByUserCode(
userCode: string
): Promise<AdapterPayload | undefined | void> {
const id = await client.get(userCodeKeyFor(userCode));
return this.find(id);
}
async destroy(id: string): Promise<undefined | void> {
const key = this.key(id);
await client.del(key);
}
async revokeByGrantId(grantId: string): Promise<undefined | void> {
// eslint-disable-line class-methods-use-this
const multi = client.multi();
const tokens = await client.lrange(grantKeyFor(grantId), 0, -1);
tokens.forEach((token) => multi.del(token));
multi.del(grantKeyFor(grantId));
await multi.exec();
}
async consume(id: string) {
await client.hset(this.key(id), 'consumed', Math.floor(Date.now() / 1000));
}
/**
* 查询客户端
*/
private async findClient(clientId: string): Promise<AdapterPayload | void> {
const app = await OpenApp.findOne({
appId: clientId,
}).exec();
if (!app) {
return;
}
if (!app.capability.includes('oauth')) {
return;
}
const clientPayload: AdapterPayload = {
client_id: app.appId,
client_secret: app.appSecret,
client_name: app.appName,
application_type: 'web',
grant_types: ['refresh_token', 'authorization_code'],
redirect_uris: [...(app.oauth?.redirectUrls ?? [])],
};
if (app.appIcon) {
clientPayload.logo_uri = parseImageUrl(app.appIcon);
}
return clientPayload;
}
key(id: string) {
return `${this.name}:${id}`;
}
}

View File

@@ -0,0 +1,8 @@
import { mongoose } from '@typegoose/typegoose';
import { config } from 'tailchat-server-sdk';
import OpenApp from '../../../models/openapi/app';
import User from '../../../models/user/user';
mongoose.connect(config.mongoUrl);
export { OpenApp, User };

View File

@@ -0,0 +1,332 @@
import { Provider, Configuration, InteractionResults } from 'oidc-provider';
import { config, TcService, ApiGatewayMixin } from 'tailchat-server-sdk';
import type { IncomingMessage, ServerResponse } from 'http';
import ejs from 'ejs';
import path from 'path';
import assert from 'assert';
import qs from 'qs';
import _ from 'lodash';
import serve from 'serve-static';
import { TcOIDCAdapter } from './adapter';
import { claimUserInfo } from './account';
import type { UserLoginRes } from '../../../models/user/user';
const PORT = process.env.OPENAPI_PORT || config.port + 1;
const ISSUER = config.apiUrl;
const IS_PROXY = process.env.OPENAPI_UNDER_PROXY === 'true';
const configuration: Configuration = {
adapter: TcOIDCAdapter,
// ... see /docs for available configuration
clients: [],
pkce: {
methods: ['S256'],
required: () => false, // TODO: false in test
},
claims: {
profile: ['nickname', 'discriminator', 'avatar'],
},
async findAccount(ctx, id) {
return {
accountId: id,
async claims(use, scope, claims, rejected) {
const userInfo = await claimUserInfo(id);
console.log('[oidc] findAccount', {
use,
scope,
claims,
rejected,
userInfo,
});
return userInfo;
},
};
},
cookies: {
keys: ['__tailchat_oidc'],
},
features: {
devInteractions: {
enabled: false,
},
},
interactions: {
url: (ctx, interaction) => `/open/interaction/${interaction.uid}`,
},
// TODO
// ttl.Session
// renderError
};
function readIncomingMessageData(req: IncomingMessage) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', function (chunk) {
body += chunk;
});
req.on('end', function () {
resolve(body);
});
req.on('error', () => {
reject();
});
});
}
class OIDCService extends TcService {
provider = this.createOIDCProvider();
get serviceName(): string {
return 'openapi.oidc';
}
private createOIDCProvider() {
const oidc = new Provider(ISSUER, configuration);
if (IS_PROXY) {
oidc.proxy = true;
this.logger.info('is running under proxy.');
}
return oidc;
}
protected onInit(): void {
this.registerMixin(ApiGatewayMixin);
this.registerSetting('port', PORT);
this.registerSetting('routes', this.getRoutes());
}
protected async onStart(): Promise<void> {
this.initListeners();
}
initListeners() {
function handleClientAuthErrors(
{ headers: { authorization }, oidc: { body, client } },
err
) {
console.error('handleClientAuthErrors', err);
if (err.statusCode === 401 && err.message === 'invalid_client') {
// console.log(err);
// save error details out-of-bands for the client developers, `authorization`, `body`, `client`
// are just some details available, you can dig in ctx object for more.
}
}
this.provider.on('authorization.error', handleClientAuthErrors);
this.provider.on('jwks.error', handleClientAuthErrors);
this.provider.on('discovery.error', handleClientAuthErrors);
this.provider.on('end_session.error', handleClientAuthErrors);
this.provider.on('grant.error', handleClientAuthErrors);
this.provider.on('introspection.error', handleClientAuthErrors);
this.provider.on('revocation.error', handleClientAuthErrors);
this.provider.on('userinfo.error', handleClientAuthErrors);
}
getRoutes() {
const providerRoute = (req, res) => {
try {
this.provider.callback()(req, res);
} catch (err) {
console.error('[oidc]', err);
}
};
return [
{
// Reference: https://github.com/moleculerjs/moleculer-web/blob/master/examples/file/index.js
path: '/open',
// You should disable body parsers
bodyParsers: {
json: false,
urlencoded: false,
},
whitelist: [],
authentication: false,
authorization: false,
aliases: {
/**
* 授权交互界面
*/
'GET /interaction/:uid': async (
req: IncomingMessage,
res: ServerResponse
) => {
try {
const details = await this.provider.interactionDetails(req, res);
const { uid, prompt, params, session } = details;
const client = await this.provider.Client.find(
String(params.client_id)
);
const promptName = prompt.name;
const data = {
logoUri: client.logoUri,
clientName: client.clientName,
uid,
details: prompt.details,
params,
session,
dbg: {
params: params,
prompt: prompt,
},
};
if (promptName === 'login') {
this.renderHTML(
res,
await ejs.renderFile(
path.resolve(__dirname, './views/login.ejs'),
data
)
);
} else if (promptName === 'consent') {
this.renderHTML(
res,
await ejs.renderFile(
path.resolve(__dirname, './views/authorize.ejs'),
data
)
);
} else {
this.renderError(res, 'Unknown operation');
}
} catch (err) {
this.renderError(res, err);
}
},
'POST /interaction/:uid/login': async (
req: IncomingMessage,
res: ServerResponse
) => {
try {
const {
prompt: { name },
} = await this.provider.interactionDetails(req, res);
assert.equal(name, 'login');
const data = await readIncomingMessageData(req);
const { email, password } = qs.parse(String(data));
// Find user
const user: UserLoginRes = await this.broker.call('user.login', {
email,
password,
});
const result = {
login: {
accountId: String(user._id),
...user,
},
};
await this.provider.interactionFinished(req, res, result, {
mergeWithLastSubmission: false,
});
} catch (err) {
console.error(err);
this.renderError(res, err);
}
},
'POST /interaction/:uid/confirm': async (
req: IncomingMessage,
res: ServerResponse
) => {
try {
const interactionDetails = await this.provider.interactionDetails(
req,
res
);
const {
prompt: { name, details },
params,
session: { accountId },
} = interactionDetails;
assert.equal(name, 'consent');
let { grantId } = interactionDetails;
const grant = grantId
? // we'll be modifying existing grant in existing session
await this.provider.Grant.find(grantId)
: // we're establishing a new grant
new this.provider.Grant({
accountId,
clientId: String(params.client_id),
});
if (Array.isArray(details.missingOIDCScope)) {
grant.addOIDCScope(details.missingOIDCScope.join(' '));
}
if (Array.isArray(details.missingOIDCClaims)) {
grant.addOIDCClaims(details.missingOIDCClaims);
}
if (details.missingResourceScopes) {
for (const [indicator, scopes] of Object.entries(
details.missingResourceScopes
)) {
grant.addResourceScope(indicator, scopes.join(' '));
}
}
grantId = await grant.save();
const consent: InteractionResults['consent'] = {};
if (!interactionDetails.grantId) {
// we don't have to pass grantId to consent, we're just modifying existing one
consent.grantId = grantId;
}
const result: InteractionResults = { consent };
await this.provider.interactionFinished(req, res, result, {
mergeWithLastSubmission: true,
});
} catch (err) {
this.renderError(res, err);
}
},
'GET /auth': providerRoute,
'GET /auth/:uid': providerRoute,
'POST /token': providerRoute,
'POST /me': providerRoute,
'GET /me': providerRoute,
'GET /jwks': providerRoute,
'GET /.well-known/openid-configuration': providerRoute,
},
},
{
// For css file in development
path: '/',
authentication: false,
authorization: false,
use: [serve('public')],
whitelist: [],
autoAliases: false,
},
];
}
async renderError(res: ServerResponse, error: any) {
res.writeHead(500);
res.end(
await ejs.renderFile(path.resolve(__dirname, './views/error.ejs'), {
text: String(error),
})
);
}
renderHTML(res: ServerResponse, html: string) {
res.writeHead(200, {
'Content-Type': 'text/html',
});
res.end(html);
}
}
export default OIDCService;

View File

@@ -0,0 +1,4 @@
</body>
</html>

View File

@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tailchat 开放平台</title>
<link rel="stylesheet" href="/css/bulma.min.css">
<style>
body {
font-family: 'Roboto', sans-serif;
margin-top: 25px;
margin-bottom: 25px;
}
.login-card {
padding: 40px;
padding-top: 0px;
padding-bottom: 10px;
width: 274px;
background-color: #F7F7F7;
margin: 0 auto 10px;
border-radius: 2px;
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.login-card+.login-card {
padding-top: 10px;
}
.login-card h1 {
font-weight: 100;
text-align: center;
font-size: 2.3em;
}
.login-card [type=submit] {
width: 100%;
display: block;
margin-bottom: 10px;
position: relative;
}
.login-card input[type=text],
input[type=email],
input[type=password] {
height: 44px;
font-size: 16px;
width: 100%;
margin-bottom: 10px;
-webkit-appearance: none;
background: #fff;
border: 1px solid #d9d9d9;
border-top: 1px solid #c0c0c0;
padding: 0 8px;
box-sizing: border-box;
-moz-box-sizing: border-box;
}
.login {
text-align: center;
font-size: 14px;
font-family: 'Arial', sans-serif;
font-weight: 700;
height: 36px;
padding: 0 8px;
}
.login-submit {
border: 0px;
color: #fff;
text-shadow: 0 1px rgba(0, 0, 0, 0.1);
background-color: #4d90fe;
}
.login-card a {
text-decoration: none;
color: #666;
font-weight: 400;
text-align: center;
display: inline-block;
opacity: 0.6;
}
.login-help {
color: #666;
width: 100%;
text-align: center;
font-size: 12px;
}
.login-client-image img {
margin-bottom: 20px;
display: block;
margin-left: auto;
margin-right: auto;
width: 20%;
}
.login-card input[type=checkbox] {
margin-bottom: 10px;
}
.login-card label {
color: #999;
}
.grant-debug {
text-align: center;
font-family: Fixed, monospace;
width: 100%;
font-size: 12px;
color: #999;
}
.grant-debug div {
padding-top: 10px;
}
.login-help+form {
margin-top: 10px;
}
ul {
font-weight: 100;
padding-left: 1em;
list-style-type: circle;
}
li+ul,
ul+li,
li+li {
padding-top: 0.3em;
}
button {
cursor: pointer;
}
</style>
</head>
<body>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
<%- include('_header'); -%>
<style>
.error-card {
width: 420px;
background-color: #F7F7F7;
margin: 0 auto 10px;
border-radius: 2px;
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
overflow: hidden;
padding: 10px;
text-align: center;
}
.error-icon>svg {
width: 96px;
height: 96px;
margin-bottom: 10px;
}
</style>
<div class="card error-card">
<div class="error-icon">
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M215.751 133.938L215.577 134.077L215.438 134.251C213.848 136.238 211.25 139.004 208.448 141.253C207.049 142.376 205.642 143.335 204.32 144.006C202.977 144.687 201.859 145 201 145C198.757 145 197.353 144.472 196.283 143.731C195.14 142.939 194.239 141.818 193.1 140.3C192.146 139.027 190.451 137.627 188.511 136.302C186.522 134.943 184.106 133.55 181.554 132.323C179.005 131.097 176.281 130.016 173.675 129.305C171.102 128.602 168.492 128.217 166.236 128.518C159.558 129.408 155.954 129.376 154.01 129.072C153.054 128.923 152.598 128.721 152.399 128.605C152.248 128.517 152.211 128.461 152.166 128.394L152.164 128.391L151.891 127.98L151.458 127.744C151.124 127.562 150.665 127.038 150.327 125.57C150 124.149 149.887 122.284 149.85 120.066C149.836 119.258 149.834 118.402 149.831 117.516C149.821 113.873 149.809 109.726 149.011 106.308C149.025 105.642 148.93 104.756 148.436 103.99C148.105 103.477 147.589 103.015 146.873 102.793C146.187 102.581 145.513 102.651 144.942 102.821C143.864 103.142 142.733 103.945 141.526 105.07C140.273 106.237 138.76 107.923 136.925 110.267C129.57 119.665 120.689 137.152 117.188 144.654L117.13 144.777L117.09 144.906C116.75 145.998 116.302 147.288 115.8 148.729C114.69 151.921 113.321 155.856 112.307 160.028C110.811 166.182 109.955 173.268 111.548 180.434C114.21 192.417 123.384 203.588 130.885 210.302C130.159 215.245 127.92 219.751 124.973 224.649C124.11 226.084 123.18 227.561 122.216 229.09C119.733 233.034 117.031 237.324 114.701 242.127C110.63 250.517 106.687 258.435 102.91 266.019C92.1617 287.601 82.761 306.477 75.6238 325.807C63.5295 358.563 60.4712 394.383 62.5146 411.241C64.5719 428.214 75.3498 454.411 103.106 468.289C124.11 478.791 141.613 481.474 148.749 481.508C155.396 487.249 172.918 497.475 195.915 497.5C210.317 498.709 229.404 493.918 237.12 491.401L237.234 491.364L237.343 491.314L308.297 458.335C313.924 456.056 323.262 453.144 332.931 451.832C342.716 450.504 352.394 450.879 358.992 454.728C367.901 459.924 371.489 463.252 373.558 466.16C374.606 467.632 375.307 469.054 376.131 470.749L376.183 470.856C376.989 472.516 377.903 474.395 379.321 476.587C382.279 481.158 386.674 484.803 391.859 486.01C397.137 487.239 402.915 485.867 408.338 480.987C412.959 476.827 413.878 471.509 413.232 466.779C412.614 462.26 410.565 458.128 408.795 455.576C406.262 448.547 395.912 432.872 373.42 421.224C361.666 415.137 346.077 414.826 331.651 416.73C317.158 418.642 303.47 422.841 295.254 426.144L295.173 426.177L295.096 426.216C280.971 433.378 263.911 440.726 248.633 446.596C233.303 452.485 219.94 456.818 213.148 458.031C212.536 458.141 211.963 458.255 211.395 458.367C209.525 458.739 207.718 459.099 204.781 459.237C201.278 459.402 196.187 459.242 187.772 458.307C190.403 450.494 191.344 440.149 191.498 435.589C192.003 426.472 191.499 419.957 190.724 415.238C190.094 411.408 189.28 408.759 188.744 407.015C188.62 406.613 188.511 406.259 188.423 405.951C186.541 399.362 177.442 369.51 172.415 355.415C170.996 349.858 169.276 344.017 167.454 337.831C163.621 324.818 159.34 310.28 156.471 293.66C155.953 290.661 155.451 287.904 154.978 285.304C152.803 273.365 151.229 264.72 151.5 251.04C151.685 241.67 152.496 232.75 153.737 227.457C154.357 224.814 155.013 223.473 155.638 222.729C156.148 222.121 156.746 221.781 157.726 221.509C172.635 221.999 187.685 221.986 202.2 211.1C208.165 206.626 212.67 199.76 215.874 193.161C219.09 186.537 221.091 179.982 221.956 175.916L221.971 175.847L221.981 175.776C222.975 168.65 225 152.467 225 145.5C225 142.01 224.744 139.5 224.4 137.751C224.228 136.878 224.029 136.166 223.814 135.604C223.624 135.108 223.347 134.518 222.914 134.086C221.597 132.768 220.015 132.48 218.681 132.67C217.435 132.847 216.385 133.431 215.751 133.938Z" fill="#423C3C" stroke="white" stroke-width="4" />
<path d="M396.293 189.822L396.301 189.816L396.309 189.81C400.807 186.554 404.894 182.582 408.575 177.908C412.418 173.13 415.601 167.459 418.141 160.923C421.443 152.431 422.641 143.859 421.708 135.23C420.865 126.664 418.122 118.566 413.5 110.952C408.917 103.257 402.595 96.265 394.578 89.966C386.661 83.5971 377.324 78.3341 366.591 74.1616C359.768 71.5094 352.402 69.7828 344.503 68.971L344.496 68.9703C336.644 68.1891 329.018 68.6366 321.627 70.3215C314.215 71.9313 307.434 74.9924 301.299 79.4962L301.291 79.5027L301.282 79.5092C295.125 84.1336 290.523 90.4414 287.449 98.3483C285.286 103.913 284.142 108.976 284.111 113.506L284.111 113.526L284.112 113.547C284.172 117.909 284.629 121.729 285.516 124.976L285.527 125.016L285.54 125.056C286.518 128.125 287.6 130.626 288.823 132.47L288.845 132.504L288.868 132.537C289.426 133.308 289.865 133.911 290.185 134.344C290.372 134.596 290.551 134.837 290.696 135.02C291.218 135.978 291.858 136.83 292.667 137.443L292.694 137.464L292.721 137.483C293.481 138.021 294.29 138.459 295.146 138.792C297.574 139.735 300.011 139.638 302.3 138.454C304.548 137.368 306.191 135.694 307.069 133.435C308.368 130.095 307.498 126.632 305.183 123.292C305.078 123.107 304.955 122.923 304.873 122.801L304.863 122.785C304.707 122.553 304.5 122.252 304.243 121.885C303.919 121.226 303.574 120.196 303.246 118.715C302.953 117.162 302.808 115.257 302.833 112.98C302.973 110.805 303.608 108.187 304.809 105.097C306.114 101.739 308.209 98.7058 311.138 95.9941C314.199 93.2761 317.607 91.1259 321.37 89.5385L321.371 89.5381C325.253 87.8979 329.295 86.9421 333.506 86.6689C337.763 86.432 341.701 87.0334 345.345 88.45C350.799 90.5705 355.517 93.5139 359.518 97.2743L359.527 97.2825C363.624 101.086 366.725 105.403 368.852 110.237L368.857 110.248L368.862 110.259C371.076 115.132 372.277 120.437 372.45 126.194C372.622 131.904 371.563 137.741 369.238 143.723C366.49 150.791 363.569 156.517 360.492 160.942C357.381 165.415 354.124 169.039 350.732 171.844L350.719 171.856L350.705 171.867C347.363 174.734 343.885 176.988 340.273 178.647L340.258 178.653L340.243 178.661C336.575 180.419 332.836 182.1 329.027 183.706C325.305 185.124 321.707 186.746 318.233 188.569L318.217 188.578L318.201 188.587C314.772 190.47 311.542 192.639 308.512 195.093C305.453 197.571 302.675 200.414 300.178 203.617L300.164 203.635L300.15 203.654C297.715 206.931 295.707 210.617 294.118 214.702C293.629 215.962 293.254 217.059 293.009 217.978C292.786 218.817 292.652 219.25 292.597 219.392L292.561 219.485L292.534 219.581C291.885 221.916 292.173 224.227 293.315 226.416L293.332 226.448L293.35 226.48C294.561 228.605 296.428 230.118 298.813 231.045L298.906 231.081L299.002 231.108C301.301 231.747 303.556 231.514 305.658 230.406C307.823 229.274 309.396 227.582 310.262 225.354L311.638 221.815C313.026 218.245 314.464 215.559 315.92 213.678C317.362 211.815 319.068 210.4 321.042 209.404C323.071 208.38 325.547 207.614 328.504 207.138C331.556 206.648 335.253 206.199 339.604 205.793C343.669 205.457 348.061 205.048 352.782 204.566L352.799 204.564L352.816 204.562C357.627 203.986 362.477 203.171 367.365 202.119L367.38 202.116L367.396 202.113C372.358 200.963 377.26 199.433 382.102 197.523L382.103 197.522C387.127 195.535 391.858 192.968 396.293 189.822ZM301.113 277.932C303.892 276.709 306.369 274.988 308.537 272.782C310.831 270.572 312.57 267.931 313.755 264.883C314.909 261.914 315.39 258.835 315.191 255.665C315.081 252.565 314.396 249.65 313.124 246.94C311.862 244.253 310.122 241.83 307.922 239.672C305.686 237.479 303.081 235.806 300.125 234.656C297.168 233.507 294.117 232.981 290.987 233.088C287.907 233.193 284.987 233.806 282.242 234.935C279.464 236.077 276.983 237.772 274.804 239.994C272.611 242.23 270.938 244.835 269.788 247.792C268.608 250.827 268.062 253.921 268.169 257.06C268.275 260.162 268.939 263.114 270.166 265.901C271.393 268.689 273.121 271.172 275.337 273.346C277.573 275.538 280.178 277.211 283.135 278.361C286.091 279.51 289.142 280.036 292.272 279.929C295.374 279.823 298.326 279.159 301.113 277.932Z" fill="#292A2E" stroke="white" stroke-width="4" />
</svg>
</div>
<h2>出现错误</h2>
<p><%= text %></p>
</div>
<%- include('_footer'); -%>

View File

@@ -0,0 +1,37 @@
<%- include('_header'); -%>
<style>
.login-card {
width: 420px;
background-color: #F7F7F7;
margin: 0 auto 10px;
border-radius: 2px;
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
</style>
<div class="card login-card">
<div class="card-content">
<p class="title">
登录 Tailchat
</p>
<div class="content">
<form autocomplete="off" action="/open/interaction/<%= uid %>/login" method="post">
<div class="control">
<input class="input" name="email" required type="text" placeholder="邮箱" />
</div>
<div class="control">
<input class="input" name="password" required type="password" placeholder="密码">
</div>
<button type="submit" class="button is-info is-fullwidth">登录</button>
</form>
</div>
</div>
</div>
<%- include('_footer'); -%>