chore: move tailchat-server into tailchat

pull/49/head
moonrailgun 3 years ago
parent a3cda65027
commit ee7a0074a0

@ -0,0 +1,4 @@
.env
node_modules
logs
dist

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[.gitconfig]
indent_style = tab
[Makefile]
indent_style = tab
[*.md]
trim_trailing_whitespace = false

@ -0,0 +1,30 @@
############################
# 该环境变量用于单机运行
############################
PORT=11000
SECRET=
# 示例: mongodb://user:pass@127.0.0.1:27017/tailchat
MONGO_URL=
REDIS_URL=redis://localhost:6379/
TRANSPORTER=
# 填写服务端可访问的接口地址
API_URL=
# 文件存储
MINIO_URL=127.0.0.1:19000
MINIO_USER=tailchat
MINIO_PASS=com.msgbyte.tailchat
# SMTP 服务
# 示例: "Tailchat" example@163.com
SMTP_SENDER=
# 示例: smtp://username:password@smtp.example.com/?pool=true
SMTP_URI=
# 视频会议服务(optional) 不包含结尾的/
TAILCHAT_MEETING_URL=
# Admin 后台密码
ADMIN_PASS=com.msgbyte.tailchat

@ -0,0 +1,55 @@
name: "CI"
on:
# 单元测试还有点问题。先注释
# push:
# branches:
# - master
workflow_dispatch:
jobs:
ci:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
services:
redis:
image: redis:alpine
mongo:
image: mongo:4
minio:
image: minio/minio
env:
MINIO_ROOT_USER: tailchat
MINIO_ROOT_PASSWORD: com.msgbyte.tailchat
steps:
- name: checkout
uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Cache pnpm modules
uses: actions/cache@v2
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-
- uses: pnpm/action-setup@v2.0.1
with:
run_install: false
- name: Install packages
run: pnpm install --frozen-lockfile
- name: Test
run: pnpm test
env:
TZ: Asia/Shanghai
MONGO_URL: mongodb://localhost:27017/tailchat
REDIS_URL: redis://localhost:6379
MINIO_URL: localhost:9000
MINIO_USER: tailchat
MINIO_PASS: com.msgbyte.tailchat
- name: Check Build
run: pnpm build

@ -0,0 +1,46 @@
# Reference: https://github.com/docker/build-push-action/blob/master/docs/advanced/tags-labels.md
name: "Docker Publish"
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
jobs:
dockerize:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: moonrailgun/tailchat-server
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

112
server/.gitignore vendored

@ -0,0 +1,112 @@
config/local.*
data/
__uploads/
# 插件
public/plugins/
public/registry.json
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port

@ -0,0 +1,6 @@
# https://npmmirror.com/
registry = https://registry.npmmirror.com
ignore-workspace-root-check = true
strict-peer-dependencies = false # 因为一些旧依赖(特别是mongoose相关) 比较糟糕,因此关掉
# For docker: https://pnpm.io/npmrc#unsafe-perm
unsafe-perm = true

@ -0,0 +1,26 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always",
"parser": "babel",
"jsxBracketSameLine": false,
"overrides": [
{
"files": "*.{tsx,ts}",
"options": {
"parser": "typescript"
}
},
{
"files": ["*.json"],
"options": {
"parser": "json"
}
}
]
}

@ -0,0 +1,33 @@
FROM node:lts-alpine
# Working directory
WORKDIR /app
# Install dependencies
RUN npm install -g pnpm@7.1.9
# Install plugins and sdk dependency
COPY ./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml ./tsconfig.json ./.npmrc ./
COPY ./packages ./packages
COPY ./plugins ./plugins
RUN pnpm install
# Copy source
COPY . .
RUN pnpm install
# Build and cleanup
ENV NODE_ENV=production
RUN pnpm run build
# Install plugins(whitelist)
RUN pnpm run plugin:install com.msgbyte.tasks com.msgbyte.linkmeta com.msgbyte.github com.msgbyte.simplenotify
# Copy public files
RUN mkdir -p ./dist/public && cp -r ./public/plugins ./dist/public && cp ./public/registry.json ./dist/public
# web static service port
EXPOSE 3000
# Start server
CMD ["pnpm", "start:service"]

@ -0,0 +1,264 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
APACHE JACKRABBIT SUBCOMPONENTS
Apache Jackrabbit includes parts with separate copyright notices and license
terms. Your use of these subcomponents is subject to the terms and conditions
of the following licenses:
XPath 2.0/XQuery 1.0 Parser:
http://www.w3.org/2002/11/xquery-xpath-applets/xgrammar.zip
Copyright (C) 2002 World Wide Web Consortium, (Massachusetts Institute of
Technology, European Research Consortium for Informatics and Mathematics,
Keio University). All Rights Reserved.
This work is distributed under the W3C(R) Software License in the hope
that it will be useful, but WITHOUT ANY WARRANTY; without even the
implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
W3C(R) SOFTWARE NOTICE AND LICENSE
http://www.w3.org/Consortium/Legal/2002/copyright-software-20021231
This work (and included software, documentation such as READMEs, or
other related items) is being provided by the copyright holders under
the following license. By obtaining, using and/or copying this work,
you (the licensee) agree that you have read, understood, and will comply
with the following terms and conditions.
Permission to copy, modify, and distribute this software and its
documentation, with or without modification, for any purpose and
without fee or royalty is hereby granted, provided that you include
the following on ALL copies of the software and documentation or
portions thereof, including modifications:
1. The full text of this NOTICE in a location viewable to users
of the redistributed or derivative work.
2. Any pre-existing intellectual property disclaimers, notices,
or terms and conditions. If none exist, the W3C Software Short
Notice should be included (hypertext is preferred, text is
permitted) within the body of any redistributed or derivative code.
3. Notice of any changes or modifications to the files, including
the date changes were made. (We recommend you provide URIs to the
location from which the code is derived.)
THIS SOFTWARE AND DOCUMENTATION IS PROVIDED "AS IS," AND COPYRIGHT
HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR
DOCUMENTATION WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS,
TRADEMARKS OR OTHER RIGHTS.
COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL
OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR
DOCUMENTATION.
The name and trademarks of copyright holders may NOT be used in
advertising or publicity pertaining to the software without specific,
written prior permission. Title to copyright in this software and
any associated documentation will at all times remain with
copyright holders.

@ -0,0 +1,144 @@
# tailchat-server
## 启动开发服务器
```bash
cp .env.example .env
vim .env
```
编辑`.env`的配置为自己的
```bash
pnpm install # 安装环境变量
pnpm dev # 启动开发服务器
```
## 开发环境
强烈建议使用 `Docker` 初始化第三方开发环境, 隔离性更加好 并且无需复杂的安装配置。
mongodb
```bash
docker run -d --name mongo -p 127.0.0.1:27017:27017 mongo:4
```
redis
```bash
docker run -d --name redis -p 127.0.0.1:6379:6379 redis
```
minio
```bash
docker run -d \
-p 127.0.0.1:19000:9000 \
-p 127.0.0.1:19001:9001 \
--name minio \
-e "MINIO_ROOT_USER=tailchat" \
-e "MINIO_ROOT_PASSWORD=com.msgbyte.tailchat" \
minio/minio server /data --console-address ":9001"
```
#### 服务端插件安装方式
安装所有插件
```
pnpm plugin:install all
```
安装单个插件
```
pnpm plugin:install com.msgbyte.tasks
```
## 单节点部署
#### docker-compose 一键部署
请确保已经安装了:
- docker
- docker-compose
在项目根目录下执行
```bash
docker-compose build # 需要编译
docker-compose up -d
```
## 运维
### 使用mongo工具进行管理
#### 从docker中取出mongodb的数据
```bash
docker exec -it <MONGO_DOCKER_NAME> mongodump -h 127.0.0.1 --port 27017 -d <MONGO_COLLECTION_NAME> -o /opt/backup/
docker exec -it <MONGO_DOCKER_NAME> tar -zcvf /tmp/mongodata.tar.gz /opt/backup/<MONGO_COLLECTION_NAME>
docker cp <MONGO_DOCKER_NAME>:/tmp/mongodata.tar.gz ${PWD}/
```
#### 将本地的备份存储到mongodb镜像
```bash
docker cp mongodata.tar.gz <MONGO_DOCKER_NAME>:/tmp/
docker exec -it <MONGO_DOCKER_NAME> tar -zxvf /tmp/mongodata.tar.gz
docker exec -it <MONGO_DOCKER_NAME> mongorestore -h 127.0.0.1 --port 27017 -d <MONGO_COLLECTION_NAME> /opt/backup/<MONGO_COLLECTION_NAME>
```
### 通过docker volume
#### 备份
```bash
docker run -it --rm --volumes-from <DOCKER_CONTAINER_NAME> -v ${PWD}:/opt/backup --name export busybox sh
# 进入容器
tar -zcvf /opt/backup/data.tar <DATA_PATH>
exit
```
此处<DATA_PATH>, 如果是minio则为`/data/`如果是mongo则为`/data/db`
#### 恢复
```bash
docker run -it --rm --volumes-from <DOCKER_CONTAINER_NAME> -v ${PWD}:/opt/backup --name importer busybox sh
tar -zxvf /opt/backup/data.tar
exit
```
## Benchmark
### Case 1
部署环境
```
hash: 4771a830b0787280d53935948c99c340c81de977
env: development
cpu: i7-8700K
memory: 32G
节点数: 1
测试终端: tailchat-cli
测试脚本: bench --time 60 --num 10000 "chat.message.sendMessage" '{"converseId": "61fa58845aff4f8a3e68ccf3", "groupId": "61fa58845aff4f8a3e68ccf4", "content": "123"}'
备注:
- 使用`Redis`作为消息中转中心, `Redis`部署在局域网的nas上
- 使用一个真实账户作为消息推送的接收方
```
```
Benchmark result:
3,845 requests in 1m, 0 error
Requests/sec: 64
Latency:
Avg: 15ms
Min: 9ms
Max: 91ms
```
### Case 2
<!-- TODO -->

@ -0,0 +1,3 @@
AdminJS.UserComponents = {}
import Component1 from '../src/dashboard'
AdminJS.UserComponents.Component1 = Component1

@ -0,0 +1,5 @@
## tailchat-admin
**WIP**
tailchat的后台管理系统

@ -0,0 +1,35 @@
{
"name": "tailchat-admin",
"private": true,
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "ts-node src/index.ts",
"dev": "nodemon --watch 'src/**' --ext 'ts' --ignore 'src/**/*.spec.ts' --exec 'ts-node src/index.ts'",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "moonrailgun",
"license": "MIT",
"dependencies": {
"@adminjs/koa": "^2.1.0",
"@adminjs/mongoose": "^2.0.4",
"@koa/router": "^10.1.1",
"adminjs": "^5.10.4",
"koa": "^2.13.4",
"koa-static": "^5.0.0",
"koa2-formidable": "^1.0.3",
"react-use": "^17.4.0",
"recharts": "^2.1.12"
},
"devDependencies": {
"@types/koa": "^2.13.4",
"@types/koa-static": "^4.0.2",
"@types/node": "16.11.7",
"@types/react": "^17.0.38",
"nodemon": "^2.0.18",
"ts-node": "^10.8.0",
"typescript": "^4.3.3"
}
}

@ -0,0 +1,3 @@
import { ApiClient } from 'adminjs';
export const api = new ApiClient();

@ -0,0 +1,32 @@
import React from 'react';
import { api } from './api';
import { useAsync } from 'react-use';
/**
* WIP
*/
const Dashboard = (props) => {
useAsync(async () => {
try {
const all = await api
.resourceAction({ resourceId: 'User', actionName: 'list' })
.then(({ data }) => data.meta.total);
const temporary = await api
.searchRecords({
resourceId: 'User',
// actionName: 'list',
query: '?filters.temporary=true&page=1',
})
.then((list) => list.length);
console.log('用户情况:', all, temporary);
} catch (err) {
console.error(err);
}
}, []);
return <div>My custom dashboard</div>;
};
export default Dashboard;

@ -0,0 +1,67 @@
import AdminJS from 'adminjs';
import { buildAuthenticatedRouter } from '@adminjs/koa';
import AdminJSMongoose from '@adminjs/mongoose';
import Koa from 'koa';
import { mongoose } from '@typegoose/typegoose';
import dotenv from 'dotenv';
import path from 'path';
import { getResources } from './resources';
import serve from 'koa-static';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const mongoUri = process.env.MONGO_URL;
const adminPass = process.env.ADMIN_PASS;
AdminJS.registerAdapter(AdminJSMongoose);
async function run() {
if (!mongoUri) {
console.warn(`MONGO_URL has not been set.`);
return;
}
if (!adminPass) {
console.warn(`ADMIN_PASS has not been set.`);
return;
}
const app = new Koa();
app.keys = ['tailchat-admin-secret'];
const mongooseDb = await mongoose.connect(mongoUri);
const adminJs = new AdminJS({
branding: {
companyName: 'tailchat',
logo: '/images/logo.svg',
favicon: '/images/logo.svg',
softwareBrothers: false,
},
databases: [mongooseDb],
rootPath: '/admin',
resources: getResources(),
// dashboard: {
// component: AdminJS.bundle('./dashboard'),
// },
});
const router = buildAuthenticatedRouter(adminJs, app, {
authenticate: async (email, password) => {
if (email === 'tailchat@msgbyte.com' && password === adminPass) {
return { email, title: 'Admin' };
}
return null;
},
});
app.use(router.routes()).use(router.allowedMethods());
app.use(serve(path.resolve(__dirname, '../static')));
app.listen(14100, () => {
console.log('AdminJS is under http://localhost:14100/admin');
console.log(`please login with: tailchat@msgbyte.com/${adminPass}`);
});
}
run();

