mirror of https://github.com/msgbyte/tailchat
chore: move tailchat-server into tailchat
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 }}
|
@ -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 @@
|
||||
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,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;
|
||||
|
||||
/**
|
||||
* 根据appId获取openapp的实例
|
||||
* 用于获得获得完整数据(包括secret)
|
||||
* 并顺便判断是否拥有该开放平台用户的修改权限
|
||||
*/
|
||||
static async findAppByIdAndOwner(
|
||||
this: ReturnModelType<typeof OpenApp>,
|
||||
appId: string,
|
||||
ownerId: string
|
||||
) {
|
||||
const res = await this.findOne({
|
||||
appId,
|
||||
owner: ownerId,
|
||||
}).exec();
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAppDocument = DocumentType<OpenApp>;
|
||||
|
||||
const model = getModelForClass(OpenApp);
|
||||
|
||||
export type OpenAppModel = typeof model;
|
||||
|
||||
export default model;
|
@ -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 @@
|
||||
dist
|
@ -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,8 @@
|
||||
import Moleculer from 'moleculer';
|
||||
|
||||
/**
|
||||
* 用于不暴露moleculer让外部手动启动一个broker
|
||||
*
|
||||
* 如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 @@
|
||||
fork from `moleculer-db-adapter-mongoose`
|
@ -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…
Reference in New Issue