@ -0,0 +1,42 @@
import type { ResourceWithOptions } from 'adminjs';
import User from '../../models/user/user';
import Group from '../../models/group/group';
import Message from '../../models/chat/message';
import File from '../../models/file';
export function getResources() {
return [
{
resource: User,
options: {
properties: {
email: {
isDisabled: true,
},
username: {
isVisible: false,
},
password: {
isVisible: false,
},
},
sort: {
direction: 'desc',
sortBy: 'createdAt',
},
},
} as ResourceWithOptions,
Group,
{
resource: Message,
options: {
properties: {
content: {
type: 'textarea',
},
},
},
} as ResourceWithOptions,
File,
];
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

@ -0,0 +1,12 @@
# 可选启用
WIP
该文件夹用于运维
## 用法 Usage
```bash
cd ./devops
docker-compose -f ../docker-compose.yml -f docker-compose.devops.yml up -d
```

@ -0,0 +1,6 @@
- name: 'default'
org_id: 1
folder: ''
type: 'file'
options:
folder: '/var/lib/grafana/dashboards'

@ -0,0 +1,826 @@
{
"__inputs": [
{
"name": "DS_PROMETHEUS",
"label": "Prometheus",
"description": "",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus"
}
],
"__elements": [],
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "8.5.4"
},
{
"type": "panel",
"id": "piechart",
"name": "Pie chart",
"version": ""
},
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0"
},
{
"type": "panel",
"id": "stat",
"name": "Stat",
"version": ""
},
{
"type": "panel",
"id": "timeseries",
"name": "Time series",
"version": ""
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"displayName": "内存使用率(Byte)",
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"exemplar": false,
"expr": "os_memory_used{namespace=\"tailchat\"}",
"instant": false,
"legendFormat": "",
"range": true,
"refId": "A"
}
],
"title": "内存使用率",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"displayName": "CPU 使用率",
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "os_cpu_utilization",
"refId": "A"
}
],
"title": "CPU 利用率",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
}
},
"mappings": []
},
"overrides": []
},
"gridPos": {
"h": 16,
"w": 12,
"x": 0,
"y": 9
},
"id": 6,
"options": {
"displayLabels": [
"value",
"name"
],
"legend": {
"displayMode": "list",
"placement": "right",
"values": []
},
"pieType": "pie",
"reduceOptions": {
"calcs": [
"last"
],
"fields": "",
"values": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "8.5.4",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"exemplar": false,
"expr": "moleculer_request_total",
"format": "time_series",
"instant": true,
"legendFormat": "{{action}}({{caller}})",
"range": false,
"refId": "A"
}
],
"title": "请求分布",
"type": "piechart"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 7,
"x": 12,
"y": 9
},
"id": 8,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"exemplar": false,
"expr": "moleculer_request_active",
"instant": false,
"legendFormat": "{{action}}",
"range": true,
"refId": "A"
}
],
"title": "当前正在处理中的请求",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 5,
"x": 19,
"y": 9
},
"id": 10,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.5.4",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"exemplar": false,
"expr": "moleculer_transporter_packets_received_bytes",
"instant": true,
"range": false,
"refId": "A"
}
],
"title": "接收包流量",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 5,
"x": 19,
"y": 13
},
"id": 11,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.5.4",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"exemplar": false,
"expr": "moleculer_transporter_packets_sent_bytes",
"instant": true,
"range": false,
"refId": "A"
}
],
"title": "发送包流量",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"displayName": "在线人数",
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 9,
"x": 12,
"y": 17
},
"id": 13,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "tailchat_socketio_online_count",
"refId": "A"
}
],
"title": "当前在线人数",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 3,
"x": 21,
"y": 17
},
"id": 15,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "8.5.4",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"exemplar": false,
"expr": "moleculer_registry_nodes_online_total",
"hide": false,
"instant": true,
"interval": "",
"legendFormat": "在线",
"range": false,
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"exemplar": false,
"expr": "moleculer_registry_nodes_total",
"hide": false,
"instant": true,
"legendFormat": "总共",
"range": false,
"refId": "A"
}
],
"title": "节点数",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 3,
"x": 21,
"y": 21
},
"id": 16,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "8.5.4",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"exemplar": false,
"expr": "moleculer_registry_services_total",
"hide": false,
"instant": true,
"interval": "",
"legendFormat": "服务数",
"range": false,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"exemplar": false,
"expr": "moleculer_registry_events_total",
"hide": false,
"instant": true,
"legendFormat": "事件数",
"range": false,
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"exemplar": false,
"expr": "moleculer_registry_actions_total",
"hide": false,
"instant": true,
"legendFormat": "操作数",
"range": false,
"refId": "C"
}
],
"title": "服务数",
"type": "stat"
}
],
"refresh": false,
"schemaVersion": 36,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-15m",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "tailchat-server",
"uid": "H3miszjnk",
"version": 14,
"weekStart": ""
}

@ -0,0 +1,28 @@
apiVersion: 1
deleteDatasources:
- name: Prometheus
orgId: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
password:
user:
database: prometheus
basicAuth: false
basicAuthUser:
basicAuthPassword:
withCredentials:
isDefault: true
jsonData:
tlsAuth: false
tlsAuthWithCACert: false
secureJsonData:
tlsCACert: ""
tlsClientCert: ""
tlsClientKey: ""
version: 1
editable: true

@ -0,0 +1,27 @@
# global config
global:
scrape_interval: 120s # By default, scrape targets every 15 seconds.
evaluation_interval: 120s # By default, scrape targets every 15 seconds.
# scrape_timeout is set to the global default (10s).
# Attach these labels to any time series or alerts when communicating with
# external systems (federation, remote storage, Alertmanager).
external_labels:
monitor: 'tailchat-devops'
# Load and evaluate rules in this file every 'evaluation_interval' seconds.
rule_files:
# - "alert.rules"
# - "first.rules"
# - "second.rules"
scrape_configs:
- job_name: 'prometheus'
scrape_interval: 15s
static_configs:
- targets: ['localhost:9090']
- job_name: 'tailchat-server'
scrape_interval: 15s
metrics_path: /metrics
scheme: http
static_configs:
- targets: ['tailchat-server:13030']

@ -0,0 +1,46 @@
version: "3.3"
services:
# 应用网关
prometheus:
image: prom/prometheus:v2.26.0
user: root
container_name: tailchat-prometheus
restart: unless-stopped
volumes:
- ./config/prometheus.yml:/etc/prometheus/prometheus.yml
- ./data/prometheus:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
ports:
- 9090
links:
- service-gateway:tailchat-server
depends_on:
- service-gateway
networks:
- internal
grafana:
image: grafana/grafana:7.5.3
user: root
container_name: tailchat-grafana
restart: unless-stopped
links:
- prometheus:prometheus
ports:
- 13000:3000
volumes:
- ./config/grafana-prometheus-datasource.yml:/etc/grafana/provisioning/datasources/prometheus.yml
# - ./config/grafana-dashboards.yml:/etc/grafana/provisioning/dashboards/all.yml
# - ./config/grafana-dashboards:/var/lib/grafana/dashboards
- ./data/grafana:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_USER=tailchat
- GF_SECURITY_ADMIN_PASSWORD=tailchat
- GF_USERS_ALLOW_SIGN_UP=false
depends_on:
- prometheus
networks:
- internal

@ -0,0 +1,26 @@
LOGGER=true
LOGLEVEL=info
SERVICEDIR=services
TRANSPORTER=redis://redis:6379
CACHER=redis://redis:6379
REDIS_URL=redis://redis:6379
MONGO_URL=mongodb://mongo/tailchat
SECRET=
# file
API_URL=https://paw-server-nightly.moonrailgun.com
# minio
MINIO_URL=minio:9000
MINIO_USER=tailchat
MINIO_PASS=com.msgbyte.tailchat
# SMTP
SMTP_SENDER=
SMTP_URI=
# metrics
PROMETHEUS=1

@ -0,0 +1,176 @@
version: "3.3"
services:
# 应用网关
service-gateway:
build:
context: .
image: tailchat-server
restart: unless-stopped
env_file: docker-compose.env
environment:
SERVICES: core/gateway
PORT: 3000
depends_on:
- mongo
- redis
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-gw.rule=PathPrefix(`/`)"
- "traefik.http.services.api-gw.loadbalancer.server.port=3000"
networks:
- internal
# 用户服务
service-user:
build:
context: .
image: tailchat-server
restart: unless-stopped
env_file: docker-compose.env
environment:
SERVICES: core/user/*
depends_on:
- mongo
- redis
networks:
- internal
# 群组服务
service-group:
build:
context: .
image: tailchat-server
restart: unless-stopped
env_file: docker-compose.env
environment:
SERVICES: core/group/*
depends_on:
- mongo
- redis
networks:
- internal
# 聊天服务
service-chat:
build:
context: .
image: tailchat-server
restart: unless-stopped
env_file: docker-compose.env
environment:
SERVICES: core/chat/*
depends_on:
- mongo
- redis
networks:
- internal
# 文件服务 / 插件注册中心 / 配置服务
service-file:
build:
context: .
image: tailchat-server
restart: unless-stopped
env_file: docker-compose.env
environment:
SERVICES: core/file,core/plugin/registry,core/config
depends_on:
- mongo
- redis
- minio
networks:
- internal
service-openapi:
build:
context: .
image: tailchat-server
restart: unless-stopped
env_file: docker-compose.env
environment:
SERVICES: openapi/app,openapi/oidc/oidc
OPENAPI_PORT: 3003
OPENAPI_UNDER_PROXY: "true"
depends_on:
- mongo
- redis
- minio
labels:
- "traefik.enable=true"
- "traefik.http.routers.openapi-oidc.rule=PathPrefix(`/open`)"
- "traefik.http.services.openapi-oidc.loadbalancer.server.port=3003"
networks:
- internal
# 插件服务(所有插件)
service-all-plugins:
build:
context: .
image: tailchat-server
restart: unless-stopped
env_file: docker-compose.env
environment:
SERVICEDIR: plugins
depends_on:
- mongo
- redis
- minio
networks:
- internal
# 数据库
mongo:
image: mongo:4
restart: on-failure
volumes:
- data:/data/db
networks:
- internal
# 数据缓存与中转通讯
redis:
image: redis:alpine
restart: on-failure
networks:
- internal
# 存储服务
minio:
image: minio/minio
restart: on-failure
networks:
- internal
environment:
MINIO_ROOT_USER: tailchat
MINIO_ROOT_PASSWORD: com.msgbyte.tailchat
volumes:
- storage:/data
command: minio server /data --console-address ":9001"
# 路由转发
traefik:
image: traefik:v2.1
restart: unless-stopped
command:
- "--api.insecure=true" # Don't do that in production!
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure" # Not good
ports:
- 11000:80
- 127.0.0.1:11001:8080
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- internal
- default
networks:
internal:
name: tailchat-internal
volumes:
data:
storage:

@ -0,0 +1,14 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
setupFiles: [
'<rootDir>/test/setup.ts'
],
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json',
},
},
};

@ -0,0 +1,29 @@
import { NAME_REGEXP } from '../const';
describe('NAME_REGEXP', () => {
describe('allow', () => {
test.each([
'test',
'test01',
'你好世界',
'你好world',
'最大八个汉字内容',
'maxis16charactor',
'1234567812345678',
])('%s', (input) => {
expect(NAME_REGEXP.test(input)).toBe(true);
});
});
describe('deny', () => {
test.each([
'世 界',
'你好 world',
'超过了八个汉字内容',
'overmax16charactor',
'12345678123456781',
])('%s', (input) => {
expect(NAME_REGEXP.test(input)).toBe(false);
});
});
});

@ -0,0 +1,66 @@
import {
checkPathMatch,
generateRandomStr,
getEmailAddress,
isValidStr,
sleep,
} from '../utils';
describe('getEmailAddress', () => {
test.each([
['foo@example.com', 'foo'],
['foo.bar@example.com', 'foo.bar'],
['foo$bar@example.com', 'foo$bar'],
])('%s', (input, output) => {
expect(getEmailAddress(input)).toBe(output);
});
});
describe('generateRandomStr', () => {
test('should generate string with length 10(default)', () => {
expect(generateRandomStr()).toHaveLength(10);
});
test('should generate string with manual length', () => {
expect(generateRandomStr(4)).toHaveLength(4);
});
});
describe('isValidStr', () => {
test.each<[any, boolean]>([
[false, false],
[true, false],
[0, false],
[1, false],
['', false],
[{}, false],
[[], false],
['foo', true],
])('%p is %p', (input, output) => {
expect(isValidStr(input)).toBe(output);
});
});
test('sleep', async () => {
const start = new Date().valueOf();
await sleep(1000);
const end = new Date().valueOf();
const duration = end - start;
expect(duration).toBeGreaterThanOrEqual(1000);
expect(duration).toBeLessThan(1050);
});
describe('checkPathMatch', () => {
const testList = ['/foo/bar'];
test.each([
['/foo/bar', true],
['/foo/bar?query=1', true],
['/foo', false],
['/foo/baz', false],
['/foo/baz?bar=', false],
])('%s', (input, output) => {
expect(checkPathMatch(testList, input)).toBe(output);
});
});

@ -0,0 +1,78 @@
import type { TcContext } from 'tailchat-server-sdk';
import type { Group } from '../models/group/group';
import type { User } from '../models/user/user';
import { SYSTEM_USERID } from './const';
export function call(ctx: TcContext) {
return {
/**
*
* groupId
*/
async sendSystemMessage(
message: string,
converseId: string,
groupId?: string
) {
await ctx.call(
'chat.message.sendMessage',
{
converseId,
groupId,
content: message,
},
{
meta: {
...ctx.meta,
userId: SYSTEM_USERID,
},
}
);
},
/**
*
*/
async addGroupSystemMessage(groupId: string, message: string) {
const lobbyConverseId = await ctx.call('group.getGroupLobbyConverseId', {
groupId,
});
if (!lobbyConverseId) {
// 如果没有文本频道则跳过
return;
}
await ctx.call(
'chat.message.sendMessage',
{
converseId: lobbyConverseId,
groupId: groupId,
content: message,
},
{
meta: {
...ctx.meta,
userId: SYSTEM_USERID,
},
}
);
},
/**
*
*/
async getUserInfo(userId: string): Promise<User> {
return await ctx.call('user.getUserInfo', {
userId,
});
},
/**
*
*/
async getGroupInfo(groupId: string): Promise<Group> {
return await ctx.call('group.getGroupInfo', {
groupId,
});
},
};
}

@ -0,0 +1,46 @@
export const NAME_REGEXP =
/^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]|[\u3040-\u309Fー]|[\u30A0-\u30FF]){1,8}$/;
/**
* TODO:
*
*
* key
* value
*/
export const BUILTIN_GROUP_PERM = {
/**
*
*/
displayChannel: true,
/**
*
*/
manageChannel: false,
/**
*
*/
manageRole: false,
/**
*
*/
manageGroup: false,
/**
*
*/
sendMessage: true,
/**
*
*/
sendImage: true,
};
/**
* id
*/
export const SYSTEM_USERID = '000000000000000000000000';

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`des encrypt D 1`] = `"ihmnn4VBPYE="`;
exports[`des encrypt bar 1`] = `"p/PIC32MPm4="`;
exports[`des encrypt foo 1`] = `"NP3+ABhEiY4="`;
exports[`des encrypt 你 1`] = `"O5kF0LXzjpE="`;

@ -0,0 +1,17 @@
import { desEncrypt, desDecrypt } from '../des';
describe('des', () => {
const key = '12345678';
describe('encrypt', () => {
test.each([['foo'], ['bar'], ['你'], ['D']])('%s', (input) => {
expect(desEncrypt(input, key)).toMatchSnapshot();
});
});
describe('decrypt', () => {
test.each([['foo'], ['bar'], ['你'], ['D']])('%s', (input) => {
expect(desDecrypt(desEncrypt(input, key), key)).toBe(input);
});
});
});

@ -0,0 +1,24 @@
import crypto from 'crypto';
import { config } from 'tailchat-server-sdk';
// DES 加密
export function desEncrypt(message: string, key: string = config.secret) {
key =
key.length >= 8 ? key.slice(0, 8) : key.concat('0'.repeat(8 - key.length));
const keyHex = new Buffer(key);
const cipher = crypto.createCipheriv('des-cbc', keyHex, keyHex);
let c = cipher.update(message, 'utf8', 'base64');
c += cipher.final('base64');
return c;
}
// DES 解密
export function desDecrypt(text: string, key: string = config.secret) {
key =
key.length >= 8 ? key.slice(0, 8) : key.concat('0'.repeat(8 - key.length));
const keyHex = new Buffer(key);
const cipher = crypto.createDecipheriv('des-cbc', keyHex, keyHex);
let c = cipher.update(text, 'base64', 'utf8');
c += cipher.final('utf8');
return c;
}

@ -0,0 +1,45 @@
import ExtendableError from 'es6-error';
class TcError extends ExtendableError {
public code: number;
public type: string;
public data: any;
public retryable: boolean;
constructor(message?: string, code?: number, type?: string, data?: unknown) {
super(message ?? '服务器出错');
this.code = code ?? this.code ?? 500;
this.type = type ?? this.type;
this.data = data ?? this.data;
this.retryable = this.retryable ?? false;
}
}
export class DataNotFoundError extends TcError {
constructor(message?: string, code?: number, type?: string, data?: unknown) {
super(message ?? '找不到数据', code ?? 404, type, data);
}
}
export class EntityError extends TcError {
constructor(
message?: string,
code?: number,
type?: string,
data?: { field: string; message: string }[]
) {
super(message ?? '表单不正确', code ?? 442, type, data);
}
}
export class NoPermissionError extends TcError {
constructor(message?: string, code?: number, type?: string, data?: unknown) {
super(message ?? '没有操作权限', code ?? 403, type, data);
}
}
export class ServiceUnavailableError extends TcError {
constructor(data?: unknown) {
super('Service unavailable', 503, 'SERVICE_NOT_AVAILABLE', data);
}
}

@ -0,0 +1,68 @@
import randomString from 'crypto-random-string';
import _ from 'lodash';
/**
*
* @param email
* @returns
*/
export function getEmailAddress(email: string) {
return email.split('@')[0];
}
/**
*
* @param length
*/
export function generateRandomStr(length = 10): string {
return randomString({ length });
}
export function generateRandomNumStr(length = 6) {
return randomString({
length,
type: 'numeric',
});
}
/**
*
*
*/
export function isValidStr(str: unknown): str is string {
return typeof str == 'string' && str !== '';
}
/**
*
*/
export function isValidStaticAssetsUrl(str: unknown): str is string {
if (typeof str !== 'string') {
return false;
}
const filename = _.last(str.split('/'));
if (filename.indexOf('.') === -1) {
return false;
}
return true;
}
/**
*
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) =>
setTimeout(() => {
resolve();
}, ms)
);
}
/**
* url
*/
export function checkPathMatch(urlList: string[], url: string): boolean {
return urlList.includes(url.split('?')[0]);
}

@ -0,0 +1,104 @@
import { TcSocketIOService } from '../socketio.mixin';
import { io } from 'socket.io-client';
import ApiGateway from 'moleculer-web';
import { createTestUserToken } from '../../test/utils';
import { UserJWTPayload, TcBroker } from 'tailchat-server-sdk';
require('dotenv').config();
const PORT = 28193;
async function createAndEmitMessage(
eventName: string,
eventData: unknown = {}
): Promise<any> {
const socket = io(`http://localhost:${PORT}/`, {
transports: ['websocket'],
auth: {
token: createTestUserToken(),
},
});
await new Promise((resolve, reject) => {
socket.on('connect', () => {
resolve(null);
});
socket.on('connect_error', (err) => {
reject(err);
});
});
const res = await new Promise((resolve) => {
socket.emit(eventName, eventData, (ret) => {
resolve(ret);
});
});
socket.close();
return res;
}
describe('Testing "socketio.mixin"', () => {
const broker = new TcBroker({ logger: false });
const actionHandler1 = jest.fn();
const actionHandler2 = jest.fn();
const service = broker.createService({
name: 'test',
mixins: [
ApiGateway,
TcSocketIOService({
async userAuth(token): Promise<UserJWTPayload> {
return {
_id: 'any some',
nickname: '',
email: '',
avatar: '',
};
},
}),
],
settings: {
port: PORT,
},
actions: {
hello: actionHandler1,
publicAction: {
visibility: 'public',
handler: actionHandler2,
},
},
});
beforeAll(async () => {
await broker.start();
});
afterAll(async () => {
await broker.stop();
});
test('actions should be ok', () => {
expect(service.actions).toHaveProperty('joinRoom');
expect(service.actions).toHaveProperty('leaveRoom');
expect(service.actions).toHaveProperty('notify');
expect(service.actions).toHaveProperty('checkUserOnline');
});
test('socketio should be call action', async () => {
const res = await createAndEmitMessage('test.hello');
expect(actionHandler1.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(res).toEqual({ result: true });
});
test('socketio should not call non-published action', async () => {
const res = await createAndEmitMessage('test.publicAction');
expect(actionHandler2.mock.calls.length).toBe(0);
expect(res).toEqual({
result: false,
message: "Service 'test.publicAction' is not found.",
});
});
});

@ -0,0 +1,25 @@
import type { PureServiceSchema } from 'tailchat-server-sdk';
/**
*
*
* @deprecated 使 this.cleanActionCache
*/
export const TcCacheCleaner = (
eventNames: string[]
): Partial<PureServiceSchema> => {
const events = {};
eventNames.forEach((name) => {
events[name] = function () {
if (this.broker.cacher) {
this.logger.debug(`Clear local '${this.name}' cache`);
this.broker.cacher.clean(`${this.name}.**`);
}
};
});
return {
events,
};
};

@ -0,0 +1,26 @@
import type { PureContext, PureServiceSchema } from 'tailchat-server-sdk';
/**
* action
*
*/
export const TcHealth = (): Partial<PureServiceSchema> => {
return {
actions: {
async health(ctx: PureContext) {
const status = ctx.broker.getHealthStatus();
const services: any[] = await ctx.call('$node.services');
return {
nodeID: this.broker.nodeID,
cpu: status.cpu,
memory: status.mem,
services: services
.filter((s) => s.available === true)
.map((s) => s.fullName),
};
},
},
};
};

@ -0,0 +1,513 @@
import { Server as SocketServer } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { instrument } from '@moonrailgun/socket.io-admin-ui';
import RedisClient from 'ioredis';
import {
TcService,
TcContext,
UserJWTPayload,
parseLanguageFromHead,
config,
PureContext,
PureService,
PureServiceSchema,
Utils,
Errors,
} from 'tailchat-server-sdk';
import _ from 'lodash';
import { ServiceUnavailableError } from '../lib/errors';
import { generateRandomStr, isValidStr } from '../lib/utils';
import bcrypt from 'bcryptjs';
const blacklist: (string | RegExp)[] = ['gateway.*'];
function checkBlacklist(eventName: string): boolean {
return blacklist.some((item) => {
if (_.isString(item)) {
return Utils.match(eventName, item);
} else if (_.isRegExp(item)) {
return item.test(eventName);
}
});
}
/**
* socket
*/
function buildUserRoomId(userId: string) {
return `u-${userId}`;
}
/**
* socket online
*/
function buildUserOnlineKey(userId: string) {
return `tailchat-socketio.online:${userId}`;
}
const expiredTime = 1 * 24 * 60 * 60; // 1天
interface SocketIOService extends PureService {
io: SocketServer;
redis: RedisClient.Redis;
socketCloseCallbacks: (() => Promise<unknown>)[];
}
interface TcSocketIOServiceOptions {
/**
* token
*/
userAuth: (token: string) => Promise<UserJWTPayload>;
}
/**
* Socket IO mixin
*/
export const TcSocketIOService = (
options: TcSocketIOServiceOptions
): Partial<PureServiceSchema> => {
const { userAuth } = options;
const schema: Partial<PureServiceSchema> = {
created(this: SocketIOService) {
this.broker.metrics.register({
type: 'gauge',
name: 'tailchat.socketio.online.count',
labelNames: ['nodeId'],
description: 'Number of online user',
});
},
async started(this: SocketIOService) {
if (!this.io) {
this.initSocketIO();
}
this.logger.info('SocketIO 服务已启动');
const io: SocketServer = this.io;
if (!config.redisUrl) {
throw new Errors.MoleculerClientError(
'SocketIO服务启动失败, 需要环境变量: process.env.REDIS_URL'
);
}
this.socketCloseCallbacks = []; // socketio服务关闭时需要执行的回调
const pubClient = new RedisClient(config.redisUrl, {
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
const subClient = pubClient.duplicate();
io.adapter(
createAdapter(pubClient, subClient, {
key: 'tailchat-socket',
})
);
this.socketCloseCallbacks.push(async () => {
pubClient.disconnect(false);
subClient.disconnect(false);
});
this.logger.info('SocketIO 正在使用 Redis Adapter');
this.redis = pubClient;
io.use(async (socket, next) => {
// 授权
try {
if (
config.enableSocketAdmin &&
socket.handshake.headers['origin'] === 'https://admin.socket.io'
) {
// 如果是通过 admin-ui 访问的socket.io 直接链接
next();
return;
}
const token = socket.handshake.auth['token'];
if (typeof token !== 'string') {
throw new Errors.MoleculerError('Token不能为空');
}
const user: UserJWTPayload = await userAuth(token);
if (!(user && user._id)) {
throw new Error('Token不合规');
}
this.logger.info('[Socket] Authenticated via JWT: ', user.nickname);
socket.data.user = user;
socket.data.token = token;
socket.data.userId = user._id;
next();
} catch (e) {
return next(e);
}
});
this.io.on('connection', (socket) => {
if (typeof socket.data.userId !== 'string') {
// 不应该进入的逻辑
return;
}
this.broker.metrics.increment(
'tailchat.socketio.online.count',
{
nodeId: this.broker.nodeID,
},
1
);
const userId = socket.data.userId;
pubClient
.hset(buildUserOnlineKey(userId), socket.id, this.broker.nodeID)
.then(() => {
pubClient.expire(buildUserOnlineKey(userId), expiredTime);
});
// 加入自己userId所生产的id
socket.join(buildUserRoomId(userId));
/**
* 线线
*/
const removeOnlineMapping = () => {
return pubClient.hdel(buildUserOnlineKey(userId), socket.id);
};
this.socketCloseCallbacks.push(removeOnlineMapping);
// 用户断线
socket.on('disconnecting', (reason) => {
this.logger.info(
'Socket Disconnect:',
reason,
'| Rooms:',
socket.rooms
);
this.broker.metrics.decrement(
'tailchat.socketio.online.count',
{
nodeId: this.broker.nodeID,
},
1
);
removeOnlineMapping();
_.pull(this.socketCloseCallbacks, removeOnlineMapping);
});
// 连接时
socket.onAny(
async (
eventName: string,
eventData: unknown,
cb: (data: unknown) => void
) => {
this.logger.info(
'[SocketIO]',
eventName,
'<=',
JSON.stringify(eventData)
);
// 检测是否允许调用
if (checkBlacklist(eventName)) {
const message = '不允许的请求';
this.logger.warn('[SocketIO]', '=>', message);
cb({
result: false,
message,
});
return;
}
// 接受任意消息, 并调用action
try {
const endpoint = this.broker.findNextActionEndpoint(eventName);
if (endpoint instanceof Error) {
if (endpoint instanceof Errors.ServiceNotFoundError) {
throw new ServiceUnavailableError();
}
throw endpoint;
}
if (
typeof endpoint.action.visibility === 'string' &&
endpoint.action.visibility !== 'published'
) {
throw new Errors.ServiceNotFoundError({
visibility: endpoint.action.visibility,
action: eventName,
});
}
if (endpoint.action.disableSocket === true) {
throw new Errors.ServiceNotFoundError({
disableSocket: true,
action: eventName,
});
}
/**
* TODO:
* molecular
*
*/
const language = parseLanguageFromHead(
socket.handshake.headers['accept-language']
);
const data = await this.broker.call(eventName, eventData, {
meta: {
...socket.data,
socketId: socket.id,
language,
},
});
if (typeof cb === 'function') {
this.logger.debug(
'[SocketIO]',
eventName,
'=>',
JSON.stringify(data)
);
cb({ result: true, data });
}
} catch (err: unknown) {
const message = _.get(err, 'message', '服务器异常');
this.logger.debug('[SocketIO]', eventName, '=>', message);
this.logger.error('[SocketIO]', err);
cb({
result: false,
message,
});
}
}
);
});
},
async stopped(this: SocketIOService) {
if (this.io) {
this.io.close();
await Promise.all(this.socketCloseCallbacks.map((fn) => fn()));
}
this.logger.info('断开所有连接');
},
actions: {
joinRoom: {
visibility: 'public',
params: {
roomIds: 'array',
userId: [{ type: 'string', optional: true }], // 可选, 如果不填则为当前socket的id
},
async handler(
this: TcService,
ctx: TcContext<{ roomIds: string[]; userId?: string }>
) {
const roomIds = ctx.params.roomIds;
const userId = ctx.params.userId;
const searchId = isValidStr(userId)
? buildUserRoomId(userId)
: ctx.meta.socketId;
if (typeof searchId !== 'string') {
throw new Error('无法加入房间, 查询条件不合法, 请联系管理员');
}
if (!Array.isArray(roomIds)) {
throw new Error('无法加入房间, 参数必须为数组');
}
// 获取远程socket链接并加入
const io: SocketServer = this.io;
const remoteSockets = await io.in(searchId).fetchSockets();
if (remoteSockets.length === 0) {
this.logger.warn('无法加入房间, 无法找到当前socket链接:', searchId);
return;
}
remoteSockets.forEach((rs) =>
rs.join(
roomIds.map(String) // 强制确保roomId为字符串防止出现传个objectId类型的数据过来
)
);
},
},
leaveRoom: {
visibility: 'public',
params: {
roomIds: 'array',
userId: [{ type: 'string', optional: true }],
},
async handler(
this: TcService,
ctx: TcContext<{ roomIds: string[]; userId?: string }>
) {
const roomIds = ctx.params.roomIds;
const userId = ctx.params.userId;
const searchId = isValidStr(userId)
? buildUserRoomId(userId)
: ctx.meta.socketId;
if (typeof searchId !== 'string') {
this.logger.error('无法离开房间, 当前socket链接不存在');
return;
}
// 获取远程socket链接并离开
const io: SocketServer = this.io;
const remoteSockets = await io.in(searchId).fetchSockets();
if (remoteSockets.length === 0) {
this.logger.error('无法离开房间, 无法找到当前socket链接');
return;
}
remoteSockets.forEach((rs) => {
roomIds.forEach((roomId) => {
rs.leave(roomId);
});
});
},
},
/**
* userId
*/
fetchUserSocketIds: {
visibility: 'public',
params: {
userId: 'string',
},
async handler(
this: TcService,
ctx: TcContext<{ userId: string }>
): Promise<string[]> {
const userId = ctx.params.userId;
const io: SocketServer = this.io;
const remoteSockets = await io
.in(buildUserRoomId(userId))
.fetchSockets();
return remoteSockets.map((remoteSocket) => remoteSocket.id);
},
},
/**
*
*/
notify: {
visibility: 'public',
params: {
type: 'string',
target: [
{ type: 'string', optional: true },
{ type: 'array', optional: true },
],
eventName: 'string',
eventData: 'any',
},
handler(
this: TcService,
ctx: PureContext<{
type: string;
target: string | string[];
eventName: string;
eventData: any;
}>
) {
const { type, target, eventName, eventData } = ctx.params;
const io: SocketServer = this.io;
if (type === 'unicast' && typeof target === 'string') {
// 单播
io.to(buildUserRoomId(target)).emit(eventName, eventData);
} else if (type === 'listcast' && Array.isArray(target)) {
// 列播
io.to(target.map((t) => buildUserRoomId(t))).emit(
eventName,
eventData
);
} else if (type === 'roomcast') {
// 组播
io.to(target).emit(eventName, eventData);
} else if (type === 'broadcast') {
// 广播
io.emit(eventName, eventData);
} else {
this.logger.warn(
'[SocketIO]',
'Unknown notify type or target',
type,
target
);
}
},
},
/**
* 线
*/
checkUserOnline: {
params: {
userIds: 'array',
},
async handler(
this: TcService,
ctx: PureContext<{ userIds: string[] }>
) {
const userIds = ctx.params.userIds;
const status = await Promise.all(
userIds.map((userId) =>
(this.redis as RedisClient.Redis).exists(
buildUserOnlineKey(userId)
)
)
);
return status.map((d) => Boolean(d));
},
},
},
methods: {
initSocketIO() {
if (!this.server) {
throw new Errors.ServiceNotAvailableError(
'需要和 [moleculer-web] 一起使用'
);
}
this.io = new SocketServer(this.server, {
serveClient: false,
transports: ['websocket'],
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
});
if (config.enableSocketAdmin) {
const randomPassword = generateRandomStr(16);
this.logger.info('****************************************');
this.logger.info(
`检测到Admin管理已开启, 当前随机密码: ${randomPassword}`
);
this.logger.info('****************************************');
instrument(this.io, {
auth: {
type: 'basic',
username: 'tailchat-admin',
password: bcrypt.hashSync(randomPassword, 10),
},
});
}
},
},
};
return schema;
};

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

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

@ -0,0 +1,96 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
ReturnModelType,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import { Types } from 'mongoose';
import { NAME_REGEXP } from '../../lib/const';
import { User } from '../user/user';
/**
* : https://discord.com/developers/docs/resources/channel
*/
const converseType = [
'DM', // 私信
'Group', // 群组
] as const;
/**
*
*/
export class Converse extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop({
trim: true,
match: NAME_REGEXP,
})
name?: string;
/**
*
*/
@prop({
enum: converseType,
type: () => String,
})
type!: typeof converseType[number];
/**
*
* DM
*/
@prop({ ref: () => User })
members?: Ref<User>[];
/**
*
*/
static async findConverseWithMembers(
this: ReturnModelType<typeof Converse>,
members: string[]
): Promise<DocumentType<Converse> | null> {
const converse = await this.findOne({
members: {
$all: [...members],
},
});
return converse;
}
/**
*
*/
static async findAllJoinedConverseId(
this: ReturnModelType<typeof Converse>,
userId: string
): Promise<string[]> {
const conserves = await this.find(
{
members: new Types.ObjectId(userId),
},
{
_id: 1,
}
);
return conserves
.map((c) => c.id)
.filter(Boolean)
.map(String);
}
}
export type ConverseDocument = DocumentType<Converse>;
const model = getModelForClass(Converse);
export type ConverseModel = typeof model;
export default model;

@ -0,0 +1,47 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
index,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { User } from '../user/user';
import { Message } from './message';
/**
*
*/
@index({ userId: 1 })
export class Inbox extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop({
ref: () => Message,
})
messageId: Ref<Message>;
/**
* /
*/
@prop()
messageSnippet: string;
/**
* id
*/
@prop({
ref: () => User,
})
userId: Ref<User>;
}
export type InboxDocument = DocumentType<Inbox>;
const model = getModelForClass(Inbox);
export type InboxModel = typeof model;
export default model;

@ -0,0 +1,104 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
ReturnModelType,
modelOptions,
Severity,
} from '@typegoose/typegoose';
import { Group } from '../group/group';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import { Converse } from './converse';
import { User } from '../user/user';
import type { FilterQuery, Types } from 'mongoose';
import type { MessageMetaStruct } from 'tailchat-server-sdk';
class MessageReaction {
/**
*
* emoji
*/
@prop()
name: string;
@prop({ ref: () => User })
author?: Ref<User>;
}
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
export class Message extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop()
content: string;
@prop({ ref: () => User })
author?: Ref<User>;
@prop({ ref: () => Group })
groupId?: Ref<Group>;
/**
* ID
*
*/
@prop({ ref: () => Converse })
converseId!: Ref<Converse>;
@prop({ type: () => MessageReaction })
reactions?: MessageReaction[];
/**
*
*/
@prop({
default: false,
})
hasRecall: boolean;
/**
*
*/
@prop()
meta?: MessageMetaStruct;
/**
*
*/
static async fetchConverseMessage(
this: ReturnModelType<typeof Message>,
converseId: string,
startId: string | null,
limit = 50
) {
const conditions: FilterQuery<DocumentType<Message>> = {
converseId,
};
if (startId !== null) {
conditions['_id'] = {
$lt: startId,
};
}
const res = await this.find({ ...conditions })
.sort({ _id: -1 })
.limit(limit)
.exec();
return res;
}
}
export type MessageDocument = DocumentType<Message>;
const model = getModelForClass(Message);
export type MessageModel = typeof model;
export default model;

@ -0,0 +1,56 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
modelOptions,
Severity,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { User } from './user/user';
/**
*
*/
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
export class File extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop()
etag: string;
@prop({ ref: () => User })
userId?: Ref<User>;
@prop()
bucketName: string;
@prop()
objectName: string;
@prop()
url: string;
/**
* , : Byte
*/
@prop()
size: number;
@prop()
metaData: object;
}
export type FileDocument = DocumentType<File>;
const model = getModelForClass(File);
export type FileModel = typeof model;
export default model;

@ -0,0 +1,325 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
ReturnModelType,
modelOptions,
Severity,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import _ from 'lodash';
import { Types } from 'mongoose';
import { User } from '../user/user';
export enum GroupPanelType {
TEXT = 0,
GROUP = 1,
PLUGIN = 2,
}
class GroupMember {
@prop({
type: () => String,
})
roles?: string[]; // 角色权限组id
@prop({
ref: () => User,
})
userId: Ref<User>;
/**
* xxx
*/
@prop()
muteUntil?: Date;
}
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
export class GroupPanel {
@prop()
id: string; // 在群组中唯一, 可以用任意方式进行生成。这里使用ObjectId, 但不是ObjectId类型
@prop()
name: string; // 用于显示的名称
@prop()
parentId?: string; // 父节点id
@prop()
type: number; // 面板类型: Reference: https://discord.com/developers/docs/resources/channel#channel-object-channel-types
@prop()
provider?: string; // 面板提供者,为插件的标识,仅面板类型为插件时有效
@prop()
pluginPanelName?: string; // 插件面板名, 如 com.msgbyte.webview/grouppanel
/**
*
*/
@prop()
meta?: object;
}
/**
*
*/
export class GroupRole implements Base {
_id: Types.ObjectId;
id: string;
@prop()
name: string; // 权限组名
@prop({
type: () => String,
})
permissions: string[]; // 拥有的权限, 是一段字符串
}
/**
*
*/
export class Group extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop({
trim: true,
maxlength: [100, 'group name is too long'],
})
name!: string;
@prop()
avatar?: string;
@prop({
ref: () => User,
})
owner: Ref<User>;
@prop({ type: () => GroupMember, _id: false })
members: GroupMember[];
@prop({ type: () => GroupPanel, _id: false })
panels: GroupPanel[];
@prop({
type: () => GroupRole,
default: [],
})
roles?: GroupRole[];
/**
*
*
*/
@prop({
type: () => String,
default: () => [],
})
fallbackPermissions: string[];
/**
*
*/
static async createGroup(
this: ReturnModelType<typeof Group>,
options: {
name: string;
avatarBase64?: string; // base64版本的头像字符串
panels?: GroupPanel[];
owner: string;
}
): Promise<GroupDocument> {
const { name, avatarBase64, panels = [], owner } = options;
if (typeof avatarBase64 === 'string') {
// TODO: 处理头像上传逻辑
}
// 预处理panels信息, 变换ID为objectid
const panelSectionMap: Record<string, string> = {};
panels.forEach((panel) => {
const originPanelId = panel.id;
panel.id = String(new Types.ObjectId());
if (panel.type === GroupPanelType.GROUP) {
panelSectionMap[originPanelId] = panel.id;
}
if (typeof panel.parentId === 'string') {
if (typeof panelSectionMap[panel.parentId] !== 'string') {
throw new Error('创建失败, 面板参数不合法');
}
panel.parentId = panelSectionMap[panel.parentId];
}
});
// NOTE: Expression produces a union type that is too complex to represent.
const res = await this.create({
name,
panels,
owner,
members: [
{
roles: [],
userId: owner,
},
],
});
return res;
}
/**
*
* @param userId ID
*/
static async getUserGroups(
this: ReturnModelType<typeof Group>,
userId: string
): Promise<GroupDocument[]> {
return this.find({
'members.userId': userId,
});
}
/**
*
*/
static async updateGroupRoleName(
this: ReturnModelType<typeof Group>,
groupId: string,
roleId: string,
roleName: string,
operatorUserId: string
): Promise<Group> {
const group = await this.findById(groupId);
if (!group) {
throw new Error('Not Found Group');
}
// 首先判断是否有修改权限的权限
if (String(group.owner) !== operatorUserId) {
throw new Error('No Permission');
}
const modifyRole = group.roles.find((role) => String(role._id) === roleId);
if (!modifyRole) {
throw new Error('Not Found Role');
}
modifyRole.name = roleName;
await group.save();
return group;
}
/**
*
*/
static async updateGroupRolePermission(
this: ReturnModelType<typeof Group>,
groupId: string,
roleId: string,
permissions: string[],
operatorUserId: string
): Promise<Group> {
const group = await this.findById(groupId);
if (!group) {
throw new Error('Not Found Group');
}
// 首先判断是否有修改权限的权限
if (String(group.owner) !== operatorUserId) {
throw new Error('No Permission');
}
const modifyRole = group.roles.find((role) => String(role._id) === roleId);
if (!modifyRole) {
throw new Error('Not Found Role');
}
modifyRole.permissions = [...permissions];
await group.save();
return group;
}
/**
*
*/
static async getGroupUserPermission(
this: ReturnModelType<typeof Group>,
groupId: string,
userId: string
): Promise<string[]> {
const group = await this.findById(groupId);
if (!group) {
throw new Error('Not Found Group');
}
const member = group.members.find(
(member) => String(member.userId) === userId
);
if (!member) {
throw new Error('Not Found Member');
}
const allRoles = member.roles;
const allRolesPermission = allRoles.map((roleName) => {
const p = group.roles.find((r) => r.name === roleName);
return p?.permissions ?? [];
});
return _.union(...allRolesPermission, group.fallbackPermissions); // 权限取并集
}
/**
*
*
*
*/
static async updateGroupMemberField<K extends keyof GroupMember>(
this: ReturnModelType<typeof Group>,
groupId: string,
memberId: string,
fieldName: K,
fieldValue: GroupMember[K] | ((member: GroupMember) => void),
operatorUserId: string
): Promise<Group> {
const group = await this.findById(groupId);
if (String(group.owner) !== operatorUserId) {
throw new Error('没有操作权限');
}
const member = group.members.find((m) => String(m.userId) === memberId);
if (!member) {
throw new Error('没有找到该成员');
}
if (typeof fieldValue === 'function') {
fieldValue(member);
} else {
member[fieldName] = fieldValue;
}
await group.save();
return group;
}
}
export type GroupDocument = DocumentType<Group>;
const model = getModelForClass(Group);
export type GroupModel = typeof model;
export default model;

@ -0,0 +1,75 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
ReturnModelType,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import moment from 'moment';
import type { Types } from 'mongoose';
import { nanoid } from 'nanoid';
import { User } from '../user/user';
import { Group } from './group';
function generateCode() {
return nanoid(8);
}
export class GroupInvite extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop({
index: true,
default: () => generateCode(),
})
code!: string;
@prop({
ref: () => User,
})
creator: Ref<User>;
@prop({
ref: () => Group,
})
groupId!: Ref<Group>;
@prop()
expiredAt?: Date;
/**
*
* @param groupId id
* @param type (7)
*/
static async createGroupInvite(
this: ReturnModelType<typeof GroupInvite>,
groupId: string,
creator: string,
inviteType: 'normal' | 'permanent'
): Promise<GroupInviteDocument> {
let expiredAt = moment().add(7, 'day').toDate(); // 默认7天
if (inviteType === 'permanent') {
expiredAt = undefined;
}
const invite = await this.create({
groupId,
code: generateCode(),
creator,
expiredAt,
});
return invite;
}
}
export type GroupInviteDocument = DocumentType<GroupInvite>;
const model = getModelForClass(GroupInvite);
export type GroupInviteModel = typeof model;
export default model;

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

@ -0,0 +1,103 @@
import {
getModelForClass,
prop,
DocumentType,
index,
ReturnModelType,
Ref,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { User } from '../user/user';
const openAppCapability = [
'bot', // 机器人
'webpage', // 网页
'oauth', // 第三方登录
] as const;
type OpenAppCapability = typeof openAppCapability[number];
/**
*
*/
export function filterAvailableAppCapability(
input: string[]
): OpenAppCapability[] {
return input.filter((item) =>
openAppCapability.includes(item as OpenAppCapability)
) as OpenAppCapability[];
}
class OpenAppOAuth {
@prop({
type: () => String,
})
redirectUrls: string[];
}
/**
*
*/
@index({ appId: 1 }, { unique: true })
export class OpenApp extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop({
ref: () => User,
})
owner: Ref<User>;
@prop()
appId: string;
@prop()
appSecret: string;
@prop()
appName: string;
@prop()
appDesc: string;
@prop()
appIcon: string; // url
@prop({
enum: openAppCapability,
type: () => String,
})
capability: OpenAppCapability[];
@prop({
type: () => OpenAppOAuth,
})
oauth?: OpenAppOAuth;
/**
* appIdopenapp
* (secret)
* 便
*/
static async findAppByIdAndOwner(
this: ReturnModelType<typeof OpenApp>,
appId: string,
ownerId: string
) {
const res = await this.findOne({
appId,
owner: ownerId,
}).exec();
return res;
}
}
export type OpenAppDocument = DocumentType<OpenApp>;
const model = getModelForClass(OpenApp);
export type OpenAppModel = typeof model;
export default model;

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

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

@ -0,0 +1,61 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
plugin,
ReturnModelType,
} from '@typegoose/typegoose';
import { Base, FindOrCreate } from '@typegoose/typegoose/lib/defaultClasses';
import { User } from './user';
import findorcreate from 'mongoose-findorcreate';
import type { Types } from 'mongoose';
/**
*
*
*/
@plugin(findorcreate)
export class Friend extends FindOrCreate implements Base {
_id: Types.ObjectId;
id: string;
@prop({
ref: () => User,
index: true,
})
from: Ref<User>;
@prop({
ref: () => User,
})
to: Ref<User>;
@prop()
createdAt: Date;
static async buildFriendRelation(
this: ReturnModelType<FriendModel>,
user1: string,
user2: string
) {
await Promise.all([
this.findOrCreate({
from: user1,
to: user2,
}),
this.findOrCreate({
from: user2,
to: user1,
}),
]);
}
}
export type FriendDocument = DocumentType<Friend>;
const model = getModelForClass(Friend);
export type FriendModel = typeof model;
export default model;

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

@ -0,0 +1,200 @@
import {
getModelForClass,
prop,
DocumentType,
Ref,
modelOptions,
Severity,
ReturnModelType,
} from '@typegoose/typegoose';
import type { Base } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
import { User } from './user';
import nodemailer, { Transporter, SendMailOptions } from 'nodemailer';
import { parseConnectionUrl } from 'nodemailer/lib/shared';
import { config } from 'tailchat-server-sdk';
import type SMTPConnection from 'nodemailer/lib/smtp-connection';
/**
*
*/
function stringifyAddress(address: SendMailOptions['to']): string {
if (Array.isArray(address)) {
return address.map((a) => stringifyAddress(a)).join(',');
}
if (typeof address === 'string') {
return address;
} else if (address === undefined) {
return '';
} else if (typeof address === 'object') {
return `"${address.name}" ${address.address}`;
}
}
function getSMTPConnectionOptions(): SMTPConnection.Options | null {
if (config.smtp.connectionUrl) {
return parseConnectionUrl(config.smtp.connectionUrl);
}
return null;
}
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
export class Mail implements Base {
_id: Types.ObjectId;
id: string;
/**
* id
*/
@prop({
ref: () => User,
index: true,
})
userId: Ref<User>;
/**
*
*/
@prop()
from: string;
/**
*
*/
@prop()
to: string;
/**
*
*/
@prop()
subject: string;
/**
*
*/
@prop()
body: string;
@prop()
host?: string;
@prop()
port?: string;
@prop()
secure?: boolean;
@prop()
is_success: boolean;
@prop()
data?: any;
@prop()
error?: string;
/**
*
*/
static createMailerTransporter(): Transporter | null {
const options = getSMTPConnectionOptions();
if (options) {
const transporter = nodemailer.createTransport(options);
return transporter;
}
return null;
}
/**
*
*/
static async verifyMailService(): Promise<boolean> {
try {
const transporter = Mail.createMailerTransporter();
if (!transporter) {
return false;
}
const verify = await transporter.verify();
return verify;
} catch (e) {
console.error(e);
return false;
}
}
/**
*
*/
static async sendMail(
this: ReturnModelType<typeof Mail>,
mailOptions: SendMailOptions
) {
try {
const transporter = Mail.createMailerTransporter();
if (!transporter) {
throw new Error('Mail Transporter is null');
}
const options = {
from: config.smtp.senderName,
...mailOptions,
};
const smtpOptions = getSMTPConnectionOptions();
try {
const info = await transporter.sendMail(options);
await this.create({
from: stringifyAddress(options.from),
to: stringifyAddress(options.to),
subject: options.subject,
body: options.html,
host: smtpOptions.host,
port: smtpOptions.port,
secure: smtpOptions.secure,
is_success: true,
data: info,
});
return info;
} catch (err) {
this.create({
from: stringifyAddress(options.from),
to: stringifyAddress(options.to),
subject: options.subject,
body: options.html,
host: smtpOptions.host,
port: smtpOptions.port,
secure: smtpOptions.secure,
is_success: false,
error: String(err),
});
throw err;
}
} catch (err) {
console.error(err);
throw err;
}
}
}
export type MailDocument = DocumentType<Mail>;
const model = getModelForClass(Mail);
export type MailModel = typeof model;
export default model;

@ -0,0 +1,169 @@
import {
getModelForClass,
prop,
DocumentType,
ReturnModelType,
modelOptions,
Severity,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
type BaseUserInfo = Pick<User, 'nickname' | 'discriminator' | 'avatar'>;
const userType = ['normalUser', 'pluginBot', 'thirdpartyBot'];
type UserType = typeof userType[number];
/**
*
*/
export interface UserSettings {
/**
*
*/
messageListVirtualization?: boolean;
[key: string]: any;
}
export interface UserLoginRes extends User {
token: string;
}
@modelOptions({
options: {
allowMixed: Severity.ALLOW,
},
})
export class User extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
/**
*
* email
*/
@prop()
username?: string;
/**
*
*
*/
@prop({
index: true,
unique: true,
})
email: string;
@prop()
password!: string;
/**
*
*/
@prop({
trim: true,
maxlength: 20,
})
nickname!: string;
/**
* , username
*
* <username>#<discriminator>
*/
@prop()
discriminator: string;
/**
*
* @default false
*/
@prop({
default: false,
})
temporary: boolean;
/**
*
*/
@prop()
avatar?: string;
/**
*
*/
@prop({
enum: userType,
type: () => String,
default: 'normalUser',
})
type: UserType;
/**
*
*/
@prop({
default: {},
})
settings: UserSettings;
/**
*
* 0001 - 9999
*/
public static generateDiscriminator(
this: ReturnModelType<typeof User>,
nickname: string
): Promise<string> {
let restTimes = 10; // 最多找10次
const checkDiscriminator = async () => {
const discriminator = String(
Math.floor(Math.random() * 9999) + 1
).padStart(4, '0');
const doc = await this.findOne({
nickname,
discriminator,
}).exec();
restTimes--;
if (doc !== null) {
// 已存在, 换一个
if (restTimes <= 0) {
throw new Error('Cannot find space discriminator');
}
return checkDiscriminator();
}
return discriminator;
};
return checkDiscriminator();
}
/**
*
*/
static async getUserBaseInfo(
this: ReturnModelType<typeof User>,
userId: string
): Promise<BaseUserInfo> {
const user = await this.findById(String(userId));
return {
nickname: user.nickname,
discriminator: user.discriminator,
avatar: user.avatar,
};
}
}
export type UserDocument = DocumentType<User>;
const model = getModelForClass(User);
export type UserModel = typeof model;
export default model;

@ -0,0 +1,3 @@
import brokerConfig from 'tailchat-server-sdk/dist/runner/moleculer.config';
export default brokerConfig;

@ -0,0 +1,105 @@
{
"name": "tailchat-server",
"version": "1.0.2",
"main": "index.js",
"repository": "https://github.com/msgbyte/tailchat-server.git",
"author": "moonrailgun <moonrailgun@gmail.com>",
"license": "GPLv3",
"private": true,
"scripts": {
"dev": "ts-node ./runner.ts",
"debug": "node --inspect -r ts-node/register ./runner.ts",
"build": "ts-node scripts/build.ts",
"start:service": "cd dist && tailchat-runner --config moleculer.config.js",
"connect": "tailchat connect",
"translation": "node ./scripts/scanTranslation.js",
"check:type": "tsc --noEmit --skipLibCheck",
"test": "jest",
"migrate:up": "migrate-mongo up -f ./scripts/migrate-mongo-config.js",
"docker:build": "node ./scripts/buildDocker.js",
"dashboard": "ts-node ./scripts/dashboard.ts",
"plugin:install": "node ./scripts/installPlugin.js",
"protobuf": "proto-loader-gen-types --longs=String --enums=String --defaults --oneofs --grpcLib=@grpc/grpc-js --outDir=proto/ proto/*.proto"
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
"acorn",
"ts-jest",
"@typegoose/typegoose",
"moleculer"
]
}
},
"dependencies": {
"@moonrailgun/socket.io-admin-ui": "^0.2.1",
"@socket.io/redis-adapter": "^7.0.0",
"@typegoose/typegoose": "9.3.1",
"@types/blessed": "^0.1.19",
"@types/bluebird": "^3.5.36",
"bcryptjs": "^2.4.3",
"bluebird": "^3.7.2",
"crc": "^3.8.0",
"crypto-random-string": "3.3.1",
"dotenv": "^10.0.0",
"ejs": "^3.1.6",
"es6-error": "^4.1.1",
"execa": "5",
"got": "11.8.3",
"i18next": "^20.3.5",
"ioredis": "^4.27.6",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.21",
"mime": "^2.5.2",
"mkdirp": "^1.0.4",
"moleculer-minio": "^2.0.0",
"moleculer-web": "^0.10.4",
"moment": "^2.29.1",
"mongodb": "4.2.1",
"mongoose": "6.1.1",
"mongoose-findorcreate": "3.0.0",
"nanoid": "^3.1.23",
"nodemailer": "^6.7.2",
"oidc-provider": "^7.10.6",
"qs": "^6.10.3",
"redlock": "^4.2.0",
"socket.io": "^4.2.0",
"tailchat-server-sdk": "workspace:*",
"ts-node": "^10.0.0",
"typescript": "^4.3.3"
},
"devDependencies": {
"@babel/helper-compilation-targets": "^7.18.2",
"@types/bcryptjs": "^2.4.2",
"@types/crc": "^3.4.0",
"@types/ejs": "^3.1.0",
"@types/fs-extra": "^9.0.13",
"@types/i18next-fs-backend": "^1.0.1",
"@types/inquirer": "^8.2.1",
"@types/ioredis": "^4.26.4",
"@types/jest": "^26.0.23",
"@types/jsonwebtoken": "^8.5.2",
"@types/lodash": "^4.14.170",
"@types/mime": "^2.0.3",
"@types/minio": "^7.0.9",
"@types/mkdirp": "^1.0.1",
"@types/mongoose": "^5.11.97",
"@types/node": "16.11.7",
"@types/nodemailer": "^6.4.4",
"@types/oidc-provider": "^7.8.1",
"fs-extra": "^10.0.0",
"gulp-sort": "^2.0.0",
"i18next-scanner": "^3.0.0",
"inquirer": "^8.2.2",
"jest": "^27.0.6",
"mini-star": "^1.2.8",
"moleculer-cli": "^0.7.1",
"moleculer-repl": "^0.6.5",
"neo-blessed": "^0.2.0",
"ora": "5",
"prettier": "^2.3.2",
"socket.io-client": "^4.1.3",
"ts-jest": "^27.0.3",
"vinyl-fs": "^3.0.3"
}
}

@ -0,0 +1,52 @@
{
"name": "tailchat-server-sdk",
"version": "0.0.12",
"description": "",
"main": "dist/index.js",
"bin": {
"tailchat-runner": "./dist/runner/cli.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "echo \"Error: no test specified\" && exit 1",
"prepare": "npm run build",
"release": "npm version patch && npm publish --registry https://registry.npmjs.com/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/msgbyte/tailchat-server.git"
},
"keywords": [
"msgbyte",
"moonrailgun",
"tailchat"
],
"author": "moonrailgun <moonrailgun@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/msgbyte/tailchat-server/issues"
},
"homepage": "https://github.com/msgbyte/tailchat-server#readme",
"devDependencies": {
"typescript": "^4.3.3"
},
"dependencies": {
"@typegoose/typegoose": "9.3.1",
"accept-language": "^3.0.18",
"crc": "^3.8.0",
"dotenv": "^10.0.0",
"fastest-validator": "^1.12.0",
"i18next": "^20.3.5",
"i18next-fs-backend": "^1.1.1",
"ioredis": "^4.27.6",
"kleur": "^4.1.4",
"lodash": "^4.17.21",
"moleculer": "0.14.18",
"moleculer-db": "0.8.16",
"moleculer-repl": "^0.6.5",
"moment": "^2.29.1",
"mongodb": "4.2.1",
"mongoose": "6.1.1"
}
}

@ -0,0 +1,2 @@
export * from './typegoose';
export * from './mongoose';

@ -0,0 +1 @@
export { Types } from 'mongoose';

@ -0,0 +1,9 @@
export {
getModelForClass,
prop,
DocumentType,
Ref,
modelOptions,
Severity,
} from '@typegoose/typegoose';
export { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';

@ -0,0 +1,47 @@
export { TcService } from './services/base';
export { TcBroker } from './services/broker';
export type { TcDbService } from './services/mixins/db.mixin';
export type {
TcContext,
TcPureContext,
PureContext,
UserJWTPayload,
GroupBaseInfo,
PureServiceSchema,
PureService,
} from './services/types';
export { parseLanguageFromHead } from './services/lib/i18n/parser';
export { t } from './services/lib/i18n';
export {
config,
buildUploadUrl,
builtinAuthWhitelist,
checkEnvTrusty,
} from './services/lib/settings';
// struct
export type { MessageMetaStruct } from './structs/chat';
export type { BuiltinEventMap } from './structs/events';
export type {
GroupStruct,
GroupRoleStruct,
GroupPanelStruct,
} from './structs/group';
export type { UserStruct } from './structs/user';
// db
export * as db from './db';
// other
export { Utils, Errors } from 'moleculer';
/**
*
* NOTICE:
*/
process.on('unhandledRejection', (reason, promise) => {
console.error('unhandledRejection', reason);
});
process.on('uncaughtException', (error, origin) => {
console.error('uncaughtException', error);
});

@ -0,0 +1,4 @@
import { Runner } from 'moleculer';
const runner = new Runner();
runner.start(process.argv);

@ -0,0 +1,55 @@
import { Runner } from 'moleculer';
import path from 'path';
import cluster from 'cluster';
import { config } from '../services/lib/settings';
declare module 'moleculer' {
class Runner {
flags?: {
config?: string;
repl?: boolean;
hot?: boolean;
silent?: boolean;
env?: boolean;
envfile?: string;
instances?: number;
mask?: string;
};
servicePaths: string[];
start(args: any[]): void;
startWorkers(instances: number): void;
_run(): void;
}
}
interface DevRunnerOptions {
config?: string;
}
const isProd = config.env === 'production';
/**
*
*/
export function startDevRunner(options: DevRunnerOptions) {
const runner = new Runner();
runner.flags = {
hot: isProd ? false : true,
repl: isProd ? false : true,
env: true,
config: options.config ?? path.resolve(__dirname, './moleculer.config.ts'),
};
runner.servicePaths = [
'services/**/*.service.ts',
'services/**/*.service.dev.ts', // load plugins in dev mode
'plugins/**/*.service.ts',
'plugins/**/*.service.dev.ts', // load plugins in dev mode
];
if (runner.flags.instances !== undefined && cluster.isPrimary) {
return runner.startWorkers(runner.flags.instances);
}
return runner._run();
}

@ -0,0 +1,311 @@
'use strict';
import type {
BrokerOptions,
CallingOptions,
Errors,
MetricRegistry,
ServiceBroker,
} from 'moleculer';
import type { UserJWTPayload } from '../services/types';
import moment from 'moment';
import kleur from 'kleur';
import { config } from '../services/lib/settings';
/**
* Moleculer ServiceBroker configuration file
*
* More info about options:
* https://moleculer.services/docs/0.14/configuration.html
*
*
* Overwriting options in production:
* ================================
* You can overwrite any option with environment variables.
* For example to overwrite the "logLevel" value, use `LOGLEVEL=warn` env var.
* To overwrite a nested parameter, e.g. retryPolicy.retries, use `RETRYPOLICY_RETRIES=10` env var.
*
* To overwrite brokers deeply nested default options, which are not presented in "moleculer.config.js",
* use the `MOL_` prefix and double underscore `__` for nested properties in .env file.
* For example, to set the cacher prefix to `MYCACHE`, you should declare an env var as `MOL_CACHER__OPTIONS__PREFIX=mycache`.
* It will set this:
* {
* cacher: {
* options: {
* prefix: "mycache"
* }
* }
* }
*/
const brokerConfig: BrokerOptions = {
// Namespace of nodes to segment your nodes on the same network.
namespace: 'tailchat',
// Unique node identifier. Must be unique in a namespace.
nodeID: undefined,
// Custom metadata store. Store here what you want. Accessing: `this.broker.metadata`
metadata: {},
// Enable/disable logging or use custom logger. More info: https://moleculer.services/docs/0.14/logging.html
// Available logger types: "Console", "File", "Pino", "Winston", "Bunyan", "debug", "Log4js", "Datadog"
logger: [
{
type: 'Console',
options: {
// Using colors on the output
colors: true,
// Print module names with different colors (like docker-compose for containers)
moduleColors: false,
// Line formatter. It can be "json", "short", "simple", "full", a `Function` or a template string like "{timestamp} {level} {nodeID}/{mod}: {msg}"
// formatter: 'full',
formatter(type, args, bindings, { printArgs }) {
return [
kleur.grey(`[${moment().format('YYYY-MM-DD HH:mm:ss')}]`),
`${this.levelColorStr[type]}`,
...printArgs(args),
];
},
// Custom object printer. If not defined, it uses the `util.inspect` method.
objectPrinter: null,
// Auto-padding the module name in order to messages begin at the same column.
autoPadding: false,
},
},
{
type: 'File',
options: {
level: {
GATEWAY: 'debug',
'**': false,
},
filename: 'gateway-{nodeID}.log',
},
},
{
type: 'File',
options: {
level: {
GATEWAY: false,
'**': 'debug',
},
filename: '{date}-{nodeID}.log',
},
},
],
// Default log level for built-in console logger. It can be overwritten in logger options above.
// Available values: trace, debug, info, warn, error, fatal
logLevel: 'info',
// Define transporter.
// More info: https://moleculer.services/docs/0.14/networking.html
// Note: During the development, you don't need to define it because all services will be loaded locally.
// In production you can set it via `TRANSPORTER=nats://localhost:4222` environment variable.
transporter: undefined, // "process.env.TRANSPORTER"
// Define a cacher.
// More info: https://moleculer.services/docs/0.14/caching.html
cacher: {
type: 'Redis',
options: {
// Prefix for keys
prefix: 'TC',
// Redis settings
redis: config.redisUrl,
},
},
// Define a serializer.
// Available values: "JSON", "Avro", "ProtoBuf", "MsgPack", "Notepack", "Thrift".
// More info: https://moleculer.services/docs/0.14/networking.html#Serialization
serializer: 'JSON',
// Number of milliseconds to wait before reject a request with a RequestTimeout error. Disabled: 0
requestTimeout: 10 * 1000,
// Retry policy settings. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Retry
retryPolicy: {
// Enable feature
enabled: false,
// Count of retries
retries: 5,
// First delay in milliseconds.
delay: 100,
// Maximum delay in milliseconds.
maxDelay: 1000,
// Backoff factor for delay. 2 means exponential backoff.
factor: 2,
// A function to check failed requests.
check: ((err: Errors.MoleculerError) => err && !!err.retryable) as any,
},
// Limit of calling level. If it reaches the limit, broker will throw an MaxCallLevelError error. (Infinite loop protection)
maxCallLevel: 100,
// Number of seconds to send heartbeat packet to other nodes.
heartbeatInterval: 10,
// Number of seconds to wait before setting node to unavailable status.
heartbeatTimeout: 30,
// Cloning the params of context if enabled. High performance impact, use it with caution!
contextParamsCloning: false,
// Tracking requests and waiting for running requests before shuting down. More info: https://moleculer.services/docs/0.14/context.html#Context-tracking
tracking: {
// Enable feature
enabled: false,
// Number of milliseconds to wait before shuting down the process.
shutdownTimeout: 5000,
},
// Disable built-in request & emit balancer. (Transporter must support it, as well.). More info: https://moleculer.services/docs/0.14/networking.html#Disabled-balancer
disableBalancer: false,
// Settings of Service Registry. More info: https://moleculer.services/docs/0.14/registry.html
registry: {
// Define balancing strategy. More info: https://moleculer.services/docs/0.14/balancing.html
// Available values: "RoundRobin", "Random", "CpuUsage", "Latency", "Shard"
strategy: 'RoundRobin',
// Enable local action call preferring. Always call the local action instance if available.
preferLocal: true,
},
// Settings of Circuit Breaker. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Circuit-Breaker
circuitBreaker: {
// Enable feature
enabled: false,
// Threshold value. 0.5 means that 50% should be failed for tripping.
threshold: 0.5,
// Minimum request count. Below it, CB does not trip.
minRequestCount: 20,
// Number of seconds for time window.
windowTime: 60,
// Number of milliseconds to switch from open to half-open state
halfOpenTime: 10 * 1000,
// A function to check failed requests.
check: ((err: Errors.MoleculerError) => err && err.code >= 500) as any,
},
// Settings of bulkhead feature. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Bulkhead
bulkhead: {
// Enable feature.
enabled: false,
// Maximum concurrent executions.
concurrency: 10,
// Maximum size of queue
maxQueueSize: 100,
},
// Enable action & event parameter validation. More info: https://moleculer.services/docs/0.14/validating.html
validator: true,
errorHandler: undefined,
// Enable/disable built-in metrics function. More info: https://moleculer.services/docs/0.14/metrics.html
metrics: {
enabled: config.enablePrometheus,
// Available built-in reporters: "Console", "CSV", "Event", "Prometheus", "Datadog", "StatsD"
reporter: [
{
type: 'Prometheus',
options: {
// HTTP port
port: 13030,
// HTTP URL path
path: '/metrics',
// Default labels which are appended to all metrics labels
defaultLabels: (registry) => ({
namespace: registry.broker.namespace,
nodeID: registry.broker.nodeID,
}),
},
},
],
},
// Enable built-in tracing function. More info: https://moleculer.services/docs/0.14/tracing.html
tracing: {
enabled: true,
// Available built-in exporters: "Console", "Datadog", "Event", "EventLegacy", "Jaeger", "Zipkin"
exporter: {
type: 'Console', // Console exporter is only for development!
options: {
// Custom logger
logger: null,
// Using colors
colors: true,
// Width of row
width: 100,
// Gauge width in the row
gaugeWidth: 40,
},
},
},
// Register custom middlewares
middlewares: [],
// Register custom REPL commands.
// Reference: https://moleculer.services/docs/0.14/moleculer-repl.html#Custom-commands
replDelimiter: 'tc $',
replCommands: [
{
// NOTICE: 这个方法不要在原始上下文中使用,会造成其他用户使用不正常(登录成功后会拦截所有的call函数)
command: 'login',
description: 'Auto login or register tailchat user for cli test',
options: [{ option: '-u, --username', description: 'Username' }],
action(broker: ServiceBroker, args) {
const username = args.options.username ?? 'localtest';
const password = 'localtest';
console.log(`开始尝试登录测试账号 ${username}`);
return broker
.call('user.login', { username, password })
.catch((err) => {
if (err.name === 'EntityError') {
// 未注册
console.log('正在注册新的测试账号');
return broker.call('user.register', { username, password });
}
console.error('未知的错误:', err);
})
.then((user: any) => {
const token = user.token;
const userId = user._id;
const originCall = broker.call.bind(broker);
console.log('登录成功');
console.log('> userId:', userId);
console.log('> token:', token);
(broker.call as any) = function (
actionName: string,
params: unknown,
opts?: CallingOptions
): Promise<unknown> {
return originCall(actionName, params, {
...opts,
meta: {
...(opts.meta ?? {}),
user: {
_id: userId,
nickname: user.nickname,
email: user.email,
avatar: user.avatar,
} as UserJWTPayload,
token,
userId,
},
});
};
});
},
},
],
/*
// Called after broker created.
created : (broker: ServiceBroker): void => {},
// Called after broker started.
started: async (broker: ServiceBroker): Promise<void> => {},
stopped: async (broker: ServiceBroker): Promise<void> => {},
*/
};
export default brokerConfig;

@ -0,0 +1,378 @@
import {
ActionSchema,
CallingOptions,
Context,
LoggerInstance,
Service,
ServiceBroker,
ServiceDependency,
ServiceEvent,
ServiceEventHandler,
ServiceSchema,
WaitForServicesResult,
} from 'moleculer';
import { once } from 'lodash';
import { TcDbService } from './mixins/db.mixin';
import type { TcContext, TcPureContext } from './types';
import type { TFunction } from 'i18next';
import { t } from './lib/i18n';
import type { ValidationRuleObject } from 'fastest-validator';
import type { BuiltinEventMap } from '../structs/events';
type ServiceActionHandler<T = any> = (
ctx: TcPureContext<any>
) => Promise<T> | T;
type ShortValidationRule =
| 'any'
| 'array'
| 'boolean'
| 'custom'
| 'date'
| 'email'
| 'enum'
| 'forbidden'
| 'function'
| 'number'
| 'object'
| 'string'
| 'url'
| 'uuid';
type ServiceActionSchema = Pick<
ActionSchema,
| 'name'
| 'rest'
| 'visibility'
| 'service'
| 'cache'
| 'tracing'
| 'bulkhead'
| 'circuitBreaker'
| 'retryPolicy'
| 'fallback'
| 'hooks'
> & {
params?: Record<
string,
ValidationRuleObject | ValidationRuleObject[] | ShortValidationRule
>;
disableSocket?: boolean;
};
interface TcServiceBroker extends ServiceBroker {
// 事件类型重写
emit<K extends string>(
eventName: K,
data: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
groups?: string | string[]
): Promise<void>;
emit(eventName: string): Promise<void>;
}
/**
* TcService
*/
export interface TcService extends Service {
broker: TcServiceBroker;
}
export abstract class TcService extends Service {
/**
* ,
*/
abstract get serviceName(): string;
private _mixins: ServiceSchema['mixins'] = [];
private _actions: ServiceSchema['actions'] = {};
private _methods: ServiceSchema['methods'] = {};
private _settings: ServiceSchema['settings'] = {};
private _events: ServiceSchema['events'] = {};
private _generateAndParseSchema() {
this.parseServiceSchema({
name: this.serviceName,
mixins: this._mixins,
settings: this._settings,
actions: this._actions,
events: this._events,
started: this.onStart,
stopped: this.onStop,
});
}
constructor(broker: ServiceBroker) {
super(broker); // Skip 父级的 parseServiceSchema 方法
this.onInit(); // 初始化服务
this._generateAndParseSchema();
this.logger = this.buildLoggerWithPrefix(this.logger);
this.onInited(); // 初始化完毕
}
protected abstract onInit(): void;
protected onInited() {}
protected async onStart() {}
protected async onStop() {}
registerMixin(mixin: Partial<ServiceSchema>): void {
this._mixins.push(mixin);
}
/**
*
*
*/
registerLocalDb = once((model) => {
this.registerMixin(TcDbService(model));
});
/**
*
* @param fields
*/
registerDbField(fields: string[]) {
this.registerSetting('fields', fields);
}
/**
*
*
* httpsocketio
* @param name ,
* @param handler
* @returns
*/
registerAction(
name: string,
handler: ServiceActionHandler,
schema?: ServiceActionSchema
) {
if (this._actions[name]) {
this.logger.warn(`重复注册操作: ${name}。操作被跳过...`);
return;
}
this._actions[name] = {
...schema,
handler(
this: Service,
ctx: Context<unknown, { language: string; t: TFunction }>
) {
// 调用时生成t函数
ctx.meta.t = (key: string, defaultValue?: string) =>
t(key, defaultValue, {
lng: ctx.meta.language,
});
return handler.call(this, ctx);
},
};
}
/**
*
*/
registerMethod(name: string, method: (...args: any[]) => any) {
if (this._methods[name]) {
this.logger.warn(`重复注册方法: ${name}。操作被跳过...`);
return;
}
this._methods[name] = method;
}
/**
*
*/
registerSetting(key: string, value: unknown): void {
if (this._settings[key]) {
this.logger.warn(`重复注册配置: ${key}。之前的设置将被覆盖...`);
}
this._settings[key] = value;
}
/**
*
*/
registerEventListener<K extends string>(
eventName: K,
handler: (
payload: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
ctx: TcPureContext
) => void,
options: Omit<ServiceEvent, 'handler'> = {}
) {
this._events[eventName] = {
...options,
handler: (ctx: TcPureContext<any>) => {
handler(ctx.params, ctx);
},
};
}
/**
* token
* @param urls
* @example "/user/login"
*/
registerAuthWhitelist(urls: string[]) {
this.waitForServices('gateway').then(() => {
this.broker.broadcast(
'gateway.auth.addWhitelists',
{
urls,
},
'gateway'
);
});
}
/**
*
* @param serviceNames
* @param timeout
* @param interval
* @param logger
* @returns
*/
waitForServices(
serviceNames: string | Array<string> | Array<ServiceDependency>,
timeout?: number,
interval?: number,
logger?: LoggerInstance
): Promise<WaitForServicesResult> {
if (process.env.NODE_ENV === 'test') {
// 测试环境中跳过
return Promise.resolve({
services: [],
statuses: [],
});
}
return super.waitForServices(serviceNames, timeout, interval, logger);
}
/**
* action
* NOTICE: 使Redisservice
*/
async cleanActionCache(actionName: string, keys: string[] = []) {
await this.broker.cacher.clean(
`${this.serviceName}.${actionName}:${keys.join('|')}**`
);
}
/**
*
*/
protected generateNotifyEventName(eventName: string) {
return `notify:${this.serviceName}.${eventName}`;
}
/**
*
* @param actionName serverName
*/
protected localCall(
actionName: string,
params?: {},
opts?: CallingOptions
): Promise<any> {
return this.actions[actionName](params, opts);
}
private buildLoggerWithPrefix(_originLogger: LoggerInstance) {
const prefix = `[${this.serviceName}]`;
const originLogger = _originLogger;
return {
info: (...args: any[]) => {
originLogger.info(prefix, ...args);
},
fatal: (...args: any[]) => {
originLogger.fatal(prefix, ...args);
},
error: (...args: any[]) => {
originLogger.error(prefix, ...args);
},
warn: (...args: any[]) => {
originLogger.warn(prefix, ...args);
},
debug: (...args: any[]) => {
originLogger.debug(prefix, ...args);
},
trace: (...args: any[]) => {
originLogger.trace(prefix, ...args);
},
};
}
/**
* socket
*/
unicastNotify(
ctx: TcContext,
userId: string,
eventName: string,
eventData: unknown
): Promise<void> {
return ctx.call('gateway.notify', {
type: 'unicast',
target: userId,
eventName: this.generateNotifyEventName(eventName),
eventData,
});
}
/**
* socket
*/
listcastNotify(
ctx: TcContext,
userIds: string[],
eventName: string,
eventData: unknown
) {
return ctx.call('gateway.notify', {
type: 'listcast',
target: userIds,
eventName: this.generateNotifyEventName(eventName),
eventData,
});
}
/**
* socket
*/
roomcastNotify(
ctx: TcContext,
roomId: string,
eventName: string,
eventData: unknown
): Promise<void> {
return ctx.call('gateway.notify', {
type: 'roomcast',
target: roomId,
eventName: this.generateNotifyEventName(eventName),
eventData,
});
}
/**
* socket
*/
broadcastNotify(
ctx: TcContext,
eventName: string,
eventData: unknown
): Promise<void> {
return ctx.call('gateway.notify', {
type: 'broadcast',
eventName: this.generateNotifyEventName(eventName),
eventData,
});
}
}

@ -0,0 +1,8 @@
import Moleculer from 'moleculer';
/**
* moleculerbroker
*
* tailchat-cli
*/
export class TcBroker extends Moleculer.ServiceBroker {}

@ -0,0 +1,24 @@
import { t } from '../index';
/**
*
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) =>
setTimeout(() => {
resolve();
}, ms)
);
}
describe('i18n', () => {
test('should be work', async () => {
await sleep(2000); // 等待异步加载完毕
expect(t('Token不合规')).toBe('Token不合规');
expect(
t('Token不合规', undefined, {
lng: 'en-US',
})
).toBe('Token Invalid');
});
});

@ -0,0 +1,23 @@
import { parseLanguageFromHead } from '../parser';
describe('parseLanguageFromHead', () => {
test.each([
// zh
['zh-CN,zh;q=0.9', 'zh-CN'],
['zh-TW,zh;q=0.9', 'zh-CN'],
['zh;q=0.9', 'zh-CN'],
['zh', 'zh-CN'],
// en
['en-US,en;q=0.8,sv', 'en-US'],
['en-GB,en;q=0.8,sv', 'en-US'],
['en;q=0.8,sv', 'en-US'],
['en', 'en-US'],
// other
['de-CH;q=0.8,sv', 'en-US'],
['jp', 'en-US'],
])('%s', (input, output) => {
expect(parseLanguageFromHead(input)).toBe(output);
});
});

@ -0,0 +1,41 @@
import i18next, { TFunction, TOptionsBase } from 'i18next';
import Backend from 'i18next-fs-backend';
import path from 'path';
import { crc32 } from 'crc';
i18next.use(Backend).init({
// initImmediate: false,
lng: 'zh-CN',
fallbackLng: 'zh-CN',
preload: ['zh-CN', 'en-US'],
ns: ['translation'],
defaultNS: 'translation',
backend: {
/**
*
*/
loadPath: path.resolve(process.cwd(), './locales/{{lng}}/{{ns}}.json'),
},
});
/**
*
*/
export const t: TFunction = (
key: string,
defaultValue?: string,
options?: TOptionsBase
) => {
try {
const hashKey = `k${crc32(key).toString(16)}`;
let words = i18next.t(hashKey, defaultValue, options);
if (words === hashKey) {
words = key;
console.info(`[i18n] 翻译缺失: [${hashKey}]${key}`);
}
return words;
} catch (err) {
console.error(err);
return key;
}
};

@ -0,0 +1,19 @@
import acceptLanguage from 'accept-language';
type AllowedLanguage = 'en-US' | 'zh-CN';
acceptLanguage.languages(['en-US', 'zh-CN', 'zh', 'zh-TW']);
/**
* Accept-Language
*/
export function parseLanguageFromHead(
headerLanguage: string = 'zh-CN'
): AllowedLanguage {
const language = acceptLanguage.get(headerLanguage);
if (language === 'zh' || language === 'zh-TW') {
return 'zh-CN';
}
return language as AllowedLanguage;
}

@ -0,0 +1,450 @@
/*
* moleculer-db-adapter-mongoose
* Copyright (c) 2019 MoleculerJS (https://github.com/moleculerjs/moleculer-db)
* MIT Licensed
*/
'use strict';
import _ from 'lodash';
import { Errors, Service, ServiceBroker } from 'moleculer';
import type { DbAdapter } from 'moleculer-db';
import type { Db } from 'mongodb';
import mongoose, {
ConnectOptions,
Model,
Schema,
Document,
Connection,
} from 'mongoose';
const ServiceSchemaError = Errors.ServiceSchemaError;
export class MongooseDbAdapter<TDocument extends Document>
implements DbAdapter
{
uri: string;
opts?: ConnectOptions;
broker: ServiceBroker;
service: Service;
model: Model<TDocument>;
schema?: Schema;
modelName?: string;
db: Db;
conn: Connection;
/**
* Creates an instance of MongooseDbAdapter.
* @param {String} uri
* @param {Object?} opts
*
* @memberof MongooseDbAdapter
*/
constructor(uri: string, opts) {
(this.uri = uri), (this.opts = opts);
mongoose.Promise = Promise;
}
/**
* Initialize adapter
*
* @param {ServiceBroker} broker
* @param {Service} service
*
* @memberof MongooseDbAdapter
*/
init(broker, service) {
this.broker = broker;
this.service = service;
if (this.service.schema.model) {
this.model = this.service.schema.model;
} else if (this.service.schema.schema) {
if (!this.service.schema.modelName) {
throw new ServiceSchemaError(
'`modelName` is required when `schema` is given in schema of service!',
{}
);
}
this.schema = this.service.schema.schema;
this.modelName = this.service.schema.modelName;
}
if (!this.model && !this.schema) {
/* istanbul ignore next */
throw new ServiceSchemaError(
'Missing `model` or `schema` definition in schema of service!',
{}
);
}
}
/**
* Connect to database
*
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
connect() {
let conn: Promise<Connection>;
if (this.model) {
/* istanbul ignore next */
if (mongoose.connection.readyState == 1) {
this.conn = mongoose.connection;
this.db = this.conn.db;
return Promise.resolve();
} else if (mongoose.connection.readyState == 2) {
conn = mongoose.connection.asPromise();
} else {
conn = mongoose.connect(this.uri, this.opts).then((m) => m.connection);
}
} else if (this.schema) {
conn = mongoose
.createConnection(this.uri, this.opts)
.asPromise()
.then((conn) => {
this.model = conn.model(this.modelName, this.schema);
return conn;
});
}
return conn.then((_conn: Connection) => {
this.conn = _conn;
this.db = _conn.db;
this.service.logger.info('MongoDB adapter has connected successfully.');
/* istanbul ignore next */
this.conn.on('disconnected', () =>
this.service.logger.warn('Mongoose adapter has disconnected.')
);
this.conn.on('error', (err) =>
this.service.logger.error('MongoDB error.', err)
);
this.conn.on('reconnect', () =>
this.service.logger.info('Mongoose adapter has reconnected.')
);
});
}
/**
* Disconnect from database
*
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
disconnect() {
return new Promise<void>((resolve) => {
if (this.conn && this.conn.close) {
this.conn.close(() => {
resolve();
});
} else {
mongoose.connection.close(() => {
resolve();
});
}
});
}
/**
* Find all entities by filters.
*
* Available filter props:
* - limit
* - offset
* - sort
* - search
* - searchFields
* - query
*
* @param {any} filters
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
find(filters) {
return this.createCursor(filters).exec();
}
/**
* Find an entity by query
*
* @param {Object} query
* @returns {Promise}
* @memberof MemoryDbAdapter
*/
findOne(query) {
return this.model.findOne(query).exec();
}
/**
* Find an entities by ID
*
* @param {any} _id
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
findById(_id) {
return this.model.findById(_id).exec();
}
/**
* Find any entities by IDs
*
* @param {Array} idList
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
findByIds(idList) {
return this.model
.find({
_id: {
$in: idList,
},
})
.exec();
}
/**
* Get count of filtered entites
*
* Available filter props:
* - search
* - searchFields
* - query
*
* @param {Object} [filters={}]
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
count(filters = {}) {
return this.createCursor(filters).countDocuments().exec();
}
/**
* Insert an entity
*
* @param {Object} entity
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
insert(entity): any {
const item = new this.model(entity);
return item.save();
}
/**
* Insert many entities
*
* @param {Array} entities
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
insertMany(entities): any {
return this.model.create(entities);
}
/**
* Update many entities by `query` and `update`
*
* @param {Object} query
* @param {Object} update
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
updateMany(query, update) {
return this.model
.updateMany(query, update, { multi: true, new: true })
.then((res) => res.matchedCount);
}
/**
* Update an entity by ID and `update`
*
* @param {any} _id
* @param {Object} update
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
updateById(_id, update): any {
return this.model.findByIdAndUpdate(_id, update, { new: true });
}
/**
* Remove entities which are matched by `query`
*
* @param {Object} query
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
removeMany(query) {
return this.model.deleteMany(query).then((res) => res.deletedCount);
}
/**
* Remove an entity by ID
*
* @param {any} _id
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
removeById(_id): any {
return this.model.findByIdAndRemove(_id);
}
/**
* Clear all entities from collection
*
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
clear(): any {
return this.model.deleteMany({}).then((res) => res.deletedCount);
}
/**
* Convert DB entity to JSON object
*
* @param {any} entity
* @returns {Object}
* @memberof MongooseDbAdapter
*/
entityToObject(entity) {
let json = entity.toJSON();
if (entity._id && entity._id.toHexString) {
json._id = entity._id.toHexString();
} else if (entity._id && entity._id.toString) {
json._id = entity._id.toString();
}
return json;
}
/**
* Create a filtered query
* Available filters in `params`:
* - search
* - sort
* - limit
* - offset
* - query
*
* @param {Object} params
* @returns {MongoQuery}
*/
createCursor(params) {
if (params) {
const q = this.model.find(params.query);
// Search
if (_.isString(params.search) && params.search !== '') {
if (params.searchFields && params.searchFields.length > 0) {
q.find({
$or: params.searchFields.map((f) => ({
[f]: new RegExp(params.search, 'i'),
})),
});
} else {
// Full-text search
// More info: https://docs.mongodb.com/manual/reference/operator/query/text/
(q as any).find({
$text: {
$search: String(params.search),
},
});
(q as any)._fields = {
_score: {
$meta: 'textScore',
},
};
q.sort({
_score: {
$meta: 'textScore',
},
});
}
}
// Sort
if (_.isString(params.sort)) q.sort(params.sort.replace(/,/, ' '));
else if (Array.isArray(params.sort)) q.sort(params.sort.join(' '));
// Offset
if (_.isNumber(params.offset) && params.offset > 0) q.skip(params.offset);
// Limit
if (_.isNumber(params.limit) && params.limit > 0) q.limit(params.limit);
return q;
}
return this.model.find();
}
/**
* Transforms 'idField' into MongoDB's '_id'
* @param {Object} entity
* @param {String} idField
* @memberof MongoDbAdapter
* @returns {Object} Modified entity
*/
beforeSaveTransformID(entity, idField) {
let newEntity = _.cloneDeep(entity);
if (idField !== '_id' && entity[idField] !== undefined) {
newEntity._id = this.stringToObjectID(newEntity[idField]);
delete newEntity[idField];
}
return newEntity;
}
/**
* Transforms MongoDB's '_id' into user defined 'idField'
* @param {Object} entity
* @param {String} idField
* @memberof MongoDbAdapter
* @returns {Object} Modified entity
*/
afterRetrieveTransformID(entity, idField) {
if (idField !== '_id') {
entity[idField] = this.objectIDToString(entity['_id']);
delete entity._id;
}
return entity;
}
/**
* Convert hex string to ObjectID
* @param {String} id
* @returns ObjectID}
* @memberof MongooseDbAdapter
*/
stringToObjectID(id) {
if (typeof id == 'string' && mongoose.Types.ObjectId.isValid(id))
return new mongoose.Schema.Types.ObjectId(id);
return id;
}
/**
* Convert ObjectID to hex string
* @param {ObjectID} id
* @returns {String}
* @memberof MongooseDbAdapter
*/
objectIDToString(id) {
if (id && id.toString) return id.toString();
return id;
}
}

@ -0,0 +1,74 @@
import dotenv from 'dotenv';
import _ from 'lodash';
dotenv.config();
/**
*
*/
const port = process.env.PORT ? Number(process.env.PORT) : 11000;
const apiUrl = process.env.API_URL || `http://127.0.0.1:${port}`;
export const config = {
port,
secret: process.env.SECRET || 'tailchat',
env: process.env.NODE_ENV || 'development',
/**
* socket admin ui
*/
enableSocketAdmin: !!process.env.ADMIN,
redisUrl: process.env.REDIS_URL,
mongoUrl: process.env.MONGO_URL,
storage: {
type: 'minio', // 可选: minio
minioUrl: process.env.MINIO_URL,
user: process.env.MINIO_USER,
pass: process.env.MINIO_PASS,
bucketName: process.env.MINIO_BUCKET_NAME || 'tailchat',
/**
*
* byte
* 1m
*/
limit: process.env.FILE_LIMIT
? Number(process.env.FILE_LIMIT)
: 1 * 1024 * 1024,
},
apiUrl,
staticUrl: `${apiUrl}/static/`,
enableOpenapi: true, // 是否开始openapi
smtp: {
senderName: process.env.SMTP_SENDER, // 发邮件者显示名称
connectionUrl: process.env.SMTP_URI || '',
},
enablePrometheus: checkEnvTrusty(process.env.PROMETHEUS),
feature: {
disableFileCheck: checkEnvTrusty(process.env.DISABLE_FILE_CHECK),
},
};
export const builtinAuthWhitelist = [
'/gateway/health',
'/debug/hello',
'/user/login',
'/user/register',
'/user/createTemporaryUser',
'/user/resolveToken',
'/user/getUserInfo',
'/group/getGroupBasicInfo',
'/group/invite/findInviteByCode',
];
/**
*
*/
export function buildUploadUrl(objectName: string) {
return config.staticUrl + objectName;
}
/**
* true
*/
export function checkEnvTrusty(env: string): boolean {
return env === '1' || env === 'true';
}

@ -0,0 +1,99 @@
import { Context, Errors, ServiceSchema } from 'moleculer';
import BaseDBService, { MoleculerDB } from 'moleculer-db';
import { MongooseDbAdapter } from '../lib/moleculer-db-adapter-mongoose';
import type { Document, FilterQuery, Model } from 'mongoose';
import { config } from '../lib/settings';
import type { ReturnModelType } from '@typegoose/typegoose';
import type {
AnyParamConstructor,
BeAnObject,
} from '@typegoose/typegoose/lib/types';
type EntityChangedType = 'created' | 'updated';
// type MoleculerDBMethods = MoleculerDB<MongooseDbAdapter>['methods'];
type MoleculerDBMethods = MoleculerDB<any>['methods'];
// fork from moleculer-db-adapter-mongoose/index.d.ts
interface FindFilters<T extends Document> {
query?: FilterQuery<T>;
search?: string;
searchFields?: string[]; // never used???
sort?: string | string[];
offset?: number;
limit?: number;
}
// 复写部分 adapter 的方法类型
interface TcDbAdapterOverwrite<T extends Document, M extends Model<T>> {
model: M;
insert(entity: Partial<T>): Promise<T>;
find(filters: FindFilters<T>): Promise<T>;
findOne(query: FilterQuery<T>): Promise<T | null>;
}
export interface TcDbService<
T extends Document = Document,
M extends Model<T> = Model<T>
> extends MoleculerDBMethods {
entityChanged(type: EntityChangedType, json: {}, ctx: Context): Promise<void>;
adapter: Omit<MongooseDbAdapter<T>, keyof TcDbAdapterOverwrite<T, M>> &
TcDbAdapterOverwrite<T, M>;
/**
* fetch, json
*/
transformDocuments: MoleculerDB<
// @ts-ignore
MongooseDbAdapter<T>
>['methods']['transformDocuments'];
}
export type TcDbModel = ReturnModelType<AnyParamConstructor<any>, BeAnObject>;
/**
* Tc mixin
* @param model
*/
export function TcDbService(model: TcDbModel): Partial<ServiceSchema> {
const actions = {
/**
*
*/
find: false,
count: false,
list: false,
create: false,
insert: false,
get: false,
update: false,
remove: false,
};
const methods = {
/**
*
*/
async entityChanged(type, json, ctx) {
await this.clearCache();
const eventName = `${this.name}.entity.${type}`;
this.broker.emit(eventName, { meta: ctx.meta, entity: json });
},
};
if (!config.mongoUrl) {
throw new Errors.MoleculerClientError('需要环境变量 MONGO_URL');
}
return {
mixins: [BaseDBService],
adapter: new MongooseDbAdapter(config.mongoUrl, {
useNewUrlParser: true,
useUnifiedTopology: true,
}),
model,
actions,
methods,
};
}

@ -0,0 +1,55 @@
import type { Context } from 'moleculer';
import type { TFunction } from 'i18next';
import type { UserStruct } from '../structs/user';
import type { GroupStruct } from '../structs/group';
import type { BuiltinEventMap } from '../structs/events';
export type {
ServiceSchema as PureServiceSchema,
Service as PureService,
} from 'moleculer';
export interface UserJWTPayload {
_id: string;
nickname: string;
email: string;
avatar: string;
}
interface TranslationMeta {
t: TFunction;
language: string;
}
export type PureContext<P = {}, M extends object = {}> = Context<P, M>;
export interface TcPureContext<P = {}, M = {}>
extends Omit<Context<P>, 'emit'> {
meta: TranslationMeta & M;
// 事件类型重写
emit<K extends string>(
eventName: K,
data: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
groups?: string | string[]
): Promise<void>;
emit(eventName: string): Promise<void>;
}
export type TcContext<P = {}, M = {}> = TcPureContext<
P,
{
user: UserJWTPayload;
token: string;
userId: string;
/**
* socket.io
*/
socketId?: string;
} & M
>;
export type GroupBaseInfo = Pick<GroupStruct, 'name' | 'avatar' | 'owner'> & {
memberCount: number;
};

@ -0,0 +1,8 @@
export interface MessageMetaStruct {
mentions?: string[];
reply?: {
_id: string;
author: string;
content: string;
};
}

@ -0,0 +1,19 @@
import type { MessageMetaStruct } from './chat';
/**
*
*/
export interface BuiltinEventMap {
'gateway.auth.addWhitelists': { urls: string[] };
'chat.message.updateMessage':
| {
type: 'add';
messageId: string;
content: string;
meta: MessageMetaStruct;
}
| {
type: 'recall' | 'delete';
messageId: string;
};
}

@ -0,0 +1,52 @@
export enum GroupPanelType {
TEXT = 0,
GROUP = 1,
PLUGIN = 2,
}
interface GroupMemberStruct {
roles?: string[]; // 角色
userId: string;
}
export interface GroupPanelStruct {
id: string; // 在群组中唯一, 可以用任意方式进行生成。这里使用ObjectId, 但不是ObjectId类型
name: string; // 用于显示的名称
parentId?: string; // 父节点id
type: number; // 面板类型: Reference: https://discord.com/developers/docs/resources/channel#channel-object-channel-types
provider?: string; // 面板提供者,为插件的标识,仅面板类型为插件时有效
pluginPanelName?: string; // 插件面板名, 如 com.msgbyte.webview/grouppanel
/**
*
*/
meta?: object;
}
/**
*
*/
export interface GroupRoleStruct {
name: string; // 权限组名
permissions: string[]; // 拥有的权限, 是一段字符串
}
export interface GroupStruct {
name: string;
avatar?: string;
owner: string;
members: GroupMemberStruct[];
panels: GroupPanelStruct[];
roles?: GroupRoleStruct[];
}

@ -0,0 +1,43 @@
const userType = ['normalUser', 'pluginBot', 'thirdpartyBot'];
type UserType = typeof userType[number];
export interface UserStruct {
/**
*
* email
*/
username?: string;
/**
*
*
*/
email: string;
password: string;
/**
*
*/
nickname: string;
/**
* , username
*
* <username>#<discriminator>
*/
discriminator: string;
/**
*
* @default false
*/
temporary: boolean;
/**
*
*/
avatar?: string;
type: UserType[];
}

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"rootDir": "./src",
"outDir": "dist"
},
"include": ["./src/**/*"],
"exclude": ["node_modules/**/*"]
}

@ -0,0 +1,7 @@
const path = require('path');
module.exports = {
externalDeps: ['react'],
pluginRoot: path.resolve(__dirname, './web'),
outDir: path.resolve(__dirname, '../../public'),
};

@ -0,0 +1,35 @@
import {
getModelForClass,
prop,
DocumentType,
modelOptions,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
@modelOptions({
options: {
customName: 'p_githubSubscribe',
},
})
export class Subscribe extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop()
groupId: string;
@prop()
textPanelId: string;
@prop()
repoName: string;
}
export type SubscribeDocument = DocumentType<Subscribe>;
const model = getModelForClass(Subscribe);
export type SubscribeModel = typeof model;
export default model;

@ -0,0 +1,22 @@
{
"name": "tailchat-plugin-github",
"version": "1.0.0",
"main": "index.js",
"author": "moonrailgun",
"license": "MIT",
"private": true,
"scripts": {
"build:web": "ministar buildPlugin all",
"build:web:watch": "ministar watchPlugin all"
},
"dependencies": {
"@octokit/webhooks-types": "^5.4.0",
"react": "^17.0.2"
},
"devDependencies": {
"@types/react": "^17.0.38",
"less": "^4.1.2",
"mini-star": "^1.2.8",
"rollup-plugin-less": "^1.1.3"
}
}

@ -0,0 +1,249 @@
import {
TcService,
TcPureContext,
TcContext,
TcDbService,
} from 'tailchat-server-sdk';
import type { WebhookEvent } from '@octokit/webhooks-types';
import type { SubscribeDocument, SubscribeModel } from '../models/subscribe';
/**
* Github
*/
interface GithubSubscribeService
extends TcService,
TcDbService<SubscribeDocument, SubscribeModel> {}
class GithubSubscribeService extends TcService {
botUserId: string | undefined;
get serviceName() {
return 'plugin:com.msgbyte.github.subscribe';
}
onInit() {
this.registerLocalDb(require('../models/subscribe').default);
this.registerAction('add', this.add, {
params: {
groupId: 'string',
textPanelId: 'string',
repoName: 'string',
},
});
this.registerAction('list', this.list, {
params: {
groupId: 'string',
},
});
this.registerAction('delete', this.delete, {
params: {
groupId: 'string',
subscribeId: 'string',
},
});
this.registerAction('webhook.callback', this.webhookHandler);
this.registerAuthWhitelist([
'/plugin:com.msgbyte.github.subscribe/webhook/callback',
]);
}
protected onInited(): void {
// 确保机器人用户存在, 并记录机器人用户id
this.waitForServices(['user']).then(async () => {
try {
const botUserId = await this.broker.call('user.ensurePluginBot', {
botId: 'github-bot',
nickname: 'Github Bot',
avatar: 'https://api.iconify.design/akar-icons/github-fill.svg',
});
this.logger.info('Github Bot Id:', botUserId);
this.botUserId = String(botUserId);
} catch (e) {
this.logger.error(e);
}
});
}
/**
*
*/
async add(
ctx: TcContext<{
groupId: string;
textPanelId: string;
repoName: string;
}>
) {
const { groupId, textPanelId, repoName } = ctx.params;
if (!groupId || !textPanelId || !repoName) {
throw new Error('参数不全');
}
const isGroupOwner = await ctx.call('group.isGroupOwner', {
groupId,
});
if (isGroupOwner !== true) {
throw new Error('没有操作权限');
}
// TODO: 需要检查textPanelId是否合法
await this.adapter.model.create({
groupId,
textPanelId,
repoName,
});
}
/**
*
*/
async list(
ctx: TcContext<{
groupId: string;
}>
) {
const groupId = ctx.params.groupId;
const docs = await this.adapter.model
.find({
groupId,
})
.exec();
return await this.transformDocuments(ctx, {}, docs);
}
/**
*
*/
async delete(
ctx: TcContext<{
groupId: string;
subscribeId: string;
}>
) {
const { groupId, subscribeId } = ctx.params;
const isGroupOwner = await ctx.call('group.isGroupOwner', {
groupId,
});
if (isGroupOwner !== true) {
throw new Error('没有操作权限');
}
await this.adapter.model.deleteOne({
_id: subscribeId,
});
}
/**
* github webhook
*/
async webhookHandler(ctx: TcPureContext<any>) {
if (!this.botUserId) {
throw new Error('Not github bot');
}
const event = ctx.params as WebhookEvent;
if ('pusher' in event) {
// Is push event
const name = event.pusher.name;
const repo = event.repository.full_name;
const compareUrl = event.compare;
const commits = event.commits.map((c) => `- ${c.message}`).join('\n');
const message = `${name}${repo} 提交了新的内容:\n${commits}\n\n查看改动: ${compareUrl}`;
await this.sendMessageToSubscribes(ctx, repo, message);
} else if ('pull_request' in event) {
const name = event.sender.name;
const repo = event.repository.full_name;
const url = event.pull_request.url;
const title = event.pull_request.title;
const body = event.pull_request.body;
let message = `${name}${repo} 更新了PR请求:\n网址: ${url}`;
if (event.action === 'created') {
message = `${name}${repo} 创建了PR请求:\n${title}\n${body}\n\n网址: ${url}`;
}
await this.sendMessageToSubscribes(ctx, repo, message);
} else if ('issue' in event) {
const name = event.sender.name;
const repo = event.repository.full_name;
const url = event.issue.url;
const title = event.issue.title;
const body = event.issue.body;
let message = `${name}${repo} 更新了Issue:\n网址: ${url}`;
if (event.action === 'created') {
message = `${name}${repo} 创建了Issue:\n${title}\n${body}\n\n网址: ${url}`;
}
await this.sendMessageToSubscribes(ctx, repo, message);
}
}
/**
*
*/
private async sendMessageToSubscribes(
ctx: TcPureContext,
repoName: string,
message: string
) {
const subscribes = await this.adapter.model.find({
repoName,
});
this.logger.info(
'发送Github推送通知:',
subscribes
.map((s) => `${s.repoName}|${s.groupId}|${s.textPanelId}`)
.join(',')
);
for (const s of subscribes) {
const groupId = String(s.groupId);
const converseId = String(s.textPanelId);
this.sendPluginBotMessage(ctx, {
groupId,
converseId,
content: message,
});
}
}
private async sendPluginBotMessage(
ctx: TcPureContext<any>,
messagePayload: {
converseId: string;
groupId?: string;
content: string;
meta?: any;
}
) {
const res = await ctx.call(
'chat.message.sendMessage',
{
...messagePayload,
},
{
meta: {
userId: this.botUserId,
},
}
);
return res;
}
}
export default GithubSubscribeService;

@ -0,0 +1,9 @@
{
"label": "Github 订阅",
"name": "com.msgbyte.github",
"url": "{BACKEND}/plugins/com.msgbyte.github/index.js",
"version": "0.0.0",
"author": "msgbyte",
"description": "订阅Github项目动态到群组",
"requireRestart": true
}

@ -0,0 +1,7 @@
{
"name": "@plugins/com.msgbyte.github",
"main": "src/index.tsx",
"version": "0.0.0",
"private": true,
"dependencies": {}
}

@ -0,0 +1,79 @@
import React, { useMemo } from 'react';
import {
ModalWrapper,
createFastFormSchema,
fieldSchema,
useAsyncRequest,
showToasts,
} from '@capital/common';
import { WebFastForm, GroupPanelSelector } from '@capital/component';
import { request } from '../request';
import { Translate } from '../translate';
interface Values {
repoName: string;
textPanelId: string;
}
const schema = createFastFormSchema({
repoName: fieldSchema.string().required(Translate.repoNameEmpty),
textPanelId: fieldSchema.string().required(Translate.textPanelEmpty),
});
export const AddGroupSubscribeModal: React.FC<{
groupId: string;
onSuccess?: () => void;
}> = React.memo((props) => {
const groupId = props.groupId;
const [, handleSubmit] = useAsyncRequest(
async (values: Values) => {
const { repoName, textPanelId } = values;
await request.post('subscribe.add', {
groupId,
textPanelId,
repoName,
});
showToasts(Translate.success, 'success');
props.onSuccess?.();
},
[groupId, props.onSuccess]
);
const fields = useMemo(
() => [
{
type: 'text',
name: 'repoName',
label: Translate.repoName,
placeholder: 'msgbyte/tailchat',
},
{
type: 'custom',
name: 'textPanelId',
label: Translate.textPanel,
render: (props: {
value: any;
error: string | undefined;
onChange: (val: any) => void; // 修改数据的回调函数
}) => {
return (
<GroupPanelSelector
value={props.value}
onChange={props.onChange}
groupId={groupId}
/>
);
},
},
],
[groupId]
);
return (
<ModalWrapper title={Translate.createApplication}>
<WebFastForm schema={schema} fields={fields} onSubmit={handleSubmit} />
</ModalWrapper>
);
});
AddGroupSubscribeModal.displayName = 'AddGroupSubscribeModal';

@ -0,0 +1,146 @@
import React, { useCallback, useMemo } from 'react';
import {
openModal,
closeModal,
useGroupIdContext,
useAsyncRefresh,
useAsyncRequest,
getServiceUrl,
useGroupPanelInfo,
} from '@capital/common';
import { Button, Space, Table } from '@capital/component';
import { Translate } from '../translate';
import { AddGroupSubscribeModal } from './AddGroupSubscribeModal';
import { request } from '../request';
interface SubscribeItem {
_id: string;
groupId: string;
repoName: string;
textPanelId: string;
createdAt: string;
updatedAt: string;
}
const GroupPanelName: React.FC<{
groupId: string;
panelId: string;
}> = React.memo(({ groupId, panelId }) => {
const groupPanelInfo = useGroupPanelInfo(groupId, panelId);
return groupPanelInfo?.name ?? '';
});
const GroupSubscribePanel: React.FC = React.memo(() => {
const groupId = useGroupIdContext();
const { value: subscribes, refresh } = useAsyncRefresh(async () => {
const { data } = await request.post('subscribe.list', { groupId });
return data;
}, [groupId]);
const handleAdd = useCallback(() => {
const key = openModal(
<AddGroupSubscribeModal
groupId={groupId}
onSuccess={() => {
closeModal(key);
refresh();
}}
/>
);
}, [groupId, refresh]);
const [, handleDelete] = useAsyncRequest(
async (subscribeId) => {
await request.post('subscribe.delete', {
groupId,
subscribeId,
});
refresh();
},
[groupId, refresh]
);
const columns = useMemo(
() => [
{
title: Translate.repo,
key: 'repoName',
dataIndex: 'repoName',
},
{
title: Translate.panel,
key: 'textPanelId',
dataIndex: 'textPanelId',
render: (panelId: string) => (
<GroupPanelName groupId={groupId} panelId={panelId} />
),
},
{
title: Translate.createdTime,
key: 'createdAt',
dataIndex: 'createdAt',
render: (date: string) => new Date(date).toLocaleString(),
},
{
title: Translate.action,
key: 'action',
render: (_, record: SubscribeItem) => (
<Space>
<Button onClick={() => handleDelete(record._id)}>
{Translate.delete}
</Button>
</Space>
),
},
],
[handleDelete]
);
return (
<div>
<div
style={{
marginBottom: 10,
display: 'flex',
justifyContent: 'space-between',
}}
>
<h2>{Translate.groupSubscribe}</h2>
<Button type="primary" onClick={handleAdd}>
{Translate.add}
</Button>
</div>
<Table
rowKey="_id"
columns={columns}
dataSource={subscribes}
pagination={false}
/>
{Array.isArray(subscribes) && subscribes.length > 0 && (
<div style={{ marginTop: 10 }}>
<h3>:</h3>
<p>
Github github webhook, :{' '}
<code style={{ userSelect: 'text' }}>
{getServiceUrl()}
/api/plugin:com.msgbyte.github.subscribe/webhook/callback
</code>
</p>
<p>
<code>Content type</code> {' '}
<code>application/json</code>
</p>
</div>
)}
</div>
);
});
GroupSubscribePanel.displayName = 'GroupSubscribePanel';
export default GroupSubscribePanel;

@ -0,0 +1,14 @@
import { regCustomPanel, Loadable, regInspectService } from '@capital/common';
import { Translate } from './translate';
regCustomPanel({
position: 'groupdetail',
name: 'com.msgbyte.github/groupSubscribe',
label: Translate.groupSubscribe,
render: Loadable(() => import('./GroupSubscribePanel')),
});
regInspectService({
name: 'plugin:com.msgbyte.github.subscribe',
label: Translate.githubService,
});

@ -0,0 +1,3 @@
import { createPluginRequest } from '@capital/common';
export const request = createPluginRequest('com.msgbyte.github');

@ -0,0 +1,60 @@
import { localTrans } from '@capital/common';
export const Translate = {
groupSubscribe: localTrans({
'zh-CN': 'Github 群组订阅',
'en-US': 'Github Group Subscribe',
}),
githubService: localTrans({
'zh-CN': 'Github 群组订阅服务',
'en-US': 'Github Group Subscribe Service',
}),
add: localTrans({
'zh-CN': '新增',
'en-US': 'Add',
}),
repo: localTrans({
'zh-CN': '项目',
'en-US': 'Repository',
}),
panel: localTrans({
'zh-CN': '面板',
'en-US': 'Panel',
}),
createdTime: localTrans({
'zh-CN': '创建时间',
'en-US': 'Created Time',
}),
action: localTrans({
'zh-CN': '操作',
'en-US': 'Action',
}),
delete: localTrans({
'zh-CN': '删除',
'en-US': 'Delete',
}),
repoName: localTrans({
'zh-CN': '仓库名',
'en-US': 'Repo Name',
}),
textPanel: localTrans({
'zh-CN': '文本频道',
'en-US': 'Text Channel',
}),
success: localTrans({
'zh-CN': '成功',
'en-US': 'Success',
}),
createApplication: localTrans({
'zh-CN': '创建应用',
'en-US': 'Create Application',
}),
repoNameEmpty: localTrans({
'zh-CN': '仓库名不能为空',
'en-US': 'Github Repo Name Not Allowd Empty',
}),
textPanelEmpty: localTrans({
'zh-CN': '文本频道不能为空',
'en-US': 'Text Panel Not Allowd Empty',
}),
};

@ -0,0 +1,7 @@
{
"compilerOptions": {
"esModuleInterop": true,
"jsx": "react",
"importsNotUsedAsValues": "error"
}
}

@ -0,0 +1,2 @@
declare module '@capital/common';
declare module '@capital/component';

@ -0,0 +1,7 @@
const path = require('path');
module.exports = {
externalDeps: ['react'],
pluginRoot: path.resolve(__dirname, './web'),
outDir: path.resolve(__dirname, '../../public'),
};

@ -0,0 +1,36 @@
import {
getModelForClass,
DocumentType,
modelOptions,
prop,
Severity,
index,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
@modelOptions({
options: {
customName: 'p_linkmeta',
allowMixed: Severity.ALLOW,
},
})
@index({ url: 1 })
export class Linkmeta extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop()
url: string;
@prop()
data: any;
}
export type LinkmetaDocument = DocumentType<Linkmeta>;
const model = getModelForClass(Linkmeta);
export type LinkmetaModel = typeof model;
export default model;

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save